both custom and dealer claim merged
This commit is contained in:
commit
0fd522eaa7
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,16 +279,85 @@ 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', {
|
||||||
|
fileName: truncatedOriginalFileName,
|
||||||
|
filePath: gcsFilePath,
|
||||||
|
storageUrl: finalStorageUrl ? 'present' : 'null (too long)',
|
||||||
|
requestId: workflow.requestId
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const doc = await Document.create({
|
const doc = await Document.create({
|
||||||
requestId: workflow.requestId,
|
requestId: workflow.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,
|
||||||
@ -300,6 +369,24 @@ export class WorkflowController {
|
|||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
} as any);
|
||||||
docs.push(doc);
|
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,11 +686,28 @@ export class WorkflowController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update workflow
|
// Update workflow
|
||||||
const workflow = await workflowService.updateWorkflow(id, updateData);
|
let workflow;
|
||||||
|
try {
|
||||||
|
workflow = await workflowService.updateWorkflow(id, updateData);
|
||||||
if (!workflow) {
|
if (!workflow) {
|
||||||
ResponseHandler.notFound(res, 'Workflow not found');
|
ResponseHandler.notFound(res, 'Workflow not found');
|
||||||
return;
|
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
|
||||||
const files = (req as any).files as Express.Multer.File[] | undefined;
|
const files = (req as any).files as Express.Multer.File[] | undefined;
|
||||||
@ -632,23 +743,85 @@ export class WorkflowController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('[Workflow] Creating document record', {
|
// Truncate file names if they exceed database column limits (255 chars)
|
||||||
fileName: file.originalname,
|
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,
|
filePath: gcsFilePath,
|
||||||
storageUrl: storageUrl,
|
});
|
||||||
|
// 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: actualRequestId
|
requestId: actualRequestId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const doc = await Document.create({
|
const doc = await Document.create({
|
||||||
requestId: actualRequestId,
|
requestId: actualRequestId,
|
||||||
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,
|
||||||
@ -660,12 +833,39 @@ export class WorkflowController {
|
|||||||
downloadCount: 0,
|
downloadCount: 0,
|
||||||
} as any);
|
} as any);
|
||||||
docs.push(doc);
|
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,13 +457,6 @@ 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) {
|
|
||||||
// Normal flow - notify next approver (skip for auto-steps)
|
|
||||||
// Check if it's an auto-step by checking approverEmail or levelName
|
|
||||||
// Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are no longer approval steps
|
|
||||||
const isAutoStep = (nextLevel as any).approverEmail === 'system@royalenfield.com'
|
|
||||||
|| (nextLevel as any).approverName === 'System Auto-Process';
|
|
||||||
|
|
||||||
// Log approval activity
|
// Log approval activity
|
||||||
activityService.log({
|
activityService.log({
|
||||||
requestId: level.requestId,
|
requestId: level.requestId,
|
||||||
@ -477,11 +469,31 @@ export class ApprovalService {
|
|||||||
userAgent: requestMetadata?.userAgent || undefined
|
userAgent: requestMetadata?.userAgent || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log assignment activity for next level (when it becomes active)
|
// Notify initiator about the approval (triggers email for regular workflows)
|
||||||
// IMPORTANT: Skip notifications and assignment logging for system/auto-steps
|
if (wf) {
|
||||||
// System steps are any step with system@royalenfield.com
|
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,
|
||||||
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
type: 'approval',
|
||||||
|
priority: 'MEDIUM'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify next approver
|
||||||
|
if (wf && nextLevel) {
|
||||||
|
// 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) {
|
||||||
|
try {
|
||||||
const previewUrl = nodemailer.getTestMessageUrl(info);
|
const previewUrl = nodemailer.getTestMessageUrl(info);
|
||||||
result.previewUrl = previewUrl || undefined;
|
|
||||||
|
if (previewUrl) {
|
||||||
|
result.previewUrl = previewUrl;
|
||||||
|
|
||||||
// Always log to console for visibility
|
// Always log to console for visibility
|
||||||
console.log('\n' + '='.repeat(80));
|
console.log('\n' + '='.repeat(80));
|
||||||
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
||||||
console.log(`To: ${recipients}`);
|
console.log(`To: ${recipients}`);
|
||||||
console.log(`Preview URL: ${previewUrl}`);
|
console.log(`Preview URL: ${previewUrl}`);
|
||||||
|
console.log(`Message ID: ${info.messageId}`);
|
||||||
console.log('='.repeat(80) + '\n');
|
console.log('='.repeat(80) + '\n');
|
||||||
|
|
||||||
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
||||||
logger.info(`📧 Preview URL: ${previewUrl}`);
|
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';
|
||||||
|
|
||||||
|
// Convert user to plain object if needed
|
||||||
|
const userData = user.toJSON ? user.toJSON() : user;
|
||||||
|
|
||||||
|
// Determine if the recipient is the approver or initiator
|
||||||
|
const isApprover = approverData && userData.userId === approverData.userId;
|
||||||
|
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(
|
await emailNotificationService.sendWorkflowResumed(
|
||||||
requestData,
|
requestData,
|
||||||
currentApprover ? currentApprover.toJSON() : user,
|
userData,
|
||||||
initiatorData,
|
initiatorData,
|
||||||
resumedBy,
|
resumedBy,
|
||||||
pauseDuration
|
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