Compare commits
7 Commits
b2ccfe8b4b
...
b864b20bfa
| Author | SHA1 | Date | |
|---|---|---|---|
| b864b20bfa | |||
| 0fd522eaa7 | |||
| 4cf7288857 | |||
| f69814ce98 | |||
| 47077552cf | |||
| db02d6eb01 | |||
| 90fe2c8e87 |
@ -1,2 +1,2 @@
|
|||||||
import{a as t}from"./index-BuDjHQd8.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BMozKGOM.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
|
import{a as t}from"./index-Du2Seh2m.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BMozKGOM.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
|
||||||
//# sourceMappingURL=conclusionApi-XzPsIGYn.js.map
|
//# sourceMappingURL=conclusionApi-DbH35Tyj.js.map
|
||||||
@ -1 +1 @@
|
|||||||
{"version":3,"file":"conclusionApi-XzPsIGYn.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 */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"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,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
|
{"version":3,"file":"conclusionApi-DbH35Tyj.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 */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"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,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}
|
||||||
1
build/assets/index-BXYPuXlF.css
Normal file
1
build/assets/index-BXYPuXlF.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
75
build/assets/index-Du2Seh2m.js
Normal file
75
build/assets/index-Du2Seh2m.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-Du2Seh2m.js.map
Normal file
1
build/assets/index-Du2Seh2m.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -1,2 +1,2 @@
|
|||||||
import{g as s}from"./index-BuDjHQd8.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BMozKGOM.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
|
import{g as s}from"./index-Du2Seh2m.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BMozKGOM.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
|
||||||
//# sourceMappingURL=requestNavigation-Dsrv0hfs.js.map
|
//# sourceMappingURL=requestNavigation-DA4yyAJr.js.map
|
||||||
@ -1 +1 @@
|
|||||||
{"version":3,"file":"requestNavigation-Dsrv0hfs.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}
|
{"version":3,"file":"requestNavigation-DA4yyAJr.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}
|
||||||
@ -52,7 +52,7 @@
|
|||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-BuDjHQd8.js"></script>
|
<script type="module" crossorigin src="/assets/index-Du2Seh2m.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
||||||
@ -60,7 +60,7 @@
|
|||||||
<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-CRr9x_Jp.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-vendor-CRr9x_Jp.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DImBgs5K.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BXYPuXlF.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
277
docs/GOOGLE_SECRET_MANAGER_SETUP.md
Normal file
277
docs/GOOGLE_SECRET_MANAGER_SETUP.md
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# Google Secret Manager Integration Guide
|
||||||
|
|
||||||
|
This guide explains how to integrate Google Cloud Secret Manager with your Node.js application to securely manage environment variables.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Google Secret Manager integration allows you to:
|
||||||
|
- Store sensitive configuration values (passwords, API keys, tokens) in Google Cloud Secret Manager
|
||||||
|
- Load secrets at application startup and merge them with your existing environment variables
|
||||||
|
- Maintain backward compatibility with `.env` files for local development
|
||||||
|
- Use minimal code changes - existing `process.env.VARIABLE_NAME` access continues to work
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Google Cloud Project** with Secret Manager API enabled
|
||||||
|
2. **Service Account** with Secret Manager Secret Accessor role
|
||||||
|
3. **Authentication** - Service account credentials configured (via `GCP_KEY_FILE` or default credentials)
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Enable Secret Manager API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Secrets in Google Secret Manager
|
||||||
|
|
||||||
|
Create secrets using the Google Cloud Console or gcloud CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Create a database password secret
|
||||||
|
echo -n "your-secure-password" | gcloud secrets create DB_PASSWORD \
|
||||||
|
--project=YOUR_PROJECT_ID \
|
||||||
|
--data-file=-
|
||||||
|
|
||||||
|
# Example: Create a JWT secret
|
||||||
|
echo -n "your-jwt-secret-key" | gcloud secrets create JWT_SECRET \
|
||||||
|
--project=YOUR_PROJECT_ID \
|
||||||
|
--data-file=-
|
||||||
|
|
||||||
|
# Grant service account access to secrets
|
||||||
|
gcloud secrets add-iam-policy-binding DB_PASSWORD \
|
||||||
|
--member="serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/secretmanager.secretAccessor" \
|
||||||
|
--project=YOUR_PROJECT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Environment Variables
|
||||||
|
|
||||||
|
Add the following to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Google Secret Manager Configuration
|
||||||
|
USE_GOOGLE_SECRET_MANAGER=true
|
||||||
|
GCP_PROJECT_ID=your-project-id
|
||||||
|
|
||||||
|
# Optional: Prefix for all secret names (e.g., "prod" -> looks for "prod-DB_PASSWORD")
|
||||||
|
GCP_SECRET_PREFIX=
|
||||||
|
|
||||||
|
# Optional: JSON file mapping secret names to env var names
|
||||||
|
GCP_SECRET_MAP_FILE=./secret-map.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Set `USE_GOOGLE_SECRET_MANAGER=true` to enable the integration
|
||||||
|
- `GCP_PROJECT_ID` must be set (same as used for GCS/Vertex AI)
|
||||||
|
- `GCP_KEY_FILE` should already be configured for other GCP services
|
||||||
|
- When `USE_GOOGLE_SECRET_MANAGER=false` or not set, the app uses `.env` file only
|
||||||
|
|
||||||
|
### 4. Secret Name Mapping
|
||||||
|
|
||||||
|
By default, secrets in Google Secret Manager are automatically mapped to environment variables:
|
||||||
|
- Secret name: `DB_PASSWORD` → Environment variable: `DB_PASSWORD`
|
||||||
|
- Secret name: `db-password` → Environment variable: `DB_PASSWORD` (hyphens converted to underscores, uppercase)
|
||||||
|
- Secret name: `jwt-secret-key` → Environment variable: `JWT_SECRET_KEY`
|
||||||
|
|
||||||
|
#### Custom Mapping (Optional)
|
||||||
|
|
||||||
|
If you need custom mappings, create a JSON file (e.g., `secret-map.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"db-password-prod": "DB_PASSWORD",
|
||||||
|
"jwt-secret-key": "JWT_SECRET",
|
||||||
|
"okta-client-secret-prod": "OKTA_CLIENT_SECRET"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set in `.env`:
|
||||||
|
```env
|
||||||
|
GCP_SECRET_MAP_FILE=./secret-map.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Secret Prefix (Optional)
|
||||||
|
|
||||||
|
If all your secrets share a common prefix:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GCP_SECRET_PREFIX=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
This will look for secrets named `prod-DB_PASSWORD`, `prod-JWT_SECRET`, etc.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Application Startup:**
|
||||||
|
- `.env` file is loaded first (provides fallback values)
|
||||||
|
- If `USE_GOOGLE_SECRET_MANAGER=true`, secrets are fetched from Google Secret Manager
|
||||||
|
- Secrets are merged into `process.env`, overriding `.env` values if they exist
|
||||||
|
- Application continues with merged environment variables
|
||||||
|
|
||||||
|
2. **Fallback Behavior:**
|
||||||
|
- If Secret Manager is disabled or fails, the app falls back to `.env` file
|
||||||
|
- No errors are thrown - the app continues with available configuration
|
||||||
|
- Logs indicate whether secrets were loaded successfully
|
||||||
|
|
||||||
|
3. **Existing Code Compatibility:**
|
||||||
|
- No changes needed to existing code
|
||||||
|
- Continue using `process.env.VARIABLE_NAME` as before
|
||||||
|
- Secrets from GCS automatically populate `process.env`
|
||||||
|
|
||||||
|
## Default Secrets Loaded
|
||||||
|
|
||||||
|
The service automatically attempts to load these common secrets (if they exist in Secret Manager):
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `SESSION_SECRET`
|
||||||
|
|
||||||
|
**SSO/Okta:**
|
||||||
|
- `OKTA_DOMAIN`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_API_TOKEN`
|
||||||
|
|
||||||
|
**Email:**
|
||||||
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`
|
||||||
|
|
||||||
|
**Web Push (VAPID):**
|
||||||
|
- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`
|
||||||
|
|
||||||
|
**Logging:**
|
||||||
|
- `LOKI_HOST`, `LOKI_USER`, `LOKI_PASSWORD`
|
||||||
|
|
||||||
|
### Loading Custom Secrets
|
||||||
|
|
||||||
|
To load additional secrets, modify the code:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In server.ts or app.ts
|
||||||
|
import { googleSecretManager } from './services/googleSecretManager.service';
|
||||||
|
|
||||||
|
// Load default secrets + custom ones
|
||||||
|
await googleSecretManager.loadSecrets([
|
||||||
|
'DB_PASSWORD',
|
||||||
|
'JWT_SECRET',
|
||||||
|
'CUSTOM_API_KEY', // Your custom secret
|
||||||
|
'CUSTOM_SECRET_2'
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or load a single secret on-demand:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { googleSecretManager } from './services/googleSecretManager.service';
|
||||||
|
|
||||||
|
const apiKey = await googleSecretManager.getSecretValue('CUSTOM_API_KEY', 'API_KEY');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. **Service Account Permissions:**
|
||||||
|
- Grant only `roles/secretmanager.secretAccessor` role
|
||||||
|
- Use separate service accounts for different environments
|
||||||
|
- Never grant `roles/owner` or `roles/editor` to service accounts
|
||||||
|
|
||||||
|
2. **Secret Rotation:**
|
||||||
|
- Rotate secrets regularly in Google Secret Manager
|
||||||
|
- The app automatically uses the `latest` version of each secret
|
||||||
|
- No code changes needed when secrets are rotated
|
||||||
|
|
||||||
|
3. **Environment Separation:**
|
||||||
|
- Use different Google Cloud projects for dev/staging/prod
|
||||||
|
- Use `GCP_SECRET_PREFIX` to namespace secrets by environment
|
||||||
|
- Never commit `.env` files with production secrets to version control
|
||||||
|
|
||||||
|
4. **Access Control:**
|
||||||
|
- Use IAM policies to control who can read secrets
|
||||||
|
- Enable audit logging for secret access
|
||||||
|
- Regularly review secret access logs
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Secrets Not Loading
|
||||||
|
|
||||||
|
**Check logs for:**
|
||||||
|
```
|
||||||
|
[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)
|
||||||
|
[Secret Manager] GCP_PROJECT_ID not set, skipping Google Secret Manager
|
||||||
|
[Secret Manager] Failed to load secrets: [error message]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
1. `USE_GOOGLE_SECRET_MANAGER` not set to `true`
|
||||||
|
2. `GCP_PROJECT_ID` not configured
|
||||||
|
3. Service account lacks Secret Manager permissions
|
||||||
|
4. Secrets don't exist in Secret Manager
|
||||||
|
5. Incorrect secret names (check case sensitivity)
|
||||||
|
|
||||||
|
### Service Account Authentication
|
||||||
|
|
||||||
|
Ensure service account credentials are available:
|
||||||
|
- Set `GCP_KEY_FILE` to point to service account JSON file
|
||||||
|
- Or configure Application Default Credentials (ADC)
|
||||||
|
- Test with: `gcloud auth application-default login`
|
||||||
|
|
||||||
|
### Secret Not Found
|
||||||
|
|
||||||
|
If a secret doesn't exist in Secret Manager:
|
||||||
|
- The app logs a debug message and continues
|
||||||
|
- Falls back to `.env` file value
|
||||||
|
- This is expected behavior - not all secrets need to be in GCS
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
Enable debug logging by setting:
|
||||||
|
```env
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
This will show detailed logs about which secrets are being loaded.
|
||||||
|
|
||||||
|
## Example Configuration
|
||||||
|
|
||||||
|
**Local Development (.env):**
|
||||||
|
```env
|
||||||
|
USE_GOOGLE_SECRET_MANAGER=false
|
||||||
|
DB_PASSWORD=local-dev-password
|
||||||
|
JWT_SECRET=local-jwt-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production (.env):**
|
||||||
|
```env
|
||||||
|
USE_GOOGLE_SECRET_MANAGER=true
|
||||||
|
GCP_PROJECT_ID=re-platform-workflow-dealer
|
||||||
|
GCP_SECRET_PREFIX=prod
|
||||||
|
GCP_KEY_FILE=./credentials/service-account.json
|
||||||
|
# DB_PASSWORD and other secrets loaded from GCS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
1. **Phase 1: Setup**
|
||||||
|
- Create secrets in Google Secret Manager
|
||||||
|
- Keep `.env` file with current values (as backup)
|
||||||
|
|
||||||
|
2. **Phase 2: Test**
|
||||||
|
- Set `USE_GOOGLE_SECRET_MANAGER=true` in development
|
||||||
|
- Verify secrets are loaded correctly
|
||||||
|
- Test application functionality
|
||||||
|
|
||||||
|
3. **Phase 3: Production**
|
||||||
|
- Deploy with `USE_GOOGLE_SECRET_MANAGER=true`
|
||||||
|
- Monitor logs for secret loading success
|
||||||
|
- Remove sensitive values from `.env` file (keep placeholders)
|
||||||
|
|
||||||
|
4. **Phase 4: Cleanup**
|
||||||
|
- Remove production secrets from `.env` file
|
||||||
|
- Ensure all secrets are in Secret Manager
|
||||||
|
- Document secret names and mappings
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Google Secret Manager Documentation](https://cloud.google.com/secret-manager/docs)
|
||||||
|
- [Secret Manager Client Library](https://cloud.google.com/nodejs/docs/reference/secret-manager/latest)
|
||||||
|
- [Service Account Best Practices](https://cloud.google.com/iam/docs/best-practices-service-accounts)
|
||||||
|
|
||||||
@ -30,6 +30,13 @@ GCP_PROJECT_ID=re-workflow-project
|
|||||||
GCP_BUCKET_NAME=re-workflow-documents
|
GCP_BUCKET_NAME=re-workflow-documents
|
||||||
GCP_KEY_FILE=./config/gcp-key.json
|
GCP_KEY_FILE=./config/gcp-key.json
|
||||||
|
|
||||||
|
# Google Secret Manager (Optional - for production)
|
||||||
|
# Set USE_GOOGLE_SECRET_MANAGER=true to enable loading secrets from Google Secret Manager
|
||||||
|
# Secrets from GCS will override .env file values
|
||||||
|
USE_GOOGLE_SECRET_MANAGER=false
|
||||||
|
# GCP_SECRET_PREFIX=optional-prefix-for-secret-names (e.g., "prod" -> looks for "prod-DB_PASSWORD")
|
||||||
|
# GCP_SECRET_MAP_FILE=./secret-map.json (optional JSON file to map secret names to env var names)
|
||||||
|
|
||||||
# Email Service (Optional)
|
# Email Service (Optional)
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
395
package-lock.json
generated
395
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "re-workflow-backend",
|
"name": "re-workflow-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/secret-manager": "^6.1.1",
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
"@google-cloud/vertexai": "^1.10.0",
|
"@google-cloud/vertexai": "^1.10.0",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
@ -1614,6 +1615,18 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google-cloud/secret-manager": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-dwSuxJ9RNmAW46FjK1StiNIeOiSHHQs/XIy4VArJ6bBMR+WsIvR+zhPh2pa40aFa9uTty67j38Rl268TVV62EA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"google-gax": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@google-cloud/storage": {
|
"node_modules/@google-cloud/storage": {
|
||||||
"version": "7.18.0",
|
"version": "7.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz",
|
||||||
@ -1682,6 +1695,37 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@grpc/grpc-js": {
|
||||||
|
"version": "1.14.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||||
|
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
|
"@js-sdsl/ordered-map": "^4.4.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@grpc/proto-loader": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"long": "^5.0.0",
|
||||||
|
"protobufjs": "^7.5.3",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@ -1744,7 +1788,6 @@
|
|||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^5.1.2",
|
"string-width": "^5.1.2",
|
||||||
@ -1762,7 +1805,6 @@
|
|||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -1775,7 +1817,6 @@
|
|||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -1788,14 +1829,12 @@
|
|||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eastasianwidth": "^0.2.0",
|
"eastasianwidth": "^0.2.0",
|
||||||
@ -1813,7 +1852,6 @@
|
|||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^6.0.1"
|
"ansi-regex": "^6.0.1"
|
||||||
@ -1829,7 +1867,6 @@
|
|||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^6.1.0",
|
"ansi-styles": "^6.1.0",
|
||||||
@ -2302,6 +2339,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@js-sdsl/ordered-map": {
|
||||||
|
"version": "4.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
|
||||||
|
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||||
@ -2761,7 +2808,6 @@
|
|||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -4348,7 +4394,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -4358,7 +4403,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@ -4629,7 +4673,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
@ -4785,7 +4828,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@ -5095,7 +5137,6 @@
|
|||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@ -5150,7 +5191,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@ -5163,7 +5203,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-string": {
|
"node_modules/color-string": {
|
||||||
@ -5439,7 +5478,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
@ -5450,6 +5488,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
@ -5664,7 +5711,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
@ -5741,7 +5787,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enabled": {
|
"node_modules/enabled": {
|
||||||
@ -5903,7 +5948,6 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@ -6408,6 +6452,29 @@
|
|||||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@ -6535,7 +6602,6 @@
|
|||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.6",
|
"cross-spawn": "^7.0.6",
|
||||||
@ -6552,7 +6618,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
@ -6577,6 +6642,18 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/formidable": {
|
"node_modules/formidable": {
|
||||||
"version": "3.5.4",
|
"version": "3.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||||
@ -6717,7 +6794,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@ -6916,6 +6992,203 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/google-gax": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@grpc/grpc-js": "^1.12.6",
|
||||||
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
|
"duplexify": "^4.1.3",
|
||||||
|
"google-auth-library": "^10.1.0",
|
||||||
|
"google-logging-utils": "^1.1.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"object-hash": "^3.0.0",
|
||||||
|
"proto3-json-serializer": "^3.0.0",
|
||||||
|
"protobufjs": "^7.5.3",
|
||||||
|
"retry-request": "^8.0.0",
|
||||||
|
"rimraf": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/gaxios": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.1",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"rimraf": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/gcp-metadata": {
|
||||||
|
"version": "8.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||||
|
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"google-logging-utils": "^1.0.0",
|
||||||
|
"json-bigint": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/google-auth-library": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"gcp-metadata": "^8.0.0",
|
||||||
|
"google-logging-utils": "^1.0.0",
|
||||||
|
"gtoken": "^8.0.0",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/google-logging-utils": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/gtoken": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"gaxios": "^7.0.0",
|
||||||
|
"jws": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/retry-request": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"teeny-request": "^10.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/rimraf": {
|
||||||
|
"version": "5.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
|
||||||
|
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^10.3.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rimraf": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/teeny-request": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"http-proxy-agent": "^5.0.0",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"stream-events": "^1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/google-gax/node_modules/teeny-request/node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/google-logging-utils": {
|
"node_modules/google-logging-utils": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||||
@ -7334,7 +7607,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -7395,7 +7667,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
@ -7473,7 +7744,6 @@
|
|||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/cliui": "^8.0.2"
|
"@isaacs/cliui": "^8.0.2"
|
||||||
@ -8360,6 +8630,12 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.camelcase": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.defaults": {
|
"node_modules/lodash.defaults": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
@ -8623,7 +8899,6 @@
|
|||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^2.0.1"
|
||||||
@ -8648,7 +8923,6 @@
|
|||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@ -8841,6 +9115,26 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
@ -9023,6 +9317,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@ -9174,7 +9477,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
@ -9278,7 +9580,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -9295,7 +9596,6 @@
|
|||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru-cache": "^10.2.0",
|
"lru-cache": "^10.2.0",
|
||||||
@ -9312,7 +9612,6 @@
|
|||||||
"version": "10.4.3",
|
"version": "10.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
@ -9682,6 +9981,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/proto3-json-serializer": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"protobufjs": "^7.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||||
@ -9888,7 +10199,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@ -10313,7 +10623,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
@ -10326,7 +10635,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@ -10722,7 +11030,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@ -10738,7 +11045,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@ -10753,7 +11059,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@ -10767,7 +11072,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@ -11620,6 +11924,15 @@
|
|||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
@ -11640,7 +11953,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
@ -11734,7 +12046,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@ -11753,7 +12064,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@ -11823,7 +12133,6 @@
|
|||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@ -11840,7 +12149,6 @@
|
|||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^8.0.1",
|
"cliui": "^8.0.1",
|
||||||
@ -11859,7 +12167,6 @@
|
|||||||
"version": "21.1.1",
|
"version": "21.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
|
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google-cloud/secret-manager": "^6.1.1",
|
||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
"@google-cloud/vertexai": "^1.10.0",
|
"@google-cloud/vertexai": "^1.10.0",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
|
|||||||
11
secret-map.example.json
Normal file
11
secret-map.example.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Optional: Map Google Secret Manager secret names to environment variable names",
|
||||||
|
"_comment2": "If not provided, secrets are mapped automatically: secret-name -> SECRET_NAME (uppercase)",
|
||||||
|
|
||||||
|
"examples": {
|
||||||
|
"db-password": "DB_PASSWORD",
|
||||||
|
"jwt-secret-key": "JWT_SECRET",
|
||||||
|
"okta-client-secret": "OKTA_CLIENT_SECRET"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
15
src/app.ts
15
src/app.ts
@ -10,11 +10,24 @@ import { corsMiddleware } from './middlewares/cors.middleware';
|
|||||||
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
||||||
import routes from './routes/index';
|
import routes from './routes/index';
|
||||||
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
||||||
|
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables from .env file first
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
// Initialize Google Secret Manager (async, but we'll wait for it in server.ts)
|
||||||
|
// This will merge secrets from GCS into process.env if USE_GOOGLE_SECRET_MANAGER=true
|
||||||
|
// Export initialization function so server.ts can await it before starting
|
||||||
|
export async function initializeSecrets(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await initializeGoogleSecretManager();
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw - allow fallback to .env
|
||||||
|
console.error('⚠️ Failed to initialize Google Secret Manager, using .env file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const app: express.Application = express();
|
const app: express.Application = express();
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
import { SSOConfig, SSOUserData } from '../types/auth.types';
|
import { SSOConfig, SSOUserData } from '../types/auth.types';
|
||||||
|
|
||||||
|
// Use getter functions to read from process.env dynamically
|
||||||
|
// This ensures values are read after secrets are loaded from Google Secret Manager
|
||||||
const ssoConfig: SSOConfig = {
|
const ssoConfig: SSOConfig = {
|
||||||
jwtSecret: process.env.JWT_SECRET || '',
|
get jwtSecret() { return process.env.JWT_SECRET || ''; },
|
||||||
jwtExpiry: process.env.JWT_EXPIRY || '24h',
|
get jwtExpiry() { return process.env.JWT_EXPIRY || '24h'; },
|
||||||
refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY || '7d',
|
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; },
|
||||||
sessionSecret: process.env.SESSION_SECRET || '',
|
get sessionSecret() { return process.env.SESSION_SECRET || ''; },
|
||||||
// Use only FRONTEND_URL from environment - no fallbacks
|
// Use only FRONTEND_URL from environment - no fallbacks
|
||||||
allowedOrigins: process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [],
|
get allowedOrigins() {
|
||||||
|
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
||||||
|
},
|
||||||
// Okta/Auth0 configuration for token exchange
|
// Okta/Auth0 configuration for token exchange
|
||||||
oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com',
|
get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; },
|
||||||
oktaClientId: process.env.OKTA_CLIENT_ID || '',
|
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
||||||
oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
|
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
||||||
oktaApiToken: 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
|
||||||
tanflowBaseUrl: process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE',
|
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; },
|
||||||
tanflowClientId: process.env.TANFLOW_CLIENT_ID || 'REFLOW',
|
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
|
||||||
tanflowClientSecret: process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox',
|
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; },
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ssoConfig };
|
export { ssoConfig };
|
||||||
|
|||||||
@ -133,16 +133,84 @@ export class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await Document.create({
|
// Check if storageUrl exceeds database column limit (500 chars)
|
||||||
|
// GCS signed URLs can be very long (500-1000+ chars)
|
||||||
|
const MAX_STORAGE_URL_LENGTH = 500;
|
||||||
|
let finalStorageUrl = storageUrl;
|
||||||
|
if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) {
|
||||||
|
logWithContext('warn', 'Storage URL exceeds database column limit, truncating', {
|
||||||
|
originalLength: storageUrl.length,
|
||||||
|
maxLength: MAX_STORAGE_URL_LENGTH,
|
||||||
|
urlPrefix: storageUrl.substring(0, 100),
|
||||||
|
});
|
||||||
|
// For signed URLs, we can't truncate as it will break the URL
|
||||||
|
// Instead, store null and generate signed URLs on-demand when needed
|
||||||
|
// The filePath is sufficient to generate a new signed URL later
|
||||||
|
finalStorageUrl = null as any;
|
||||||
|
logWithContext('info', 'Storing null storageUrl - will generate signed URL on-demand', {
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
reason: 'Signed URL too long for database column',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate file names if they exceed database column limits (255 chars)
|
||||||
|
const MAX_FILE_NAME_LENGTH = 255;
|
||||||
|
const originalFileName = file.originalname;
|
||||||
|
let truncatedOriginalFileName = originalFileName;
|
||||||
|
|
||||||
|
if (originalFileName.length > MAX_FILE_NAME_LENGTH) {
|
||||||
|
// Preserve file extension when truncating
|
||||||
|
const ext = path.extname(originalFileName);
|
||||||
|
const nameWithoutExt = path.basename(originalFileName, ext);
|
||||||
|
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
|
||||||
|
|
||||||
|
if (maxNameLength > 0) {
|
||||||
|
truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
|
||||||
|
} else {
|
||||||
|
// If extension itself is too long, just use the extension
|
||||||
|
truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
logWithContext('warn', 'File name truncated to fit database column', {
|
||||||
|
originalLength: originalFileName.length,
|
||||||
|
truncatedLength: truncatedOriginalFileName.length,
|
||||||
|
originalName: originalFileName.substring(0, 100) + '...',
|
||||||
|
truncatedName: truncatedOriginalFileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fileName (basename of the generated file name in GCS)
|
||||||
|
const generatedFileName = path.basename(gcsFilePath);
|
||||||
|
let truncatedFileName = generatedFileName;
|
||||||
|
|
||||||
|
if (generatedFileName.length > MAX_FILE_NAME_LENGTH) {
|
||||||
|
const ext = path.extname(generatedFileName);
|
||||||
|
const nameWithoutExt = path.basename(generatedFileName, ext);
|
||||||
|
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
|
||||||
|
|
||||||
|
if (maxNameLength > 0) {
|
||||||
|
truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
|
||||||
|
} else {
|
||||||
|
truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
logWithContext('warn', 'Generated file name truncated', {
|
||||||
|
originalLength: generatedFileName.length,
|
||||||
|
truncatedLength: truncatedFileName.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare document data
|
||||||
|
const documentData = {
|
||||||
requestId,
|
requestId,
|
||||||
uploadedBy: userId,
|
uploadedBy: userId,
|
||||||
fileName: path.basename(file.filename || file.originalname),
|
fileName: truncatedFileName,
|
||||||
originalFileName: file.originalname,
|
originalFileName: truncatedOriginalFileName,
|
||||||
fileType: extension,
|
fileType: extension,
|
||||||
fileExtension: extension,
|
fileExtension: extension,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
filePath: gcsFilePath, // Store GCS path or local path
|
filePath: gcsFilePath, // Store GCS path or local path
|
||||||
storageUrl: storageUrl, // Store GCS URL or local URL
|
storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long)
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
checksum,
|
checksum,
|
||||||
isGoogleDoc: false,
|
isGoogleDoc: false,
|
||||||
@ -152,7 +220,43 @@ export class DocumentController {
|
|||||||
parentDocumentId: null as any,
|
parentDocumentId: null as any,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
};
|
||||||
|
|
||||||
|
logWithContext('info', 'Creating document record', {
|
||||||
|
requestId,
|
||||||
|
userId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: storageUrl,
|
||||||
|
documentData: JSON.stringify(documentData, null, 2),
|
||||||
|
});
|
||||||
|
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = await Document.create(documentData as any);
|
||||||
|
logWithContext('info', 'Document record created successfully', {
|
||||||
|
documentId: doc.documentId,
|
||||||
|
requestId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
});
|
||||||
|
} catch (createError) {
|
||||||
|
const createErrorMessage = createError instanceof Error ? createError.message : 'Unknown error';
|
||||||
|
const createErrorStack = createError instanceof Error ? createError.stack : undefined;
|
||||||
|
// Check if it's a Sequelize validation error
|
||||||
|
const sequelizeError = (createError as any)?.errors || (createError as any)?.parent;
|
||||||
|
logWithContext('error', 'Document.create() failed', {
|
||||||
|
error: createErrorMessage,
|
||||||
|
stack: createErrorStack,
|
||||||
|
sequelizeErrors: sequelizeError,
|
||||||
|
requestId,
|
||||||
|
userId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: storageUrl,
|
||||||
|
documentData: JSON.stringify(documentData, null, 2),
|
||||||
|
});
|
||||||
|
throw createError; // Re-throw to be caught by outer catch block
|
||||||
|
}
|
||||||
|
|
||||||
// Log document upload event
|
// Log document upload event
|
||||||
logDocumentEvent('uploaded', doc.documentId, {
|
logDocumentEvent('uploaded', doc.documentId, {
|
||||||
|
|||||||
@ -279,27 +279,114 @@ export class WorkflowController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = await Document.create({
|
// Truncate file names if they exceed database column limits (255 chars)
|
||||||
requestId: workflow.requestId,
|
const MAX_FILE_NAME_LENGTH = 255;
|
||||||
uploadedBy: userId,
|
const originalFileName = file.originalname;
|
||||||
fileName: path.basename(file.filename || file.originalname),
|
let truncatedOriginalFileName = originalFileName;
|
||||||
originalFileName: file.originalname,
|
|
||||||
fileType: extension,
|
if (originalFileName.length > MAX_FILE_NAME_LENGTH) {
|
||||||
fileExtension: extension,
|
// Preserve file extension when truncating
|
||||||
fileSize: file.size,
|
const ext = path.extname(originalFileName);
|
||||||
filePath: gcsFilePath, // Store GCS path or local path
|
const nameWithoutExt = path.basename(originalFileName, ext);
|
||||||
storageUrl: storageUrl, // Store GCS URL or local URL
|
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
|
||||||
mimeType: file.mimetype,
|
|
||||||
checksum,
|
if (maxNameLength > 0) {
|
||||||
isGoogleDoc: false,
|
truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
|
||||||
googleDocUrl: null as any,
|
} else {
|
||||||
category: category || 'OTHER',
|
// If extension itself is too long, just use the extension
|
||||||
version: 1,
|
truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
parentDocumentId: null as any,
|
}
|
||||||
isDeleted: false,
|
|
||||||
downloadCount: 0,
|
logger.warn('[Workflow] File name truncated to fit database column', {
|
||||||
} as any);
|
originalLength: originalFileName.length,
|
||||||
docs.push(doc);
|
truncatedLength: truncatedOriginalFileName.length,
|
||||||
|
originalName: originalFileName.substring(0, 100) + '...',
|
||||||
|
truncatedName: truncatedOriginalFileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fileName (basename of the generated file name in GCS)
|
||||||
|
const generatedFileName = path.basename(gcsFilePath);
|
||||||
|
let truncatedFileName = generatedFileName;
|
||||||
|
|
||||||
|
if (generatedFileName.length > MAX_FILE_NAME_LENGTH) {
|
||||||
|
const ext = path.extname(generatedFileName);
|
||||||
|
const nameWithoutExt = path.basename(generatedFileName, ext);
|
||||||
|
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
|
||||||
|
|
||||||
|
if (maxNameLength > 0) {
|
||||||
|
truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
|
||||||
|
} else {
|
||||||
|
truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[Workflow] Generated file name truncated', {
|
||||||
|
originalLength: generatedFileName.length,
|
||||||
|
truncatedLength: truncatedFileName.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if storageUrl exceeds database column limit (500 chars)
|
||||||
|
const MAX_STORAGE_URL_LENGTH = 500;
|
||||||
|
let finalStorageUrl = storageUrl;
|
||||||
|
if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) {
|
||||||
|
logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', {
|
||||||
|
originalLength: storageUrl.length,
|
||||||
|
maxLength: MAX_STORAGE_URL_LENGTH,
|
||||||
|
urlPrefix: storageUrl.substring(0, 100),
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
});
|
||||||
|
// For signed URLs, store null and generate on-demand later
|
||||||
|
finalStorageUrl = null as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[Workflow] Creating document record', {
|
||||||
|
fileName: truncatedOriginalFileName,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: finalStorageUrl ? 'present' : 'null (too long)',
|
||||||
|
requestId: workflow.requestId
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = await Document.create({
|
||||||
|
requestId: workflow.requestId,
|
||||||
|
uploadedBy: userId,
|
||||||
|
fileName: truncatedFileName,
|
||||||
|
originalFileName: truncatedOriginalFileName,
|
||||||
|
fileType: extension,
|
||||||
|
fileExtension: extension,
|
||||||
|
fileSize: file.size,
|
||||||
|
filePath: gcsFilePath, // Store GCS path or local path
|
||||||
|
storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long)
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
checksum,
|
||||||
|
isGoogleDoc: false,
|
||||||
|
googleDocUrl: null as any,
|
||||||
|
category: category || 'OTHER',
|
||||||
|
version: 1,
|
||||||
|
parentDocumentId: null as any,
|
||||||
|
isDeleted: false,
|
||||||
|
downloadCount: 0,
|
||||||
|
} as any);
|
||||||
|
docs.push(doc);
|
||||||
|
logger.info('[Workflow] Document record created successfully', {
|
||||||
|
documentId: doc.documentId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
});
|
||||||
|
} catch (docError) {
|
||||||
|
const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error';
|
||||||
|
const docErrorStack = docError instanceof Error ? docError.stack : undefined;
|
||||||
|
logger.error('[Workflow] Failed to create document record', {
|
||||||
|
error: docErrorMessage,
|
||||||
|
stack: docErrorStack,
|
||||||
|
fileName: file.originalname,
|
||||||
|
requestId: workflow.requestId,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: storageUrl,
|
||||||
|
});
|
||||||
|
// Re-throw to be caught by outer catch block
|
||||||
|
throw docError;
|
||||||
|
}
|
||||||
|
|
||||||
// Log document upload activity
|
// Log document upload activity
|
||||||
const requestMeta = getRequestMetadata(req);
|
const requestMeta = getRequestMetadata(req);
|
||||||
@ -320,6 +407,13 @@ export class WorkflowController {
|
|||||||
ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201);
|
ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
logger.error('[WorkflowController] createWorkflowMultipart failed', {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
filesCount: (req as any).files?.length || 0,
|
||||||
|
});
|
||||||
ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage);
|
ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -592,10 +686,27 @@ export class WorkflowController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update workflow
|
// Update workflow
|
||||||
const workflow = await workflowService.updateWorkflow(id, updateData);
|
let workflow;
|
||||||
if (!workflow) {
|
try {
|
||||||
ResponseHandler.notFound(res, 'Workflow not found');
|
workflow = await workflowService.updateWorkflow(id, updateData);
|
||||||
return;
|
if (!workflow) {
|
||||||
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info('[WorkflowController] Workflow updated successfully', {
|
||||||
|
requestId: id,
|
||||||
|
workflowId: (workflow as any).requestId,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
const updateErrorMessage = updateError instanceof Error ? updateError.message : 'Unknown error';
|
||||||
|
const updateErrorStack = updateError instanceof Error ? updateError.stack : undefined;
|
||||||
|
logger.error('[WorkflowController] updateWorkflow failed', {
|
||||||
|
error: updateErrorMessage,
|
||||||
|
stack: updateErrorStack,
|
||||||
|
requestId: id,
|
||||||
|
updateData: JSON.stringify(updateData, null, 2),
|
||||||
|
});
|
||||||
|
throw updateError; // Re-throw to be caught by outer catch block
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach new files as documents
|
// Attach new files as documents
|
||||||
@ -632,40 +743,129 @@ export class WorkflowController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Truncate file names if they exceed database column limits (255 chars)
|
||||||
|
const MAX_FILE_NAME_LENGTH = 255;
|
||||||
|
const originalFileName = file.originalname;
|
||||||
|
let truncatedOriginalFileName = originalFileName;
|
||||||
|
|
||||||
|
if (originalFileName.length > MAX_FILE_NAME_LENGTH) {
|
||||||
|
// Preserve file extension when truncating
|
||||||
|
const ext = path.extname(originalFileName);
|
||||||
|
const nameWithoutExt = path.basename(originalFileName, ext);
|
||||||
|
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
|
||||||
|
|
||||||
|
if (maxNameLength > 0) {
|
||||||
|
truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
|
||||||
|
} else {
|
||||||
|
// If extension itself is too long, just use the extension
|
||||||
|
truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[Workflow] File name truncated to fit database column', {
|
||||||
|
originalLength: originalFileName.length,
|
||||||
|
truncatedLength: truncatedOriginalFileName.length,
|
||||||
|
originalName: originalFileName.substring(0, 100) + '...',
|
||||||
|
truncatedName: truncatedOriginalFileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate fileName (basename of the generated file name in GCS)
|
||||||
|
const generatedFileName = path.basename(gcsFilePath);
|
||||||
|
let truncatedFileName = generatedFileName;
|
||||||
|
|
||||||
|
if (generatedFileName.length > MAX_FILE_NAME_LENGTH) {
|
||||||
|
const ext = path.extname(generatedFileName);
|
||||||
|
const nameWithoutExt = path.basename(generatedFileName, ext);
|
||||||
|
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
|
||||||
|
|
||||||
|
if (maxNameLength > 0) {
|
||||||
|
truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
|
||||||
|
} else {
|
||||||
|
truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('[Workflow] Generated file name truncated', {
|
||||||
|
originalLength: generatedFileName.length,
|
||||||
|
truncatedLength: truncatedFileName.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if storageUrl exceeds database column limit (500 chars)
|
||||||
|
const MAX_STORAGE_URL_LENGTH = 500;
|
||||||
|
let finalStorageUrl = storageUrl;
|
||||||
|
if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) {
|
||||||
|
logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', {
|
||||||
|
originalLength: storageUrl.length,
|
||||||
|
maxLength: MAX_STORAGE_URL_LENGTH,
|
||||||
|
urlPrefix: storageUrl.substring(0, 100),
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
});
|
||||||
|
// For signed URLs, store null and generate on-demand later
|
||||||
|
finalStorageUrl = null as any;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('[Workflow] Creating document record', {
|
logger.info('[Workflow] Creating document record', {
|
||||||
fileName: file.originalname,
|
fileName: truncatedOriginalFileName,
|
||||||
filePath: gcsFilePath,
|
filePath: gcsFilePath,
|
||||||
storageUrl: storageUrl,
|
storageUrl: finalStorageUrl ? 'present' : 'null (too long)',
|
||||||
requestId: actualRequestId
|
requestId: actualRequestId
|
||||||
});
|
});
|
||||||
|
|
||||||
const doc = await Document.create({
|
try {
|
||||||
requestId: actualRequestId,
|
const doc = await Document.create({
|
||||||
uploadedBy: userId,
|
requestId: actualRequestId,
|
||||||
fileName: path.basename(file.filename || file.originalname),
|
uploadedBy: userId,
|
||||||
originalFileName: file.originalname,
|
fileName: truncatedFileName,
|
||||||
fileType: extension,
|
originalFileName: truncatedOriginalFileName,
|
||||||
fileExtension: extension,
|
fileType: extension,
|
||||||
fileSize: file.size,
|
fileExtension: extension,
|
||||||
filePath: gcsFilePath, // Store GCS path or local path
|
fileSize: file.size,
|
||||||
storageUrl: storageUrl, // Store GCS URL or local URL
|
filePath: gcsFilePath, // Store GCS path or local path
|
||||||
mimeType: file.mimetype,
|
storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long)
|
||||||
checksum,
|
mimeType: file.mimetype,
|
||||||
isGoogleDoc: false,
|
checksum,
|
||||||
googleDocUrl: null as any,
|
isGoogleDoc: false,
|
||||||
category: category || 'OTHER',
|
googleDocUrl: null as any,
|
||||||
version: 1,
|
category: category || 'OTHER',
|
||||||
parentDocumentId: null as any,
|
version: 1,
|
||||||
isDeleted: false,
|
parentDocumentId: null as any,
|
||||||
downloadCount: 0,
|
isDeleted: false,
|
||||||
} as any);
|
downloadCount: 0,
|
||||||
docs.push(doc);
|
} as any);
|
||||||
|
docs.push(doc);
|
||||||
|
logger.info('[Workflow] Document record created successfully', {
|
||||||
|
documentId: doc.documentId,
|
||||||
|
fileName: file.originalname,
|
||||||
|
});
|
||||||
|
} catch (docError) {
|
||||||
|
const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error';
|
||||||
|
const docErrorStack = docError instanceof Error ? docError.stack : undefined;
|
||||||
|
logger.error('[Workflow] Failed to create document record', {
|
||||||
|
error: docErrorMessage,
|
||||||
|
stack: docErrorStack,
|
||||||
|
fileName: file.originalname,
|
||||||
|
requestId: actualRequestId,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: storageUrl,
|
||||||
|
});
|
||||||
|
// Continue with other files, but log the error
|
||||||
|
// Don't throw here - let the workflow update complete
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200);
|
ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
logger.error('[WorkflowController] updateWorkflowMultipart failed', {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
requestId: req.params.id,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
hasFiles: !!(req as any).files && (req as any).files.length > 0,
|
||||||
|
fileCount: (req as any).files ? (req as any).files.length : 0,
|
||||||
|
});
|
||||||
ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage);
|
ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApprovalConfirmationData } from './types';
|
import { ApprovalConfirmationData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string {
|
export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string {
|
||||||
@ -21,21 +21,24 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Request Approved</title>
|
<title>Request Approved</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Approved',
|
title: 'Request Approved',
|
||||||
...HeaderStyles.success
|
...HeaderStyles.success
|
||||||
}))}
|
}))}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 30px;">
|
<td class="email-content">
|
||||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||||
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
|
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
|
||||||
</p>
|
</p>
|
||||||
@ -46,47 +49,47 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
|
|||||||
|
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 25px;">
|
<td class="detail-box" style="padding: 30px;">
|
||||||
<h2 style="margin: 0 0 20px; color: #155724; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
<h2 style="margin: 0 0 25px; color: #155724; font-size: 20px; font-weight: 600;">Request Summary</h2>
|
||||||
|
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px; width: 140px;">
|
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
<strong>Request ID:</strong>
|
<strong>Request ID:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
${data.requestId}
|
${data.requestId}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
<strong>Approved By:</strong>
|
<strong>Approved By:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
${data.approverName}
|
${data.approverName}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
<strong>Approved On:</strong>
|
<strong>Approved On:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
${data.approvalDate}
|
${data.approvalDate}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
<strong>Time:</strong>
|
<strong>Time:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
${data.approvalTime}
|
${data.approvalTime}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
<strong>Request Type:</strong>
|
<strong>Request Type:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||||
${data.requestType}
|
${data.requestType}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApprovalRequestData } from './types';
|
import { ApprovalRequestData } from './types';
|
||||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
|
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||||
@ -22,7 +22,7 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Approval Request',
|
title: 'Approval Request',
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApproverSkippedData } from './types';
|
import { ApproverSkippedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
||||||
@ -12,14 +12,17 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Approver Skipped</title>
|
<title>Approver Skipped</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Approval Level Skipped',
|
title: 'Approval Level Skipped',
|
||||||
...HeaderStyles.infoSecondary
|
...HeaderStyles.infoSecondary
|
||||||
|
|||||||
@ -144,9 +144,18 @@ export function wrapRichText(htmlContent: string): string {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inline styles for email container table
|
||||||
|
* This ensures width is preserved when emails are forwarded
|
||||||
|
* Email clients often strip CSS classes, so inline styles are critical
|
||||||
|
*/
|
||||||
|
export function getEmailContainerStyles(): string {
|
||||||
|
return 'width: 95%; max-width: 1200px; min-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate all email styles (responsive + rich text)
|
* Generate all email styles (responsive + rich text)
|
||||||
* Optimized for screens up to 600px width
|
* Desktop-first design (optimized for browser) with mobile responsive breakpoints
|
||||||
*/
|
*/
|
||||||
export function getResponsiveStyles(): string {
|
export function getResponsiveStyles(): string {
|
||||||
return `
|
return `
|
||||||
@ -173,6 +182,100 @@ export function getResponsiveStyles(): string {
|
|||||||
border-collapse: collapse !important;
|
border-collapse: collapse !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop-first base styles */
|
||||||
|
.email-container {
|
||||||
|
width: 95% !important;
|
||||||
|
max-width: 1200px !important;
|
||||||
|
min-width: 600px !important; /* Prevent shrinking below 600px when forwarded */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force full width for forwarded emails - use inline styles in templates */
|
||||||
|
table.email-container {
|
||||||
|
width: 95% !important;
|
||||||
|
max-width: 1200px !important;
|
||||||
|
min-width: 600px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper table to force full width even when forwarded */
|
||||||
|
.email-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content {
|
||||||
|
padding: 50px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
padding: 40px 40px 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
padding: 30px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop typography */
|
||||||
|
.header-title {
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop detail tables - side by side */
|
||||||
|
.detail-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-table td {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 10px 0;
|
||||||
|
display: table-cell;
|
||||||
|
width: auto;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
width: 200px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-box {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop button styles */
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 16px 45px;
|
||||||
|
font-size: 16px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet responsive styles */
|
||||||
|
@media only screen and (max-width: 1200px) {
|
||||||
|
.email-container {
|
||||||
|
width: 95% !important;
|
||||||
|
max-width: 95% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-content {
|
||||||
|
padding: 40px 30px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
padding: 35px 30px 30px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
padding: 25px 30px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile responsive styles */
|
/* Mobile responsive styles */
|
||||||
@media only screen and (max-width: 600px) {
|
@media only screen and (max-width: 600px) {
|
||||||
/* Container adjustments */
|
/* Container adjustments */
|
||||||
@ -180,11 +283,12 @@ export function getResponsiveStyles(): string {
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header adjustments */
|
/* Header adjustments */
|
||||||
.email-header {
|
.email-header {
|
||||||
padding: 25px 15px 30px !important;
|
padding: 25px 20px 30px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content adjustments */
|
/* Content adjustments */
|
||||||
@ -207,7 +311,7 @@ export function getResponsiveStyles(): string {
|
|||||||
/* Typography adjustments */
|
/* Typography adjustments */
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 20px !important;
|
font-size: 20px !important;
|
||||||
letter-spacing: 1px !important;
|
letter-spacing: 0.5px !important;
|
||||||
line-height: 1.4 !important;
|
line-height: 1.4 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,21 +319,22 @@ export function getResponsiveStyles(): string {
|
|||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail tables */
|
/* Detail tables - stack on mobile */
|
||||||
.detail-box {
|
.detail-box {
|
||||||
padding: 20px 15px !important;
|
padding: 20px 15px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-table td {
|
.detail-table td {
|
||||||
font-size: 13px !important;
|
font-size: 14px !important;
|
||||||
padding: 6px 0 !important;
|
padding: 8px 0 !important;
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
margin-bottom: 2px !important;
|
margin-bottom: 4px !important;
|
||||||
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button adjustments */
|
/* Button adjustments */
|
||||||
@ -240,27 +345,28 @@ export function getResponsiveStyles(): string {
|
|||||||
padding: 16px 20px !important;
|
padding: 16px 20px !important;
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
|
min-width: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section adjustments */
|
/* Section adjustments */
|
||||||
.info-section {
|
.info-section {
|
||||||
padding: 15px !important;
|
padding: 18px 15px !important;
|
||||||
margin-bottom: 20px !important;
|
margin-bottom: 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 15px !important;
|
font-size: 16px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-text {
|
.section-text {
|
||||||
font-size: 13px !important;
|
font-size: 14px !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List items */
|
/* List items */
|
||||||
.info-section ul {
|
.info-section ul {
|
||||||
padding-left: 15px !important;
|
padding-left: 20px !important;
|
||||||
font-size: 13px !important;
|
font-size: 14px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section li {
|
.info-section li {
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MultiApproverRequestData } from './types';
|
import { MultiApproverRequestData } from './types';
|
||||||
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
|
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
|
||||||
@ -22,7 +22,7 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Multi-Level Approval Request',
|
title: 'Multi-Level Approval Request',
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ParticipantAddedData } from './types';
|
import { ParticipantAddedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
||||||
@ -12,14 +12,17 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Added to Request</title>
|
<title>Added to Request</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: `You've Been Added as ${data.participantRole}`,
|
title: `You've Been Added as ${data.participantRole}`,
|
||||||
...HeaderStyles.info
|
...HeaderStyles.info
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RejectionNotificationData } from './types';
|
import { RejectionNotificationData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getRejectionNotificationEmail(data: RejectionNotificationData): string {
|
export function getRejectionNotificationEmail(data: RejectionNotificationData): string {
|
||||||
@ -12,14 +12,17 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Request Rejected</title>
|
<title>Request Rejected</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Rejected',
|
title: 'Request Rejected',
|
||||||
...HeaderStyles.error
|
...HeaderStyles.error
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RequestClosedData } from './types';
|
import { RequestClosedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getRequestClosedEmail(data: RequestClosedData): string {
|
export function getRequestClosedEmail(data: RequestClosedData): string {
|
||||||
@ -22,7 +22,7 @@ export function getRequestClosedEmail(data: RequestClosedData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Closed',
|
title: 'Request Closed',
|
||||||
...HeaderStyles.complete
|
...HeaderStyles.complete
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RequestCreatedData } from './types';
|
import { RequestCreatedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
||||||
@ -22,7 +22,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Request Created Successfully',
|
title: 'Request Created Successfully',
|
||||||
@ -31,7 +31,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<tr>
|
<tr>
|
||||||
<td class="email-content" style="padding: 40px 30px;">
|
<td class="email-content">
|
||||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||||
Dear <strong style="color: #667eea;">${data.initiatorName}</strong>,
|
Dear <strong style="color: #667eea;">${data.initiatorName}</strong>,
|
||||||
</p>
|
</p>
|
||||||
@ -43,55 +43,55 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
|||||||
<!-- Request Details Box -->
|
<!-- Request Details Box -->
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 25px;">
|
<td class="detail-box" style="padding: 30px;">
|
||||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
<h2 style="margin: 0 0 25px; color: #333333; font-size: 20px; font-weight: 600;">Request Summary</h2>
|
||||||
|
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||||
<strong>Request ID:</strong>
|
<strong>Request ID:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||||
${data.requestId}
|
${data.requestId}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||||
<strong>Title:</strong>
|
<strong>Title:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||||
${data.requestTitle || 'N/A'}
|
${data.requestTitle || 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||||
<strong>Request Type:</strong>
|
<strong>Request Type:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||||
${data.requestType}
|
${data.requestType}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||||
<strong>Priority:</strong>
|
<strong>Priority:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||||
${data.priority}
|
${data.priority}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||||
<strong>Created On:</strong>
|
<strong>Created On:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||||
${data.requestDate} at ${data.requestTime}
|
${data.requestDate} at ${data.requestTime}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||||
<strong>Total Approvers:</strong>
|
<strong>Total Approvers:</strong>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||||
${data.totalApprovers}
|
${data.totalApprovers}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TATBreachedData } from './types';
|
import { TATBreachedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getTATBreachedEmail(data: TATBreachedData): string {
|
export function getTATBreachedEmail(data: TATBreachedData): string {
|
||||||
@ -12,14 +12,17 @@ export function getTATBreachedEmail(data: TATBreachedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>TAT Breached - Urgent Action Required</title>
|
<title>TAT Breached - Urgent Action Required</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'TAT Breached',
|
title: 'TAT Breached',
|
||||||
subtitle: 'Immediate Action Required',
|
subtitle: 'Immediate Action Required',
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TATReminderData } from './types';
|
import { TATReminderData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,7 +52,7 @@ export function getTATReminderEmail(data: TATReminderData): string {
|
|||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'TAT Reminder',
|
title: 'TAT Reminder',
|
||||||
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
|
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { WorkflowPausedData } from './types';
|
import { WorkflowPausedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
||||||
@ -12,14 +12,17 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Workflow Paused</title>
|
<title>Workflow Paused</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Workflow Paused',
|
title: 'Workflow Paused',
|
||||||
...HeaderStyles.neutral
|
...HeaderStyles.neutral
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { WorkflowResumedData } from './types';
|
import { WorkflowResumedData } from './types';
|
||||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection } from './helpers';
|
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||||
import { getBrandedHeader } from './branding.config';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
||||||
@ -12,14 +12,17 @@ export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
<title>Workflow Resumed</title>
|
<title>Workflow Resumed</title>
|
||||||
|
${getResponsiveStyles()}
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 0;">
|
<td style="padding: 40px 0;">
|
||||||
<table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Workflow Resumed',
|
title: 'Workflow Resumed',
|
||||||
...HeaderStyles.success
|
...HeaderStyles.success
|
||||||
|
|||||||
@ -210,6 +210,13 @@ export async function handleTatJob(job: Job<TatJobData>) {
|
|||||||
type === 'threshold2' ? 'HIGH' :
|
type === 'threshold2' ? 'HIGH' :
|
||||||
'MEDIUM';
|
'MEDIUM';
|
||||||
|
|
||||||
|
// Format time remaining/overdue for email
|
||||||
|
const timeRemainingText = remainingHours > 0
|
||||||
|
? `${remainingHours.toFixed(1)} hours remaining`
|
||||||
|
: type === 'breach'
|
||||||
|
? `${Math.abs(remainingHours).toFixed(1)} hours overdue`
|
||||||
|
: 'Time exceeded';
|
||||||
|
|
||||||
// Send notification to approver (with error handling to prevent job failure)
|
// Send notification to approver (with error handling to prevent job failure)
|
||||||
try {
|
try {
|
||||||
await notificationService.sendToUsers([approverId], {
|
await notificationService.sendToUsers([approverId], {
|
||||||
@ -220,7 +227,17 @@ export async function handleTatJob(job: Job<TatJobData>) {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: type,
|
type: type,
|
||||||
priority: notificationPriority,
|
priority: notificationPriority,
|
||||||
actionRequired: type === 'breach' || type === 'threshold2' // Require action for critical alerts
|
actionRequired: type === 'breach' || type === 'threshold2', // Require action for critical alerts
|
||||||
|
metadata: {
|
||||||
|
thresholdPercentage: thresholdPercentage,
|
||||||
|
tatInfo: {
|
||||||
|
thresholdPercentage: thresholdPercentage,
|
||||||
|
timeRemaining: timeRemainingText,
|
||||||
|
tatDeadline: expectedCompletionTime,
|
||||||
|
assignedDate: levelStartTime,
|
||||||
|
timeOverdue: type === 'breach' ? timeRemainingText : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`);
|
logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`);
|
||||||
} catch (notificationError: any) {
|
} catch (notificationError: any) {
|
||||||
|
|||||||
@ -21,6 +21,33 @@ import { pauseController } from '../controllers/pause.controller';
|
|||||||
import logger from '@utils/logger';
|
import logger from '@utils/logger';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create proper Content-Disposition header
|
||||||
|
* Returns clean filename header that browsers handle correctly
|
||||||
|
*/
|
||||||
|
function createContentDisposition(disposition: 'inline' | 'attachment', filename: string): string {
|
||||||
|
// Clean filename: only remove truly problematic characters for HTTP headers
|
||||||
|
// Keep spaces, dots, hyphens, underscores - these are safe
|
||||||
|
const cleanFilename = filename
|
||||||
|
.replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_') // Only replace truly problematic chars
|
||||||
|
.replace(/\\/g, '_') // Replace backslashes
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// For ASCII-only filenames, use simple format (browsers prefer this)
|
||||||
|
// Only use filename* for non-ASCII characters
|
||||||
|
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||||
|
|
||||||
|
if (hasNonASCII) {
|
||||||
|
// Use RFC 5987 encoding for non-ASCII characters
|
||||||
|
const encodedFilename = encodeURIComponent(filename);
|
||||||
|
return `${disposition}; filename="${cleanFilename}"; filename*=UTF-8''${encodedFilename}`;
|
||||||
|
} else {
|
||||||
|
// Simple ASCII filename - use clean version (no filename* needed)
|
||||||
|
// This prevents browsers from showing both filename and filename*
|
||||||
|
return `${disposition}; filename="${cleanFilename}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
const workflowController = new WorkflowController();
|
const workflowController = new WorkflowController();
|
||||||
const approvalController = new ApprovalController();
|
const approvalController = new ApprovalController();
|
||||||
const workNoteController = new WorkNoteController();
|
const workNoteController = new WorkNoteController();
|
||||||
@ -223,6 +250,89 @@ router.get('/documents/:documentId/preview',
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS
|
||||||
|
if (!storageUrl && filePath && filePath.startsWith('requests/')) {
|
||||||
|
try {
|
||||||
|
// Use the existing GCS storage service instance
|
||||||
|
if (!gcsStorageService.isConfigured()) {
|
||||||
|
throw new Error('GCS not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access the storage instance from the service
|
||||||
|
const { Storage } = require('@google-cloud/storage');
|
||||||
|
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||||
|
const bucketName = process.env.GCP_BUCKET_NAME || '';
|
||||||
|
const path = require('path');
|
||||||
|
const resolvedKeyPath = path.isAbsolute(keyFilePath)
|
||||||
|
? keyFilePath
|
||||||
|
: path.resolve(process.cwd(), keyFilePath);
|
||||||
|
|
||||||
|
const storage = new Storage({
|
||||||
|
projectId: process.env.GCP_PROJECT_ID || '',
|
||||||
|
keyFilename: resolvedKeyPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bucket = storage.bucket(bucketName);
|
||||||
|
const file = bucket.file(filePath);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const [exists] = await file.exists();
|
||||||
|
if (!exists) {
|
||||||
|
res.status(404).json({ success: false, error: 'File not found in GCS' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file metadata for content type
|
||||||
|
const [metadata] = await file.getMetadata();
|
||||||
|
const contentType = metadata.contentType || fileType || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Set CORS headers
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
|
||||||
|
// For images and PDFs, allow inline viewing
|
||||||
|
const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf'));
|
||||||
|
const disposition = isPreviewable ? 'inline' : 'attachment';
|
||||||
|
res.setHeader('Content-Disposition', createContentDisposition(disposition, fileName));
|
||||||
|
|
||||||
|
// Stream file from GCS to response
|
||||||
|
file.createReadStream()
|
||||||
|
.on('error', (streamError: Error) => {
|
||||||
|
const logger = require('../utils/logger').default;
|
||||||
|
logger.error('[Workflow] Failed to stream file from GCS', {
|
||||||
|
documentId,
|
||||||
|
filePath,
|
||||||
|
error: streamError.message,
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to stream file from storage'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pipe(res);
|
||||||
|
return;
|
||||||
|
} catch (gcsError) {
|
||||||
|
const logger = require('../utils/logger').default;
|
||||||
|
logger.error('[Workflow] Failed to access GCS file for preview', {
|
||||||
|
documentId,
|
||||||
|
filePath,
|
||||||
|
error: gcsError instanceof Error ? gcsError.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to access file. Please try again.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||||
// File is served by express.static middleware, redirect to the storage URL
|
// File is served by express.static middleware, redirect to the storage URL
|
||||||
@ -296,6 +406,87 @@ router.get('/documents/:documentId/download',
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS
|
||||||
|
if (!storageUrl && filePath && filePath.startsWith('requests/')) {
|
||||||
|
try {
|
||||||
|
// Use the existing GCS storage service instance
|
||||||
|
if (!gcsStorageService.isConfigured()) {
|
||||||
|
throw new Error('GCS not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access the storage instance from the service
|
||||||
|
const { Storage } = require('@google-cloud/storage');
|
||||||
|
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||||
|
const bucketName = process.env.GCP_BUCKET_NAME || '';
|
||||||
|
const path = require('path');
|
||||||
|
const resolvedKeyPath = path.isAbsolute(keyFilePath)
|
||||||
|
? keyFilePath
|
||||||
|
: path.resolve(process.cwd(), keyFilePath);
|
||||||
|
|
||||||
|
const storage = new Storage({
|
||||||
|
projectId: process.env.GCP_PROJECT_ID || '',
|
||||||
|
keyFilename: resolvedKeyPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bucket = storage.bucket(bucketName);
|
||||||
|
const file = bucket.file(filePath);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
const [exists] = await file.exists();
|
||||||
|
if (!exists) {
|
||||||
|
res.status(404).json({ success: false, error: 'File not found in GCS' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file metadata for content type
|
||||||
|
const [metadata] = await file.getMetadata();
|
||||||
|
const contentType = metadata.contentType || (document as any).mimeType || (document as any).mime_type || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Set CORS headers
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (origin) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
|
||||||
|
|
||||||
|
// Set headers for download
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName));
|
||||||
|
|
||||||
|
// Stream file from GCS to response
|
||||||
|
file.createReadStream()
|
||||||
|
.on('error', (streamError: Error) => {
|
||||||
|
const logger = require('../utils/logger').default;
|
||||||
|
logger.error('[Workflow] Failed to stream file from GCS for download', {
|
||||||
|
documentId,
|
||||||
|
filePath,
|
||||||
|
error: streamError.message,
|
||||||
|
});
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to stream file from storage'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pipe(res);
|
||||||
|
return;
|
||||||
|
} catch (gcsError) {
|
||||||
|
const logger = require('../utils/logger').default;
|
||||||
|
logger.error('[Workflow] Failed to access GCS file for download', {
|
||||||
|
documentId,
|
||||||
|
filePath,
|
||||||
|
error: gcsError instanceof Error ? gcsError.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to access file. Please try again.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||||
// File is served by express.static middleware, redirect to the storage URL
|
// File is served by express.static middleware, redirect to the storage URL
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import app from './app';
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import { initializeSecrets } from './app'; // Import initialization function
|
||||||
|
import app from './app';
|
||||||
import { initSocket } from './realtime/socket';
|
import { initSocket } from './realtime/socket';
|
||||||
import './queues/tatWorker'; // Initialize TAT worker
|
import './queues/tatWorker'; // Initialize TAT worker
|
||||||
import { logTatConfig } from './config/tat.config';
|
import { logTatConfig } from './config/tat.config';
|
||||||
@ -15,6 +16,10 @@ const PORT: number = parseInt(process.env.PORT || '5000', 10);
|
|||||||
// Start server
|
// Start server
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
// Initialize Google Secret Manager before starting server
|
||||||
|
// This will merge secrets from GCS into process.env if enabled
|
||||||
|
await initializeSecrets();
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
initSocket(server);
|
initSocket(server);
|
||||||
|
|
||||||
|
|||||||
@ -115,11 +115,66 @@ class AIService {
|
|||||||
const streamingResp = await generativeModel.generateContent(request);
|
const streamingResp = await generativeModel.generateContent(request);
|
||||||
const response = streamingResp.response;
|
const response = streamingResp.response;
|
||||||
|
|
||||||
|
// Log full response structure for debugging if empty
|
||||||
|
if (!response.candidates || response.candidates.length === 0) {
|
||||||
|
logger.error('[AI Service] No candidates in Vertex AI response:', {
|
||||||
|
response: JSON.stringify(response, null, 2),
|
||||||
|
promptLength: prompt.length,
|
||||||
|
model: this.model
|
||||||
|
});
|
||||||
|
throw new Error('Vertex AI returned no candidates. The response may have been blocked by safety filters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = response.candidates[0];
|
||||||
|
|
||||||
|
// Check for safety ratings or blocked reasons
|
||||||
|
if (candidate.safetyRatings && candidate.safetyRatings.length > 0) {
|
||||||
|
const blockedRatings = candidate.safetyRatings.filter((rating: any) =>
|
||||||
|
rating.probability === 'HIGH' || rating.probability === 'MEDIUM'
|
||||||
|
);
|
||||||
|
if (blockedRatings.length > 0) {
|
||||||
|
logger.warn('[AI Service] Vertex AI safety filters triggered:', {
|
||||||
|
ratings: blockedRatings.map((r: any) => ({
|
||||||
|
category: r.category,
|
||||||
|
probability: r.probability
|
||||||
|
})),
|
||||||
|
finishReason: candidate.finishReason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check finish reason
|
||||||
|
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
||||||
|
logger.warn('[AI Service] Vertex AI finish reason:', {
|
||||||
|
finishReason: candidate.finishReason,
|
||||||
|
safetyRatings: candidate.safetyRatings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Extract text from response
|
// Extract text from response
|
||||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
const text = candidate.content?.parts?.[0]?.text || '';
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
throw new Error('Empty response from Vertex AI');
|
// Log detailed response structure for debugging
|
||||||
|
logger.error('[AI Service] Empty text in Vertex AI response:', {
|
||||||
|
candidate: JSON.stringify(candidate, null, 2),
|
||||||
|
finishReason: candidate.finishReason,
|
||||||
|
safetyRatings: candidate.safetyRatings,
|
||||||
|
promptLength: prompt.length,
|
||||||
|
promptPreview: prompt.substring(0, 200) + '...',
|
||||||
|
model: this.model
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide more helpful error message
|
||||||
|
if (candidate.finishReason === 'SAFETY') {
|
||||||
|
throw new Error('Vertex AI blocked the response due to safety filters. The prompt may contain content that violates safety policies.');
|
||||||
|
} else if (candidate.finishReason === 'MAX_TOKENS') {
|
||||||
|
throw new Error('Vertex AI response was truncated due to token limit.');
|
||||||
|
} else if (candidate.finishReason === 'RECITATION') {
|
||||||
|
throw new Error('Vertex AI blocked the response due to recitation concerns.');
|
||||||
|
} else {
|
||||||
|
throw new Error(`Empty response from Vertex AI. Finish reason: ${candidate.finishReason || 'UNKNOWN'}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@ -261,7 +261,7 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'ai_conclusion_generated',
|
type: 'ai_conclusion_generated',
|
||||||
user: { userId: 'system', name: 'System' },
|
user: { userId: null as any, name: 'System' }, // Use null instead of 'system' for UUID field
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'AI Conclusion Generated',
|
action: 'AI Conclusion Generated',
|
||||||
details: 'AI-powered conclusion remark generated for review by initiator',
|
details: 'AI-powered conclusion remark generated for review by initiator',
|
||||||
@ -292,7 +292,7 @@ export class ApprovalService {
|
|||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
type: 'summary_generated',
|
type: 'summary_generated',
|
||||||
user: { userId: 'system', name: 'System' },
|
user: { userId: null as any, name: 'System' }, // Use null instead of 'system' for UUID field
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
action: 'Summary Auto-Generated',
|
action: 'Summary Auto-Generated',
|
||||||
details: 'Request summary auto-generated after final approval',
|
details: 'Request summary auto-generated after final approval',
|
||||||
@ -340,15 +340,15 @@ export class ApprovalService {
|
|||||||
targetUserIds.add(p.userId); // Includes spectators
|
targetUserIds.add(p.userId); // Includes spectators
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send notification to initiator (with action required)
|
// Send notification to initiator about final approval (triggers email)
|
||||||
const initiatorId = (wf as any).initiatorId;
|
const initiatorId = (wf as any).initiatorId;
|
||||||
await notificationService.sendToUsers([initiatorId], {
|
await notificationService.sendToUsers([initiatorId], {
|
||||||
title: `Request Approved - Closure Pending`,
|
title: `Request Approved - All Approvals Complete`,
|
||||||
body: `Your request "${(wf as any).title}" has been fully approved. Please review and finalize the conclusion remark to close the request.`,
|
body: `Your request "${(wf as any).title}" has been fully approved by all approvers. Please review and finalize the conclusion remark to close the request.`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
type: 'approval_pending_closure',
|
type: 'approval',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true
|
actionRequired: true
|
||||||
});
|
});
|
||||||
@ -437,9 +437,8 @@ export class ApprovalService {
|
|||||||
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
|
const isDeptLeadApproval = currentLevelName.includes('department lead') || level.levelNumber === 3;
|
||||||
|
|
||||||
// Check if current level is Requestor Claim Approval (Step 5, was Step 6)
|
// Check if current level is Requestor Claim Approval (Step 5, was Step 6)
|
||||||
const currentLevelNameForStep5 = (level.levelName || '').toLowerCase();
|
const isRequestorClaimApproval = currentLevelName.includes('requestor') &&
|
||||||
const isRequestorClaimApproval = currentLevelNameForStep5.includes('requestor') &&
|
(currentLevelName.includes('claim') || currentLevelName.includes('approval')) ||
|
||||||
(currentLevelNameForStep5.includes('claim') || currentLevelNameForStep5.includes('approval')) ||
|
|
||||||
level.levelNumber === 5;
|
level.levelNumber === 5;
|
||||||
|
|
||||||
if (isClaimManagement && isDeptLeadApproval) {
|
if (isClaimManagement && isDeptLeadApproval) {
|
||||||
@ -458,30 +457,43 @@ export class ApprovalService {
|
|||||||
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`);
|
logger.info(`[Approval] Requestor Claim Approval approved for claim management workflow. E-Invoice generation will be logged as activity when DMS webhook is received.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wf && nextLevel) {
|
// Log approval activity
|
||||||
// Normal flow - notify next approver (skip for auto-steps)
|
activityService.log({
|
||||||
// Check if it's an auto-step by checking approverEmail or levelName
|
requestId: level.requestId,
|
||||||
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are no longer approval steps
|
type: 'approval',
|
||||||
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|
user: { userId: level.approverId, name: level.approverName },
|
||||||
|| (nextLevel as any).approverName === 'System Auto-Process';
|
timestamp: new Date().toISOString(),
|
||||||
|
action: 'Approved',
|
||||||
|
details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`,
|
||||||
|
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||||
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
|
});
|
||||||
|
|
||||||
// Log approval activity
|
// Notify initiator about the approval (triggers email for regular workflows)
|
||||||
activityService.log({
|
if (wf) {
|
||||||
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
|
title: `Request Approved - Level ${level.levelNumber}`,
|
||||||
|
body: `Your request "${(wf as any).title}" has been approved by ${level.approverName || level.approverEmail} and forwarded to the next approver.`,
|
||||||
|
requestNumber: (wf as any).requestNumber,
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
type: 'approval',
|
type: 'approval',
|
||||||
user: { userId: level.approverId, name: level.approverName },
|
priority: 'MEDIUM'
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
action: 'Approved',
|
|
||||||
details: `Request approved and forwarded to ${(nextLevel as any).approverName || (nextLevel as any).approverEmail} by ${level.approverName || level.approverEmail}`,
|
|
||||||
ipAddress: requestMetadata?.ipAddress || undefined,
|
|
||||||
userAgent: requestMetadata?.userAgent || undefined
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log assignment activity for next level (when it becomes active)
|
// Notify next approver
|
||||||
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
if (wf && nextLevel) {
|
||||||
// System steps are any step with system@royalenfield.com
|
// 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@royalenfield.com'
|
||||||
|
|| (nextLevel as any).approverName === 'System Auto-Process'
|
||||||
|
|| (nextLevel as any).approverId === 'system';
|
||||||
|
|
||||||
|
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
||||||
|
// System steps are any step with system@royalenfield.com
|
||||||
|
// 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
|
||||||
@ -534,8 +546,6 @@ export class ApprovalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management)
|
// Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents approval in claim management)
|
||||||
const workflowType = (wf as any)?.workflowType;
|
|
||||||
const isClaimManagement = workflowType === 'CLAIM_MANAGEMENT';
|
|
||||||
const levelName = (level.levelName || '').toLowerCase();
|
const levelName = (level.levelName || '').toLowerCase();
|
||||||
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
|
const isDealerProposalApproval = levelName.includes('dealer') && levelName.includes('proposal') || level.levelNumber === 1;
|
||||||
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
|
const isDealerCompletionApproval = levelName.includes('dealer') && (levelName.includes('completion') || levelName.includes('documents')) || level.levelNumber === 5;
|
||||||
@ -643,12 +653,34 @@ export class ApprovalService {
|
|||||||
for (const p of participants as any[]) {
|
for (const p of participants as any[]) {
|
||||||
targetUserIds.add(p.userId);
|
targetUserIds.add(p.userId);
|
||||||
}
|
}
|
||||||
await notificationService.sendToUsers(Array.from(targetUserIds), {
|
|
||||||
|
// Send notification to initiator with type 'rejection' to trigger email
|
||||||
|
await notificationService.sendToUsers([(wf as any).initiatorId], {
|
||||||
title: `Rejected: ${(wf as any).requestNumber}`,
|
title: `Rejected: ${(wf as any).requestNumber}`,
|
||||||
body: `${(wf as any).title}`,
|
body: `${(wf as any).title}`,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
url: `/request/${(wf as any).requestNumber}`
|
requestId: level.requestId,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
type: 'rejection',
|
||||||
|
priority: 'HIGH',
|
||||||
|
metadata: {
|
||||||
|
rejectionReason: action.rejectionReason || action.comments || 'No reason provided'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send notification to other participants (spectators) for transparency (no email, just in-app)
|
||||||
|
const participantUserIds = Array.from(targetUserIds).filter(id => id !== (wf as any).initiatorId);
|
||||||
|
if (participantUserIds.length > 0) {
|
||||||
|
await notificationService.sendToUsers(participantUserIds, {
|
||||||
|
title: `Rejected: ${(wf as any).requestNumber}`,
|
||||||
|
body: `Request "${(wf as any).title}" has been rejected.`,
|
||||||
|
requestNumber: (wf as any).requestNumber,
|
||||||
|
requestId: level.requestId,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
type: 'status_change', // Use status_change to avoid triggering emails for participants
|
||||||
|
priority: 'MEDIUM'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved)
|
// Generate AI conclusion remark ASYNCHRONOUSLY for rejected requests (similar to approved)
|
||||||
|
|||||||
@ -121,24 +121,42 @@ export class EmailService {
|
|||||||
try {
|
try {
|
||||||
const info = await this.transporter!.sendMail(mailOptions);
|
const info = await this.transporter!.sendMail(mailOptions);
|
||||||
|
|
||||||
|
if (!info || !info.messageId) {
|
||||||
|
throw new Error('Email sent but no messageId returned');
|
||||||
|
}
|
||||||
|
|
||||||
const result: { messageId: string; previewUrl?: string } = {
|
const result: { messageId: string; previewUrl?: string } = {
|
||||||
messageId: info.messageId
|
messageId: info.messageId
|
||||||
};
|
};
|
||||||
|
|
||||||
// If using test account, generate preview URL
|
// If using test account, generate preview URL
|
||||||
if (this.useTestAccount) {
|
if (this.useTestAccount) {
|
||||||
const previewUrl = nodemailer.getTestMessageUrl(info);
|
try {
|
||||||
result.previewUrl = previewUrl || undefined;
|
const previewUrl = nodemailer.getTestMessageUrl(info);
|
||||||
|
|
||||||
// Always log to console for visibility
|
if (previewUrl) {
|
||||||
console.log('\n' + '='.repeat(80));
|
result.previewUrl = previewUrl;
|
||||||
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
|
||||||
console.log(`To: ${recipients}`);
|
|
||||||
console.log(`Preview URL: ${previewUrl}`);
|
|
||||||
console.log('='.repeat(80) + '\n');
|
|
||||||
|
|
||||||
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
// Always log to console for visibility
|
||||||
logger.info(`📧 Preview URL: ${previewUrl}`);
|
console.log('\n' + '='.repeat(80));
|
||||||
|
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
||||||
|
console.log(`To: ${recipients}`);
|
||||||
|
console.log(`Preview URL: ${previewUrl}`);
|
||||||
|
console.log(`Message ID: ${info.messageId}`);
|
||||||
|
console.log('='.repeat(80) + '\n');
|
||||||
|
|
||||||
|
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
||||||
|
logger.info(`📧 Preview URL: ${previewUrl}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Email sent but preview URL not available. Message ID: ${info.messageId}`);
|
||||||
|
logger.warn(`💡 This can happen if the email service is rate-limited or the message hasn't been processed yet.`);
|
||||||
|
}
|
||||||
|
} catch (previewError: any) {
|
||||||
|
logger.error(`❌ Failed to generate preview URL:`, previewError);
|
||||||
|
logger.warn(`⚠️ Email was sent successfully (Message ID: ${info.messageId}) but preview URL generation failed.`);
|
||||||
|
logger.warn(`💡 You can try sending the email again to get a new preview URL.`);
|
||||||
|
// Don't throw - email was sent successfully, just preview URL failed
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(`✅ Email sent to ${recipients}: ${options.subject}`);
|
logger.info(`✅ Email sent to ${recipients}: ${options.subject}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export class EmailNotificationService {
|
|||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||||
firstApproverName: firstApproverData.displayName || firstApproverData.email,
|
firstApproverName: firstApproverData.displayName || firstApproverData.email,
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
requestTime: this.formatTime(requestData.createdAt),
|
requestTime: this.formatTime(requestData.createdAt),
|
||||||
@ -157,7 +157,7 @@ export class EmailNotificationService {
|
|||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
requestDescription: requestData.description || '',
|
requestDescription: requestData.description || '',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
@ -188,7 +188,7 @@ export class EmailNotificationService {
|
|||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
requestDescription: requestData.description || '',
|
requestDescription: requestData.description || '',
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
@ -243,7 +243,7 @@ export class EmailNotificationService {
|
|||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
approvalDate: this.formatDate(approverData.approvedAt || new Date()),
|
approvalDate: this.formatDate(approverData.approvedAt || new Date()),
|
||||||
approvalTime: this.formatTime(approverData.approvedAt || new Date()),
|
approvalTime: this.formatTime(approverData.approvedAt || new Date()),
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
approverComments: approverData.comments || undefined,
|
approverComments: approverData.comments || undefined,
|
||||||
isFinalApproval,
|
isFinalApproval,
|
||||||
nextApproverName: nextApproverData?.displayName || nextApproverData?.email,
|
nextApproverName: nextApproverData?.displayName || nextApproverData?.email,
|
||||||
@ -296,7 +296,7 @@ export class EmailNotificationService {
|
|||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
rejectionDate: this.formatDate(approverData.rejectedAt || new Date()),
|
rejectionDate: this.formatDate(approverData.rejectedAt || new Date()),
|
||||||
rejectionTime: this.formatTime(approverData.rejectedAt || new Date()),
|
rejectionTime: this.formatTime(approverData.rejectedAt || new Date()),
|
||||||
requestType: requestData.requestType || 'General',
|
requestType: requestData.templateType || requestData.requestType || 'CUSTOM',
|
||||||
rejectionReason,
|
rejectionReason,
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
companyName: CompanyInfo.name
|
companyName: CompanyInfo.name
|
||||||
@ -348,12 +348,27 @@ export class EmailNotificationService {
|
|||||||
: tatInfo.thresholdPercentage >= 50 ? 'medium'
|
: tatInfo.thresholdPercentage >= 50 ? 'medium'
|
||||||
: 'low';
|
: 'low';
|
||||||
|
|
||||||
|
// Get initiator name - try from requestData first, then fetch if needed
|
||||||
|
let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator';
|
||||||
|
if (initiatorName === 'Initiator' && requestData.initiatorId) {
|
||||||
|
try {
|
||||||
|
const { User } = await import('@models/index');
|
||||||
|
const initiator = await User.findByPk(requestData.initiatorId);
|
||||||
|
if (initiator) {
|
||||||
|
const initiatorJson = initiator.toJSON();
|
||||||
|
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to fetch initiator for TAT reminder: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data: TATReminderData = {
|
const data: TATReminderData = {
|
||||||
recipientName: approverData.displayName || approverData.email,
|
recipientName: approverData.displayName || approverData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: requestData.initiatorName || 'Initiator',
|
initiatorName: initiatorName,
|
||||||
assignedDate: this.formatDate(tatInfo.assignedDate),
|
assignedDate: this.formatDate(tatInfo.assignedDate),
|
||||||
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
||||||
timeRemaining: tatInfo.timeRemaining,
|
timeRemaining: tatInfo.timeRemaining,
|
||||||
@ -404,12 +419,27 @@ export class EmailNotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get initiator name - try from requestData first, then fetch if needed
|
||||||
|
let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator';
|
||||||
|
if (initiatorName === 'Initiator' && requestData.initiatorId) {
|
||||||
|
try {
|
||||||
|
const { User } = await import('@models/index');
|
||||||
|
const initiator = await User.findByPk(requestData.initiatorId);
|
||||||
|
if (initiator) {
|
||||||
|
const initiatorJson = initiator.toJSON();
|
||||||
|
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to fetch initiator for TAT breach: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data: TATBreachedData = {
|
const data: TATBreachedData = {
|
||||||
recipientName: approverData.displayName || approverData.email,
|
recipientName: approverData.displayName || approverData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
approverName: approverData.displayName || approverData.email,
|
approverName: approverData.displayName || approverData.email,
|
||||||
initiatorName: requestData.initiatorName || 'Initiator',
|
initiatorName: initiatorName,
|
||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
assignedDate: this.formatDate(tatInfo.assignedDate),
|
assignedDate: this.formatDate(tatInfo.assignedDate),
|
||||||
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
||||||
@ -446,6 +476,15 @@ export class EmailNotificationService {
|
|||||||
pauseDuration: string
|
pauseDuration: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Validate approver data has email
|
||||||
|
if (!approverData || !approverData.email) {
|
||||||
|
logger.warn(`[Email] Cannot send Workflow Resumed email: approver email missing`, {
|
||||||
|
approverData: approverData ? { userId: approverData.userId, displayName: approverData.displayName } : null,
|
||||||
|
requestNumber: requestData.requestNumber
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canSend = await shouldSendEmail(
|
const canSend = await shouldSendEmail(
|
||||||
approverData.userId,
|
approverData.userId,
|
||||||
EmailNotificationType.WORKFLOW_RESUMED
|
EmailNotificationType.WORKFLOW_RESUMED
|
||||||
@ -495,6 +534,75 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Workflow Resumed Email to Initiator
|
||||||
|
*/
|
||||||
|
async sendWorkflowResumedToInitiator(
|
||||||
|
requestData: any,
|
||||||
|
initiatorData: any,
|
||||||
|
approverData: any,
|
||||||
|
resumedByData: any,
|
||||||
|
pauseDuration: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Validate initiator data has email
|
||||||
|
if (!initiatorData || !initiatorData.email) {
|
||||||
|
logger.warn(`[Email] Cannot send Workflow Resumed email to initiator: email missing`, {
|
||||||
|
initiatorData: initiatorData ? { userId: initiatorData.userId, displayName: initiatorData.displayName } : null,
|
||||||
|
requestNumber: requestData.requestNumber
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = await shouldSendEmail(
|
||||||
|
initiatorData.userId,
|
||||||
|
EmailNotificationType.WORKFLOW_RESUMED
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canSend) {
|
||||||
|
logger.info(`Email skipped (preferences): Workflow Resumed for initiator ${initiatorData.email}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAutoResumed = !resumedByData || resumedByData.userId === 'system' || !resumedByData.userId;
|
||||||
|
const resumedByText = isAutoResumed
|
||||||
|
? 'automatically'
|
||||||
|
: `by ${resumedByData.displayName || resumedByData.email || resumedByData.name || 'User'}`;
|
||||||
|
|
||||||
|
const data: WorkflowResumedData = {
|
||||||
|
recipientName: initiatorData.displayName || initiatorData.email,
|
||||||
|
requestId: requestData.requestNumber,
|
||||||
|
requestTitle: requestData.title,
|
||||||
|
resumedByText,
|
||||||
|
resumedDate: this.formatDate(new Date()),
|
||||||
|
resumedTime: this.formatTime(new Date()),
|
||||||
|
pausedDuration: pauseDuration,
|
||||||
|
currentApprover: approverData?.displayName || approverData?.email || 'Current Approver',
|
||||||
|
newTATDeadline: requestData.tatDeadline
|
||||||
|
? this.formatDate(requestData.tatDeadline) + ' ' + this.formatTime(requestData.tatDeadline)
|
||||||
|
: 'To be determined',
|
||||||
|
isApprover: false, // This is for initiator
|
||||||
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
|
companyName: CompanyInfo.name
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = getWorkflowResumedEmail(data);
|
||||||
|
const subject = `[${requestData.requestNumber}] Workflow Resumed`;
|
||||||
|
|
||||||
|
const result = await emailService.sendEmail({
|
||||||
|
to: initiatorData.email,
|
||||||
|
subject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.previewUrl) {
|
||||||
|
logger.info(`📧 Workflow Resumed Email Preview (Initiator): ${result.previewUrl}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send Workflow Resumed email to initiator:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 8. Send Request Closed Email
|
* 8. Send Request Closed Email
|
||||||
*/
|
*/
|
||||||
@ -523,11 +631,26 @@ export class EmailNotificationService {
|
|||||||
const duration = closedDate.diff(createdDate, 'day');
|
const duration = closedDate.diff(createdDate, 'day');
|
||||||
const totalDuration = `${duration} day${duration !== 1 ? 's' : ''}`;
|
const totalDuration = `${duration} day${duration !== 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
// Get initiator name - try from requestData first, then fetch if needed
|
||||||
|
let initiatorName = requestData.initiatorName || requestData.initiator?.displayName || 'Initiator';
|
||||||
|
if (initiatorName === 'Initiator' && requestData.initiatorId) {
|
||||||
|
try {
|
||||||
|
const { User } = await import('@models/index');
|
||||||
|
const initiator = await User.findByPk(requestData.initiatorId);
|
||||||
|
if (initiator) {
|
||||||
|
const initiatorJson = initiator.toJSON();
|
||||||
|
initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to fetch initiator for closed request: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data: RequestClosedData = {
|
const data: RequestClosedData = {
|
||||||
recipientName: recipientData.displayName || recipientData.email,
|
recipientName: recipientData.displayName || recipientData.email,
|
||||||
requestId: requestData.requestNumber,
|
requestId: requestData.requestNumber,
|
||||||
requestTitle: requestData.title,
|
requestTitle: requestData.title,
|
||||||
initiatorName: requestData.initiatorName || 'Initiator',
|
initiatorName: initiatorName,
|
||||||
createdDate: this.formatDate(requestData.createdAt),
|
createdDate: this.formatDate(requestData.createdAt),
|
||||||
closedDate: this.formatDate(requestData.closedAt || new Date()),
|
closedDate: this.formatDate(requestData.closedAt || new Date()),
|
||||||
closedTime: this.formatTime(requestData.closedAt || new Date()),
|
closedTime: this.formatTime(requestData.closedAt || new Date()),
|
||||||
@ -575,6 +698,118 @@ export class EmailNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 9. Send Approver Skipped Email
|
||||||
|
*/
|
||||||
|
async sendApproverSkipped(
|
||||||
|
requestData: any,
|
||||||
|
skippedApproverData: any,
|
||||||
|
skippedByData: any,
|
||||||
|
nextApproverData: any,
|
||||||
|
skipReason: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const canSend = await shouldSendEmail(
|
||||||
|
skippedApproverData.userId,
|
||||||
|
EmailNotificationType.APPROVER_SKIPPED
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canSend) {
|
||||||
|
logger.info(`Email skipped (preferences): Approver Skipped for ${skippedApproverData.email}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ApproverSkippedData = {
|
||||||
|
recipientName: skippedApproverData.displayName || skippedApproverData.email,
|
||||||
|
requestId: requestData.requestNumber,
|
||||||
|
requestTitle: requestData.title,
|
||||||
|
skippedApproverName: skippedApproverData.displayName || skippedApproverData.email,
|
||||||
|
skippedByName: skippedByData.displayName || skippedByData.email,
|
||||||
|
skippedDate: this.formatDate(new Date()),
|
||||||
|
skippedTime: this.formatTime(new Date()),
|
||||||
|
nextApproverName: nextApproverData?.displayName || nextApproverData?.email || 'Next Approver',
|
||||||
|
skipReason: skipReason || 'Not provided',
|
||||||
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
|
companyName: CompanyInfo.name
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = getApproverSkippedEmail(data);
|
||||||
|
const subject = `[${requestData.requestNumber}] Approver Skipped`;
|
||||||
|
|
||||||
|
const result = await emailService.sendEmail({
|
||||||
|
to: skippedApproverData.email,
|
||||||
|
subject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.previewUrl) {
|
||||||
|
logger.info(`📧 Approver Skipped Email Preview: ${result.previewUrl}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send Approver Skipped email:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 10. Send Workflow Paused Email
|
||||||
|
*/
|
||||||
|
async sendWorkflowPaused(
|
||||||
|
requestData: any,
|
||||||
|
recipientData: any,
|
||||||
|
pausedByData: any,
|
||||||
|
pauseReason: string,
|
||||||
|
resumeDate: Date | string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Validate recipient data has email
|
||||||
|
if (!recipientData || !recipientData.email) {
|
||||||
|
logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, {
|
||||||
|
recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null,
|
||||||
|
requestNumber: requestData.requestNumber
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = await shouldSendEmail(
|
||||||
|
recipientData.userId,
|
||||||
|
EmailNotificationType.WORKFLOW_PAUSED
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canSend) {
|
||||||
|
logger.info(`Email skipped (preferences): Workflow Paused for ${recipientData.email}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: WorkflowPausedData = {
|
||||||
|
recipientName: recipientData.displayName || recipientData.email,
|
||||||
|
requestId: requestData.requestNumber,
|
||||||
|
requestTitle: requestData.title,
|
||||||
|
pausedByName: pausedByData?.displayName || pausedByData?.email || 'System',
|
||||||
|
pausedDate: this.formatDate(new Date()),
|
||||||
|
pausedTime: this.formatTime(new Date()),
|
||||||
|
resumeDate: this.formatDate(resumeDate),
|
||||||
|
pauseReason: pauseReason || 'Not provided',
|
||||||
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
|
companyName: CompanyInfo.name
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = getWorkflowPausedEmail(data);
|
||||||
|
const subject = `[${requestData.requestNumber}] Workflow Paused`;
|
||||||
|
|
||||||
|
const result = await emailService.sendEmail({
|
||||||
|
to: recipientData.email,
|
||||||
|
subject,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.previewUrl) {
|
||||||
|
logger.info(`📧 Workflow Paused Email Preview: ${result.previewUrl}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send Workflow Paused email:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add more email methods as needed...
|
// Add more email methods as needed...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,12 +128,15 @@ class GCSStorageService {
|
|||||||
// Ensure bucket exists before uploading
|
// Ensure bucket exists before uploading
|
||||||
await this.ensureBucketExists();
|
await this.ensureBucketExists();
|
||||||
|
|
||||||
// Generate unique file name
|
// Generate unique file name with original name first for readability
|
||||||
|
// Format: originalName-timestamp-hash.ext (e.g., proposal-1766490022228-qjlojs.pdf)
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const randomHash = Math.random().toString(36).substring(2, 8);
|
const randomHash = Math.random().toString(36).substring(2, 8);
|
||||||
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const extension = path.extname(originalName);
|
const extension = path.extname(originalName);
|
||||||
const fileName = `${timestamp}-${randomHash}-${safeName}`;
|
// Extract name without extension, then add timestamp and hash before extension
|
||||||
|
const nameWithoutExt = safeName.substring(0, safeName.length - extension.length);
|
||||||
|
const fileName = `${nameWithoutExt}-${timestamp}-${randomHash}${extension}`;
|
||||||
|
|
||||||
// Build GCS path: requests/{requestNumber}/{fileType}/{fileName}
|
// Build GCS path: requests/{requestNumber}/{fileType}/{fileName}
|
||||||
// Example: requests/REQ-2025-12-0001/documents/proposal.pdf
|
// Example: requests/REQ-2025-12-0001/documents/proposal.pdf
|
||||||
@ -265,11 +268,15 @@ class GCSStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate unique file name (same format as GCS)
|
// Generate unique file name (same format as GCS) - original name first for readability
|
||||||
|
// Format: originalName-timestamp-hash.ext (e.g., proposal-1766490022228-qjlojs.pdf)
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const randomHash = Math.random().toString(36).substring(2, 8);
|
const randomHash = Math.random().toString(36).substring(2, 8);
|
||||||
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
const fileName = `${timestamp}-${randomHash}-${safeName}`;
|
const extension = path.extname(originalName);
|
||||||
|
// Extract name without extension, then add timestamp and hash before extension
|
||||||
|
const nameWithoutExt = safeName.substring(0, safeName.length - extension.length);
|
||||||
|
const fileName = `${nameWithoutExt}-${timestamp}-${randomHash}${extension}`;
|
||||||
|
|
||||||
// Build local path: uploads/requests/{requestNumber}/{fileType}/{fileName}
|
// Build local path: uploads/requests/{requestNumber}/{fileType}/{fileName}
|
||||||
// This matches the GCS structure: requests/{requestNumber}/{fileType}/{fileName}
|
// This matches the GCS structure: requests/{requestNumber}/{fileType}/{fileName}
|
||||||
|
|||||||
449
src/services/googleSecretManager.service.ts
Normal file
449
src/services/googleSecretManager.service.ts
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
|
||||||
|
import logger from '@utils/logger';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Secret Manager Service
|
||||||
|
*
|
||||||
|
* This service loads secrets from Google Cloud Secret Manager and merges them
|
||||||
|
* with the current process.env, allowing for minimal changes to existing code.
|
||||||
|
*
|
||||||
|
* Configuration:
|
||||||
|
* - Set USE_GOOGLE_SECRET_MANAGER=true to enable (default: false)
|
||||||
|
* - Set GCP_PROJECT_ID to your Google Cloud Project ID
|
||||||
|
* - Set GCP_SECRET_PREFIX to prefix all secret names (optional, default: empty)
|
||||||
|
* - Set GCP_SECRET_MAP_FILE to map secret names to env vars (optional, see format below)
|
||||||
|
*
|
||||||
|
* Secret Name Mapping:
|
||||||
|
* - By default, secrets are mapped to env vars with the same name (uppercase)
|
||||||
|
* - Example: secret "db_password" -> process.env.DB_PASSWORD
|
||||||
|
* - Use GCP_SECRET_MAP_FILE to provide a JSON mapping file for custom mappings
|
||||||
|
*
|
||||||
|
* Fallback:
|
||||||
|
* - If Google Secret Manager is disabled or fails, falls back to .env file
|
||||||
|
* - Existing environment variables take precedence unless explicitly overridden
|
||||||
|
*/
|
||||||
|
class GoogleSecretManagerService {
|
||||||
|
private client: SecretManagerServiceClient | null = null;
|
||||||
|
private projectId: string;
|
||||||
|
private secretPrefix: string;
|
||||||
|
private secretMap: Record<string, string> = {};
|
||||||
|
private isInitialized: boolean = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.projectId = process.env.GCP_PROJECT_ID || '';
|
||||||
|
this.secretPrefix = process.env.GCP_SECRET_PREFIX || '';
|
||||||
|
|
||||||
|
// Load secret mapping file if provided
|
||||||
|
const mapFile = process.env.GCP_SECRET_MAP_FILE;
|
||||||
|
if (mapFile) {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const mapFilePath = path.resolve(mapFile);
|
||||||
|
if (fs.existsSync(mapFilePath)) {
|
||||||
|
const mapContent = fs.readFileSync(mapFilePath, 'utf8');
|
||||||
|
this.secretMap = JSON.parse(mapContent);
|
||||||
|
logger.info(`[Secret Manager] Loaded secret mapping from ${mapFilePath}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[Secret Manager] Secret mapping file not found: ${mapFilePath}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn(`[Secret Manager] Failed to load secret mapping file: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Google Secret Manager client
|
||||||
|
*/
|
||||||
|
private async initializeClient(): Promise<void> {
|
||||||
|
if (this.client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||||
|
let originalCredentialsEnv: string | undefined;
|
||||||
|
|
||||||
|
// If GCP_KEY_FILE is specified, set GOOGLE_APPLICATION_CREDENTIALS temporarily
|
||||||
|
if (keyFilePath) {
|
||||||
|
const resolvedKeyPath = path.isAbsolute(keyFilePath)
|
||||||
|
? keyFilePath
|
||||||
|
: path.resolve(process.cwd(), keyFilePath);
|
||||||
|
|
||||||
|
if (fs.existsSync(resolvedKeyPath)) {
|
||||||
|
// Save original value if it exists
|
||||||
|
originalCredentialsEnv = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||||
|
// Set it to use the key file
|
||||||
|
process.env.GOOGLE_APPLICATION_CREDENTIALS = resolvedKeyPath;
|
||||||
|
logger.debug(`[Secret Manager] Using key file: ${resolvedKeyPath}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[Secret Manager] Key file not found at: ${resolvedKeyPath}`);
|
||||||
|
logger.warn('[Secret Manager] Will attempt to use Application Default Credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create client - it will use GOOGLE_APPLICATION_CREDENTIALS if set
|
||||||
|
this.client = new SecretManagerServiceClient({
|
||||||
|
projectId: this.projectId,
|
||||||
|
});
|
||||||
|
logger.info('[Secret Manager] ✅ Google Secret Manager client initialized');
|
||||||
|
} finally {
|
||||||
|
// Restore original GOOGLE_APPLICATION_CREDENTIALS if we changed it
|
||||||
|
if (keyFilePath && originalCredentialsEnv !== undefined) {
|
||||||
|
if (originalCredentialsEnv) {
|
||||||
|
process.env.GOOGLE_APPLICATION_CREDENTIALS = originalCredentialsEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[Secret Manager] Failed to initialize client:', error);
|
||||||
|
if (error.message?.includes('Could not load the default credentials')) {
|
||||||
|
logger.error('[Secret Manager] Authentication failed. Please check:');
|
||||||
|
logger.error('[Secret Manager] 1. GCP_KEY_FILE points to a valid service account JSON file');
|
||||||
|
logger.error('[Secret Manager] 2. Service account has Secret Manager Secret Accessor role');
|
||||||
|
logger.error('[Secret Manager] 3. The key file is readable and not corrupted');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get secret value from Google Secret Manager
|
||||||
|
*/
|
||||||
|
private async getSecret(secretName: string): Promise<string | null> {
|
||||||
|
if (!this.client || !this.projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullSecretName = this.secretPrefix
|
||||||
|
? `${this.secretPrefix}-${secretName}`
|
||||||
|
: secretName;
|
||||||
|
|
||||||
|
const name = `projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [version] = await this.client.accessSecretVersion({ name });
|
||||||
|
|
||||||
|
if (version.payload?.data) {
|
||||||
|
const secretValue = version.payload.data.toString();
|
||||||
|
logger.debug(`[Secret Manager] ✅ Fetched secret: ${fullSecretName}`);
|
||||||
|
return secretValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[Secret Manager] Secret ${fullSecretName} exists but payload is empty`);
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
const isOktaSecret = /OKTA_/i.test(secretName);
|
||||||
|
const logLevel = isOktaSecret ? logger.info.bind(logger) : logger.debug.bind(logger);
|
||||||
|
|
||||||
|
// Handle "not found" errors (code 5 = NOT_FOUND)
|
||||||
|
if (error.code === 5 || error.code === 'NOT_FOUND' || error.message?.includes('not found')) {
|
||||||
|
logLevel(`[Secret Manager] Secret not found: ${fullSecretName} (project: ${this.projectId})`);
|
||||||
|
if (isOktaSecret) {
|
||||||
|
logger.info(`[Secret Manager] Searched path: projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle permission errors (code 7 = PERMISSION_DENIED)
|
||||||
|
if (error.code === 7 || error.code === 'PERMISSION_DENIED' || error.message?.includes('Permission denied')) {
|
||||||
|
logger.warn(`[Secret Manager] ❌ Permission denied for secret '${fullSecretName}'`);
|
||||||
|
if (isOktaSecret) {
|
||||||
|
logger.warn(`[Secret Manager] This is an OKTA secret - check service account permissions`);
|
||||||
|
}
|
||||||
|
logger.warn(`[Secret Manager] Service account needs 'Secret Manager Secret Accessor' role`);
|
||||||
|
logger.warn(`[Secret Manager] To grant access, run:`);
|
||||||
|
logger.warn(`[Secret Manager] gcloud secrets add-iam-policy-binding ${fullSecretName} \\`);
|
||||||
|
logger.warn(`[Secret Manager] --member="serviceAccount:YOUR_SERVICE_ACCOUNT@${this.projectId}.iam.gserviceaccount.com" \\`);
|
||||||
|
logger.warn(`[Secret Manager] --role="roles/secretmanager.secretAccessor" \\`);
|
||||||
|
logger.warn(`[Secret Manager] --project=${this.projectId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full error details for debugging (info level for OKTA secrets)
|
||||||
|
const errorLogLevel = isOktaSecret ? logger.warn.bind(logger) : logger.warn.bind(logger);
|
||||||
|
errorLogLevel(`[Secret Manager] Failed to fetch secret '${fullSecretName}' (project: ${this.projectId})`);
|
||||||
|
errorLogLevel(`[Secret Manager] Error code: ${error.code || 'unknown'}, Message: ${error.message || 'no message'}`);
|
||||||
|
if (error.details) {
|
||||||
|
errorLogLevel(`[Secret Manager] Error details: ${JSON.stringify(error.details)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map secret name to environment variable name
|
||||||
|
*/
|
||||||
|
private getEnvVarName(secretName: string): string {
|
||||||
|
// Check if there's a custom mapping
|
||||||
|
if (this.secretMap[secretName]) {
|
||||||
|
return this.secretMap[secretName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: convert secret name to uppercase and replace hyphens with underscores
|
||||||
|
// Example: "db-password" -> "DB_PASSWORD", "JWT_SECRET" -> "JWT_SECRET"
|
||||||
|
return secretName.toUpperCase().replace(/-/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all secrets from Google Secret Manager and merge with process.env
|
||||||
|
*
|
||||||
|
* @param secretNames - Array of secret names to load. If not provided,
|
||||||
|
* will attempt to load common secret names based on env.example
|
||||||
|
*/
|
||||||
|
async loadSecrets(secretNames?: string[]): Promise<void> {
|
||||||
|
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||||
|
|
||||||
|
if (!useSecretManager) {
|
||||||
|
logger.debug('[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.projectId) {
|
||||||
|
logger.warn('[Secret Manager] GCP_PROJECT_ID not set, skipping Google Secret Manager');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.initializeClient();
|
||||||
|
|
||||||
|
// Default list of secrets to load if not provided
|
||||||
|
const secretsToLoad = secretNames || this.getDefaultSecretNames();
|
||||||
|
|
||||||
|
logger.info(`[Secret Manager] Loading ${secretsToLoad.length} secrets from Google Secret Manager (project: ${this.projectId})...`);
|
||||||
|
if (this.secretPrefix) {
|
||||||
|
logger.info(`[Secret Manager] Using secret prefix: ${this.secretPrefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedSecrets: Record<string, string> = {};
|
||||||
|
const notFoundSecrets: string[] = [];
|
||||||
|
let loadedCount = 0;
|
||||||
|
|
||||||
|
// Log OKTA and EMAIL secrets specifically if they're in the list
|
||||||
|
const oktaSecrets = secretsToLoad.filter(name => /^OKTA_/i.test(name));
|
||||||
|
const emailSecrets = secretsToLoad.filter(name => /^EMAIL_|^SMTP_/i.test(name));
|
||||||
|
if (oktaSecrets.length > 0) {
|
||||||
|
logger.info(`[Secret Manager] 🔍 Attempting to load OKTA secrets: ${oktaSecrets.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (emailSecrets.length > 0) {
|
||||||
|
logger.info(`[Secret Manager] 📧 Attempting to load EMAIL secrets: ${emailSecrets.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load each secret
|
||||||
|
for (const secretName of secretsToLoad) {
|
||||||
|
const fullSecretName = this.secretPrefix
|
||||||
|
? `${this.secretPrefix}-${secretName}`
|
||||||
|
: secretName;
|
||||||
|
|
||||||
|
// Log OKTA and EMAIL secret attempts in detail
|
||||||
|
const isOktaSecret = /^OKTA_/i.test(secretName);
|
||||||
|
const isEmailSecret = /^EMAIL_|^SMTP_/i.test(secretName);
|
||||||
|
if (isOktaSecret || isEmailSecret) {
|
||||||
|
logger.info(`[Secret Manager] Attempting to load: ${secretName} (full name: ${fullSecretName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretValue = await this.getSecret(secretName);
|
||||||
|
|
||||||
|
if (secretValue !== null) {
|
||||||
|
const envVarName = this.getEnvVarName(secretName);
|
||||||
|
loadedSecrets[envVarName] = secretValue;
|
||||||
|
loadedCount++;
|
||||||
|
if (isOktaSecret || isEmailSecret) {
|
||||||
|
logger.info(`[Secret Manager] ✅ Successfully loaded: ${secretName} -> ${envVarName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Track which secrets weren't found for better logging
|
||||||
|
notFoundSecrets.push(fullSecretName);
|
||||||
|
if (isOktaSecret || isEmailSecret) {
|
||||||
|
logger.warn(`[Secret Manager] ❌ Not found: ${secretName} (searched as: ${fullSecretName})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge secrets into process.env (only override if secret exists)
|
||||||
|
// Log when overriding existing env vars vs setting new ones
|
||||||
|
for (const [envVar, value] of Object.entries(loadedSecrets)) {
|
||||||
|
const existingValue = process.env[envVar];
|
||||||
|
const isOverriding = existingValue !== undefined;
|
||||||
|
|
||||||
|
process.env[envVar] = value;
|
||||||
|
|
||||||
|
// Log override behavior for debugging
|
||||||
|
if (isOverriding) {
|
||||||
|
logger.debug(`[Secret Manager] 🔄 Overrode existing env var: ${envVar} (was: ${existingValue ? 'set' : 'undefined'}, now: from Secret Manager)`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`[Secret Manager] ✨ Set new env var: ${envVar} (from Secret Manager)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[Secret Manager] ✅ Successfully loaded ${loadedCount}/${secretsToLoad.length} secrets`);
|
||||||
|
|
||||||
|
if (loadedCount > 0) {
|
||||||
|
const loadedVars = Object.keys(loadedSecrets);
|
||||||
|
logger.info(`[Secret Manager] Loaded env vars: ${loadedVars.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[Secret Manager] ⚠️ No secrets were loaded. This might be normal if secrets don't exist yet.`);
|
||||||
|
logger.info(`[Secret Manager] To create secrets, use: gcloud secrets create SECRET_NAME --data-file=- --project=${this.projectId}`);
|
||||||
|
if (notFoundSecrets.length > 0 && notFoundSecrets.length <= 5) {
|
||||||
|
logger.info(`[Secret Manager] Example secrets to create: ${notFoundSecrets.slice(0, 3).join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log summary of not found secrets, especially OKTA and EMAIL ones
|
||||||
|
if (notFoundSecrets.length > 0) {
|
||||||
|
const notFoundOkta = notFoundSecrets.filter(name => /OKTA_/i.test(name));
|
||||||
|
const notFoundEmail = notFoundSecrets.filter(name => /EMAIL_|SMTP_/i.test(name));
|
||||||
|
|
||||||
|
if (notFoundOkta.length > 0) {
|
||||||
|
logger.warn(`[Secret Manager] ⚠️ OKTA secrets not found (${notFoundOkta.length}): ${notFoundOkta.join(', ')}`);
|
||||||
|
logger.info(`[Secret Manager] 💡 To create OKTA secrets, use:`);
|
||||||
|
notFoundOkta.forEach(secretName => {
|
||||||
|
logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFoundEmail.length > 0) {
|
||||||
|
logger.warn(`[Secret Manager] ⚠️ EMAIL secrets not found (${notFoundEmail.length}): ${notFoundEmail.join(', ')}`);
|
||||||
|
logger.info(`[Secret Manager] 💡 To create EMAIL secrets, use:`);
|
||||||
|
notFoundEmail.forEach(secretName => {
|
||||||
|
logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherNotFound = notFoundSecrets.filter(name => !/OKTA_|EMAIL_|SMTP_/i.test(name));
|
||||||
|
if (otherNotFound.length > 0) {
|
||||||
|
logger.debug(`[Secret Manager] Other secrets not found (${otherNotFound.length}): ${otherNotFound.slice(0, 10).join(', ')}${otherNotFound.length > 10 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[Secret Manager] Failed to load secrets:', error);
|
||||||
|
// Don't throw - allow fallback to .env file
|
||||||
|
logger.warn('[Secret Manager] Falling back to .env file and existing environment variables');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default list of secret names based on common environment variables
|
||||||
|
*/
|
||||||
|
private getDefaultSecretNames(): string[] {
|
||||||
|
return [
|
||||||
|
// Database
|
||||||
|
'DB_PASSWORD',
|
||||||
|
|
||||||
|
// JWT & Session
|
||||||
|
'JWT_SECRET',
|
||||||
|
'REFRESH_TOKEN_SECRET',
|
||||||
|
'SESSION_SECRET',
|
||||||
|
|
||||||
|
// Okta/SSO
|
||||||
|
'OKTA_CLIENT_ID',
|
||||||
|
'OKTA_CLIENT_SECRET',
|
||||||
|
'OKTA_API_TOKEN',
|
||||||
|
|
||||||
|
// Email
|
||||||
|
'SMTP_HOST',
|
||||||
|
'SMTP_PORT',
|
||||||
|
'SMTP_USER',
|
||||||
|
'SMTP_PASSWORD',
|
||||||
|
'EMAIL_FROM',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single secret value
|
||||||
|
* Useful for on-demand secret retrieval
|
||||||
|
*/
|
||||||
|
async getSecretValue(secretName: string, envVarName?: string): Promise<string | null> {
|
||||||
|
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||||
|
|
||||||
|
if (!useSecretManager || !this.projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.client) {
|
||||||
|
await this.initializeClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretValue = await this.getSecret(secretName);
|
||||||
|
|
||||||
|
if (secretValue !== null) {
|
||||||
|
const envVar = envVarName || this.getEnvVarName(secretName);
|
||||||
|
process.env[envVar] = secretValue;
|
||||||
|
logger.debug(`[Secret Manager] ✅ Loaded secret ${secretName} -> ${envVar}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretValue;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[Secret Manager] Failed to get secret ${secretName}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if secret manager is initialized and ready
|
||||||
|
*/
|
||||||
|
isReady(): boolean {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all secrets in the project (for debugging/setup verification)
|
||||||
|
* Returns array of secret names
|
||||||
|
*/
|
||||||
|
async listSecrets(): Promise<string[]> {
|
||||||
|
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||||
|
|
||||||
|
if (!useSecretManager || !this.projectId) {
|
||||||
|
logger.warn('[Secret Manager] Cannot list secrets: Secret Manager not enabled or project ID not set');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.client) {
|
||||||
|
await this.initializeClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.client) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = `projects/${this.projectId}`;
|
||||||
|
const [secrets] = await this.client.listSecrets({ parent });
|
||||||
|
|
||||||
|
const secretNames = secrets.map(secret => {
|
||||||
|
// Extract secret name from full path: projects/PROJECT/secrets/NAME -> NAME
|
||||||
|
const nameParts = secret.name?.split('/') || [];
|
||||||
|
return nameParts[nameParts.length - 1] || '';
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
logger.info(`[Secret Manager] Found ${secretNames.length} secrets in project ${this.projectId}`);
|
||||||
|
if (secretNames.length > 0) {
|
||||||
|
logger.info(`[Secret Manager] Available secrets: ${secretNames.slice(0, 10).join(', ')}${secretNames.length > 10 ? '...' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretNames;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`[Secret Manager] Failed to list secrets: ${error.message || JSON.stringify(error)}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const googleSecretManager = new GoogleSecretManagerService();
|
||||||
|
|
||||||
|
// Export for easy initialization
|
||||||
|
export async function initializeGoogleSecretManager(secretNames?: string[]): Promise<void> {
|
||||||
|
await googleSecretManager.loadSecrets(secretNames);
|
||||||
|
}
|
||||||
|
|
||||||
@ -123,10 +123,12 @@ class NotificationService {
|
|||||||
|
|
||||||
for (const userId of userIds) {
|
for (const userId of userIds) {
|
||||||
try {
|
try {
|
||||||
// Fetch user preferences
|
// Fetch user preferences and email data
|
||||||
const user = await User.findByPk(userId, {
|
const user = await User.findByPk(userId, {
|
||||||
attributes: [
|
attributes: [
|
||||||
'userId',
|
'userId',
|
||||||
|
'email',
|
||||||
|
'displayName',
|
||||||
'emailNotificationsEnabled',
|
'emailNotificationsEnabled',
|
||||||
'pushNotificationsEnabled',
|
'pushNotificationsEnabled',
|
||||||
'inAppNotificationsEnabled'
|
'inAppNotificationsEnabled'
|
||||||
@ -281,6 +283,10 @@ class NotificationService {
|
|||||||
'rejection': EmailNotificationType.REQUEST_REJECTED,
|
'rejection': EmailNotificationType.REQUEST_REJECTED,
|
||||||
'tat_reminder': EmailNotificationType.TAT_REMINDER,
|
'tat_reminder': EmailNotificationType.TAT_REMINDER,
|
||||||
'tat_breach': EmailNotificationType.TAT_BREACHED,
|
'tat_breach': EmailNotificationType.TAT_BREACHED,
|
||||||
|
'threshold1': EmailNotificationType.TAT_REMINDER, // 50% TAT reminder
|
||||||
|
'threshold2': EmailNotificationType.TAT_REMINDER, // 75% TAT reminder
|
||||||
|
'breach': EmailNotificationType.TAT_BREACHED, // 100% TAT breach
|
||||||
|
'tat_breach_initiator': EmailNotificationType.TAT_BREACHED, // Breach notification to initiator
|
||||||
'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED,
|
'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED,
|
||||||
'closed': EmailNotificationType.REQUEST_CLOSED,
|
'closed': EmailNotificationType.REQUEST_CLOSED,
|
||||||
// These don't get emails (in-app only)
|
// These don't get emails (in-app only)
|
||||||
@ -290,7 +296,9 @@ class NotificationService {
|
|||||||
'status_change': null,
|
'status_change': null,
|
||||||
'ai_conclusion_generated': null,
|
'ai_conclusion_generated': null,
|
||||||
'summary_generated': null,
|
'summary_generated': null,
|
||||||
'workflow_paused': null, // Conditional - handled separately
|
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
|
||||||
|
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
|
||||||
|
'pause_retrigger_request': EmailNotificationType.WORKFLOW_PAUSED, // Use same template as pause
|
||||||
'pause_retriggered': null
|
'pause_retriggered': null
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -305,9 +313,11 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if email should be sent (admin + user preferences)
|
// Check if email should be sent (admin + user preferences)
|
||||||
// For assignment notifications, always attempt to send email (unless explicitly disabled by admin)
|
// Critical emails: rejection, tat_breach, breach
|
||||||
// This ensures next approvers always receive email notifications
|
const isCriticalEmail = payload.type === 'rejection' ||
|
||||||
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
|
payload.type === 'tat_breach' ||
|
||||||
|
payload.type === 'breach';
|
||||||
|
const shouldSend = isCriticalEmail
|
||||||
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
? await shouldSendEmailWithOverride(userId, emailType) // Critical 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
|
||||||
@ -379,8 +389,17 @@ class NotificationService {
|
|||||||
|
|
||||||
const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null;
|
const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null;
|
||||||
|
|
||||||
|
// Get first approver's TAT hours (not total TAT)
|
||||||
|
const firstApproverTatHours = firstLevel ? (firstLevel as any).tatHours : null;
|
||||||
|
|
||||||
|
// Add first approver's TAT to requestData for the email
|
||||||
|
const requestDataWithFirstTat = {
|
||||||
|
...requestData,
|
||||||
|
tatHours: firstApproverTatHours || (requestData as any).totalTatHours || 24
|
||||||
|
};
|
||||||
|
|
||||||
await emailNotificationService.sendRequestCreated(
|
await emailNotificationService.sendRequestCreated(
|
||||||
requestData,
|
requestDataWithFirstTat,
|
||||||
initiatorData,
|
initiatorData,
|
||||||
firstApprover ? firstApprover.toJSON() : null
|
firstApprover ? firstApprover.toJSON() : null
|
||||||
);
|
);
|
||||||
@ -428,7 +447,7 @@ class NotificationService {
|
|||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
status: 'APPROVED'
|
status: 'APPROVED'
|
||||||
},
|
},
|
||||||
order: [['approvedAt', 'DESC']]
|
order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
const allLevels = await ApprovalLevel.findAll({
|
const allLevels = await ApprovalLevel.findAll({
|
||||||
@ -442,9 +461,21 @@ class NotificationService {
|
|||||||
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
|
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
|
||||||
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
|
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
|
||||||
|
|
||||||
|
// Get the approver who just approved from the approved level
|
||||||
|
let approverData = user; // Fallback to user if we can't find the approver
|
||||||
|
if (approvedLevel) {
|
||||||
|
const approverUser = await User.findByPk((approvedLevel as any).approverId);
|
||||||
|
if (approverUser) {
|
||||||
|
approverData = approverUser.toJSON();
|
||||||
|
// Add approval metadata
|
||||||
|
(approverData as any).approvedAt = (approvedLevel as any).actionDate;
|
||||||
|
(approverData as any).comments = (approvedLevel as any).comments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emailNotificationService.sendApprovalConfirmation(
|
await emailNotificationService.sendApprovalConfirmation(
|
||||||
requestData,
|
requestData,
|
||||||
user, // Approver who just approved
|
approverData, // Approver who just approved
|
||||||
initiatorData,
|
initiatorData,
|
||||||
isFinalApproval,
|
isFinalApproval,
|
||||||
nextApprover ? nextApprover.toJSON() : undefined
|
nextApprover ? nextApprover.toJSON() : undefined
|
||||||
@ -458,12 +489,34 @@ class NotificationService {
|
|||||||
where: {
|
where: {
|
||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
status: 'REJECTED'
|
status: 'REJECTED'
|
||||||
}
|
},
|
||||||
|
order: [['actionDate', 'DESC'], ['levelEndTime', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get the approver who rejected from the rejected level
|
||||||
|
let approverData = user; // Fallback to user if we can't find the approver
|
||||||
|
if (rejectedLevel) {
|
||||||
|
const approverUser = await User.findByPk((rejectedLevel as any).approverId);
|
||||||
|
if (approverUser) {
|
||||||
|
approverData = approverUser.toJSON();
|
||||||
|
// Add rejection metadata
|
||||||
|
(approverData as any).rejectedAt = (rejectedLevel as any).actionDate;
|
||||||
|
(approverData as any).comments = (rejectedLevel as any).comments;
|
||||||
|
} else {
|
||||||
|
// If user not found, use approver info from the level itself
|
||||||
|
approverData = {
|
||||||
|
userId: (rejectedLevel as any).approverId,
|
||||||
|
displayName: (rejectedLevel as any).approverName || 'Unknown Approver',
|
||||||
|
email: (rejectedLevel as any).approverEmail || 'unknown@royalenfield.com',
|
||||||
|
rejectedAt: (rejectedLevel as any).actionDate,
|
||||||
|
comments: (rejectedLevel as any).comments
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await emailNotificationService.sendRejectionNotification(
|
await emailNotificationService.sendRejectionNotification(
|
||||||
requestData,
|
requestData,
|
||||||
user, // Approver who rejected
|
approverData, // Approver who rejected
|
||||||
initiatorData,
|
initiatorData,
|
||||||
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
|
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
|
||||||
);
|
);
|
||||||
@ -471,38 +524,13 @@ class NotificationService {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tat_reminder':
|
case 'tat_reminder':
|
||||||
|
case 'threshold1':
|
||||||
|
case 'threshold2':
|
||||||
case 'tat_breach':
|
case 'tat_breach':
|
||||||
|
case 'breach':
|
||||||
|
case 'tat_breach_initiator':
|
||||||
{
|
{
|
||||||
// Extract TAT info from metadata or payload
|
// Get the approver from the current level (the one who needs to take action)
|
||||||
const tatInfo = payload.metadata?.tatInfo || {
|
|
||||||
thresholdPercentage: payload.type === 'tat_breach' ? 100 : 75,
|
|
||||||
timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
|
|
||||||
tatDeadline: payload.metadata?.tatDeadline || new Date(),
|
|
||||||
assignedDate: payload.metadata?.assignedDate || requestData.createdAt
|
|
||||||
};
|
|
||||||
|
|
||||||
if (notificationType === 'tat_breach') {
|
|
||||||
await emailNotificationService.sendTATBreached(
|
|
||||||
requestData,
|
|
||||||
user,
|
|
||||||
{
|
|
||||||
timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining,
|
|
||||||
tatDeadline: tatInfo.tatDeadline,
|
|
||||||
assignedDate: tatInfo.assignedDate
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await emailNotificationService.sendTATReminder(
|
|
||||||
requestData,
|
|
||||||
user,
|
|
||||||
tatInfo
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'workflow_resumed':
|
|
||||||
{
|
|
||||||
const currentLevel = await ApprovalLevel.findOne({
|
const currentLevel = await ApprovalLevel.findOne({
|
||||||
where: {
|
where: {
|
||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
@ -511,17 +539,158 @@ class NotificationService {
|
|||||||
order: [['levelNumber', 'ASC']]
|
order: [['levelNumber', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentApprover = currentLevel ? await User.findByPk((currentLevel as any).approverId) : null;
|
// Get approver data - prefer from level, fallback to user
|
||||||
|
let approverData = user; // Fallback
|
||||||
|
if (currentLevel) {
|
||||||
|
const approverUser = await User.findByPk((currentLevel as any).approverId);
|
||||||
|
if (approverUser) {
|
||||||
|
approverData = approverUser.toJSON();
|
||||||
|
} else {
|
||||||
|
// If user not found, use approver info from the level itself
|
||||||
|
approverData = {
|
||||||
|
userId: (currentLevel as any).approverId,
|
||||||
|
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
||||||
|
email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine threshold percentage based on notification type
|
||||||
|
let thresholdPercentage = 75; // Default
|
||||||
|
if (notificationType === 'threshold1') {
|
||||||
|
thresholdPercentage = 50;
|
||||||
|
} else if (notificationType === 'threshold2') {
|
||||||
|
thresholdPercentage = 75;
|
||||||
|
} else if (notificationType === 'breach' || notificationType === 'tat_breach' || notificationType === 'tat_breach_initiator') {
|
||||||
|
thresholdPercentage = 100;
|
||||||
|
} else if (payload.metadata?.thresholdPercentage) {
|
||||||
|
thresholdPercentage = payload.metadata.thresholdPercentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract TAT info from metadata or payload
|
||||||
|
const tatInfo = payload.metadata?.tatInfo || {
|
||||||
|
thresholdPercentage: thresholdPercentage,
|
||||||
|
timeRemaining: payload.metadata?.timeRemaining || 'Unknown',
|
||||||
|
tatDeadline: payload.metadata?.tatDeadline || new Date(),
|
||||||
|
assignedDate: payload.metadata?.assignedDate || requestData.createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update threshold percentage if not in tatInfo
|
||||||
|
if (!payload.metadata?.tatInfo) {
|
||||||
|
tatInfo.thresholdPercentage = thresholdPercentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle breach notifications (to approver or initiator)
|
||||||
|
if (notificationType === 'breach' || notificationType === 'tat_breach') {
|
||||||
|
// Breach notification to approver
|
||||||
|
if (approverData && approverData.email) {
|
||||||
|
await emailNotificationService.sendTATBreached(
|
||||||
|
requestData,
|
||||||
|
approverData,
|
||||||
|
{
|
||||||
|
timeOverdue: tatInfo.timeOverdue || tatInfo.timeRemaining || 'Exceeded',
|
||||||
|
tatDeadline: tatInfo.tatDeadline,
|
||||||
|
assignedDate: tatInfo.assignedDate
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (notificationType === 'tat_breach_initiator') {
|
||||||
|
// Breach notification to initiator
|
||||||
|
if (initiatorData && initiatorData.email) {
|
||||||
|
// For initiator, we can use a simpler notification or the same breach template
|
||||||
|
// For now, skip email to initiator on breach (they get in-app notification)
|
||||||
|
// Or we could create a separate initiator breach email template
|
||||||
|
logger.info(`[Email] Breach notification to initiator - in-app only for now`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TAT reminder (threshold1, threshold2, or tat_reminder)
|
||||||
|
if (approverData && approverData.email) {
|
||||||
|
await emailNotificationService.sendTATReminder(
|
||||||
|
requestData,
|
||||||
|
approverData,
|
||||||
|
tatInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'workflow_resumed':
|
||||||
|
{
|
||||||
|
// Get current level to determine approver
|
||||||
|
const currentLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
order: [['levelNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get approver data from current level
|
||||||
|
let approverData = null;
|
||||||
|
if (currentLevel) {
|
||||||
|
const approverUser = await User.findByPk((currentLevel as any).approverId);
|
||||||
|
if (approverUser) {
|
||||||
|
approverData = approverUser.toJSON();
|
||||||
|
} else {
|
||||||
|
// Use approver info from level
|
||||||
|
approverData = {
|
||||||
|
userId: (currentLevel as any).approverId,
|
||||||
|
displayName: (currentLevel as any).approverName || 'Unknown Approver',
|
||||||
|
email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resumedBy = payload.metadata?.resumedBy;
|
const resumedBy = payload.metadata?.resumedBy;
|
||||||
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
|
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
|
||||||
|
|
||||||
await emailNotificationService.sendWorkflowResumed(
|
// Convert user to plain object if needed
|
||||||
requestData,
|
const userData = user.toJSON ? user.toJSON() : user;
|
||||||
currentApprover ? currentApprover.toJSON() : user,
|
|
||||||
initiatorData,
|
// Determine if the recipient is the approver or initiator
|
||||||
resumedBy,
|
const isApprover = approverData && userData.userId === approverData.userId;
|
||||||
pauseDuration
|
const isInitiator = userData.userId === initiatorData.userId;
|
||||||
);
|
|
||||||
|
// Ensure user has email
|
||||||
|
if (!userData.email) {
|
||||||
|
logger.warn(`[Email] Cannot send Workflow Resumed email: user email missing`, {
|
||||||
|
userId: userData.userId,
|
||||||
|
displayName: userData.displayName,
|
||||||
|
requestNumber: requestData.requestNumber
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send appropriate email based on recipient role
|
||||||
|
if (isApprover) {
|
||||||
|
// Recipient is the approver - send approver email
|
||||||
|
await emailNotificationService.sendWorkflowResumed(
|
||||||
|
requestData,
|
||||||
|
userData,
|
||||||
|
initiatorData,
|
||||||
|
resumedBy,
|
||||||
|
pauseDuration
|
||||||
|
);
|
||||||
|
} else if (isInitiator) {
|
||||||
|
// Recipient is the initiator - send initiator email
|
||||||
|
await emailNotificationService.sendWorkflowResumedToInitiator(
|
||||||
|
requestData,
|
||||||
|
userData,
|
||||||
|
approverData,
|
||||||
|
resumedBy,
|
||||||
|
pauseDuration
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Recipient is neither approver nor initiator (spectator) - send initiator-style email
|
||||||
|
await emailNotificationService.sendWorkflowResumedToInitiator(
|
||||||
|
requestData,
|
||||||
|
userData,
|
||||||
|
approverData,
|
||||||
|
resumedBy,
|
||||||
|
pauseDuration
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -541,6 +710,153 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'approver_skipped':
|
||||||
|
{
|
||||||
|
const skippedLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
status: 'SKIPPED'
|
||||||
|
},
|
||||||
|
order: [['levelEndTime', 'DESC'], ['actionDate', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
order: [['levelNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
|
||||||
|
const skippedBy = payload.metadata?.skippedBy ? await User.findByPk(payload.metadata.skippedBy) : null;
|
||||||
|
const skippedApprover = skippedLevel ? await User.findByPk((skippedLevel as any).approverId) : null;
|
||||||
|
|
||||||
|
if (skippedApprover) {
|
||||||
|
await emailNotificationService.sendApproverSkipped(
|
||||||
|
requestData,
|
||||||
|
skippedApprover.toJSON(),
|
||||||
|
skippedBy ? skippedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
|
||||||
|
nextApprover ? nextApprover.toJSON() : null,
|
||||||
|
payload.metadata?.skipReason || (skippedLevel as any)?.skipReason || 'Not provided'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pause_retrigger_request':
|
||||||
|
{
|
||||||
|
// This is when initiator requests approver to resume a paused workflow
|
||||||
|
// Treat it similar to workflow_paused but with different messaging
|
||||||
|
const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
|
||||||
|
const resumeDate = payload.metadata?.resumeDate || new Date();
|
||||||
|
|
||||||
|
// Get recipient data (the approver who paused it)
|
||||||
|
let recipientData = user;
|
||||||
|
if (!recipientData || !recipientData.email) {
|
||||||
|
// Try to get from paused level
|
||||||
|
const pausedLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
isPaused: true
|
||||||
|
},
|
||||||
|
order: [['levelNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pausedLevel) {
|
||||||
|
const approverUser = await User.findByPk((pausedLevel as any).approverId);
|
||||||
|
if (approverUser) {
|
||||||
|
recipientData = approverUser.toJSON();
|
||||||
|
} else {
|
||||||
|
recipientData = {
|
||||||
|
userId: (pausedLevel as any).approverId,
|
||||||
|
displayName: (pausedLevel as any).approverName || 'Unknown Approver',
|
||||||
|
email: (pausedLevel as any).approverEmail || 'unknown@royalenfield.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure email exists before sending
|
||||||
|
if (!recipientData || !recipientData.email) {
|
||||||
|
logger.warn(`[Email] Cannot send Pause Retrigger Request email: recipient email missing`, {
|
||||||
|
recipientData: recipientData ? { userId: recipientData.userId, displayName: recipientData.displayName } : null,
|
||||||
|
requestNumber: requestData.requestNumber
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use workflow paused email template but with retrigger context
|
||||||
|
await emailNotificationService.sendWorkflowPaused(
|
||||||
|
requestData,
|
||||||
|
recipientData,
|
||||||
|
pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
|
||||||
|
`Initiator has requested to resume this workflow. Please review and resume if appropriate.`,
|
||||||
|
resumeDate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'workflow_paused':
|
||||||
|
{
|
||||||
|
const pausedBy = payload.metadata?.pausedBy ? await User.findByPk(payload.metadata.pausedBy) : null;
|
||||||
|
const resumeDate = payload.metadata?.resumeDate || new Date();
|
||||||
|
|
||||||
|
// Get recipient data - prefer from user, ensure it has email
|
||||||
|
let recipientData = user;
|
||||||
|
if (!recipientData || !recipientData.email) {
|
||||||
|
// If user object doesn't have email, try to get from current level
|
||||||
|
const currentLevel = await ApprovalLevel.findOne({
|
||||||
|
where: {
|
||||||
|
requestId: payload.requestId,
|
||||||
|
status: 'PENDING'
|
||||||
|
},
|
||||||
|
order: [['levelNumber', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentLevel) {
|
||||||
|
const approverUser = await User.findByPk((currentLevel as any).approverId);
|
||||||
|
if (approverUser) {
|
||||||
|
recipientData = approverUser.toJSON();
|
||||||
|
} else {
|
||||||
|
// Use approver info from level
|
||||||
|
recipientData = {
|
||||||
|
userId: (currentLevel as any).approverId,
|
||||||
|
displayName: (currentLevel as any).approverName || 'Unknown User',
|
||||||
|
email: (currentLevel as any).approverEmail || 'unknown@royalenfield.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no current level, try to get from initiator
|
||||||
|
const initiatorUser = await User.findByPk(requestData.initiatorId);
|
||||||
|
if (initiatorUser) {
|
||||||
|
recipientData = initiatorUser.toJSON();
|
||||||
|
} else {
|
||||||
|
logger.warn(`[Email] Cannot send Workflow Paused email: no recipient found for request ${payload.requestId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure email exists before sending
|
||||||
|
if (!recipientData.email) {
|
||||||
|
logger.warn(`[Email] Cannot send Workflow Paused email: recipient email missing`, {
|
||||||
|
recipientData: { userId: recipientData.userId, displayName: recipientData.displayName },
|
||||||
|
requestNumber: requestData.requestNumber
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailNotificationService.sendWorkflowPaused(
|
||||||
|
requestData,
|
||||||
|
recipientData,
|
||||||
|
pausedBy ? pausedBy.toJSON() : { userId: null, displayName: 'System', email: 'system' },
|
||||||
|
payload.metadata?.pauseReason || 'Not provided',
|
||||||
|
resumeDate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -182,18 +182,23 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_paused',
|
type: 'workflow_paused',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: false
|
actionRequired: false,
|
||||||
|
metadata: {
|
||||||
|
pauseReason: reason,
|
||||||
|
resumeDate: resumeDate.toISOString(),
|
||||||
|
pausedBy: userId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user who paused (confirmation)
|
// Notify the user who paused (confirmation) - no email for self-action
|
||||||
await notificationService.sendToUsers([userId], {
|
await notificationService.sendToUsers([userId], {
|
||||||
title: 'Workflow Paused Successfully',
|
title: 'Workflow Paused Successfully',
|
||||||
body: `You have paused request "${title}". It will automatically resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`,
|
body: `You have paused request "${title}". It will automatically resume on ${dayjs(resumeDate).format('MMM DD, YYYY')}.`,
|
||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_paused',
|
type: 'status_change', // Use status_change to avoid email for self-action
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
actionRequired: false
|
actionRequired: false
|
||||||
});
|
});
|
||||||
@ -210,7 +215,12 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_paused',
|
type: 'workflow_paused',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: false
|
actionRequired: false,
|
||||||
|
metadata: {
|
||||||
|
pauseReason: reason,
|
||||||
|
resumeDate: resumeDate.toISOString(),
|
||||||
|
pausedBy: userId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,6 +458,12 @@ export class PauseService {
|
|||||||
const isResumedByInitiator = userId === initiatorId;
|
const isResumedByInitiator = userId === initiatorId;
|
||||||
const isResumedByApprover = userId === approverId;
|
const isResumedByApprover = userId === approverId;
|
||||||
|
|
||||||
|
// Calculate pause duration
|
||||||
|
const pausedAt = (level as any).pausedAt || (workflow as any).pausedAt;
|
||||||
|
const pauseDurationMs = pausedAt ? now.getTime() - new Date(pausedAt).getTime() : 0;
|
||||||
|
const pauseDurationHours = Math.round((pauseDurationMs / (1000 * 60 * 60)) * 100) / 100; // Round to 2 decimal places
|
||||||
|
const pauseDuration = pauseDurationHours > 0 ? `${pauseDurationHours} hours` : 'less than 1 hour';
|
||||||
|
|
||||||
// Notify initiator only if someone else resumed (or auto-resume)
|
// Notify initiator only if someone else resumed (or auto-resume)
|
||||||
// Skip if initiator resumed their own request
|
// Skip if initiator resumed their own request
|
||||||
if (!isResumedByInitiator) {
|
if (!isResumedByInitiator) {
|
||||||
@ -459,7 +475,11 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_resumed',
|
type: 'workflow_resumed',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: false
|
actionRequired: false,
|
||||||
|
metadata: {
|
||||||
|
resumedBy: userId ? { userId, name: resumeUserName } : null,
|
||||||
|
pauseDuration: pauseDuration
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,11 +494,15 @@ export class PauseService {
|
|||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_resumed',
|
type: 'workflow_resumed',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true
|
actionRequired: true,
|
||||||
|
metadata: {
|
||||||
|
resumedBy: userId ? { userId, name: resumeUserName } : null,
|
||||||
|
pauseDuration: pauseDuration
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation to the user who resumed (if manual resume)
|
// Send confirmation to the user who resumed (if manual resume) - no email for self-action
|
||||||
if (userId) {
|
if (userId) {
|
||||||
await notificationService.sendToUsers([userId], {
|
await notificationService.sendToUsers([userId], {
|
||||||
title: 'Workflow Resumed Successfully',
|
title: 'Workflow Resumed Successfully',
|
||||||
@ -486,7 +510,7 @@ export class PauseService {
|
|||||||
requestId,
|
requestId,
|
||||||
requestNumber,
|
requestNumber,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
type: 'workflow_resumed',
|
type: 'status_change', // Use status_change to avoid email for self-action
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
actionRequired: isResumedByApprover
|
actionRequired: isResumedByApprover
|
||||||
});
|
});
|
||||||
|
|||||||
@ -221,13 +221,31 @@ export class WorkflowService {
|
|||||||
// Update workflow current level
|
// Update workflow current level
|
||||||
await workflow.update({ currentLevel: nextLevelNumber });
|
await workflow.update({ currentLevel: nextLevelNumber });
|
||||||
|
|
||||||
|
// Notify skipped approver (triggers email)
|
||||||
|
await notificationService.sendToUsers([(level as any).approverId], {
|
||||||
|
title: 'Approver Skipped',
|
||||||
|
body: `You have been skipped in request ${(workflow as any).requestNumber}. The workflow has moved to the next approver.`,
|
||||||
|
requestId,
|
||||||
|
requestNumber: (workflow as any).requestNumber,
|
||||||
|
url: `/request/${(workflow as any).requestNumber}`,
|
||||||
|
type: 'approver_skipped',
|
||||||
|
priority: 'MEDIUM',
|
||||||
|
metadata: {
|
||||||
|
skipReason: skipReason,
|
||||||
|
skippedBy: skippedBy
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Notify next approver
|
// Notify next approver
|
||||||
await notificationService.sendToUsers([(nextLevel as any).approverId], {
|
await notificationService.sendToUsers([(nextLevel as any).approverId], {
|
||||||
title: 'Request Escalated',
|
title: 'Request Escalated',
|
||||||
body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`,
|
body: `Previous approver was skipped. Request ${(workflow as any).requestNumber} is now awaiting your approval.`,
|
||||||
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',
|
||||||
|
priority: 'HIGH',
|
||||||
|
actionRequired: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3235,8 +3253,16 @@ export class WorkflowService {
|
|||||||
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
|
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
|
||||||
return refreshed;
|
return refreshed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update workflow ${requestId}:`, error);
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
throw new Error('Failed to update workflow');
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||||
|
logger.error(`Failed to update workflow ${requestId}:`, {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
requestId,
|
||||||
|
updateData: JSON.stringify(updateData, null, 2),
|
||||||
|
});
|
||||||
|
// Preserve original error message for better debugging
|
||||||
|
throw new Error(`Failed to update workflow: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user