email template flow added with test account and templates for all cenerios
This commit is contained in:
parent
134b7d547d
commit
9089e8c035
12
monitoring/.env.example
Normal file
12
monitoring/.env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# =============================================================================
|
||||
# MONITORING STACK ENVIRONMENT VARIABLES
|
||||
# =============================================================================
|
||||
# Copy this file to .env and update with your actual values
|
||||
# Command: copy .env.example .env
|
||||
|
||||
# =============================================================================
|
||||
# REDIS CONNECTION (External Redis Server)
|
||||
# =============================================================================
|
||||
REDIS_HOST=160.187.166.17
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=Redis@123
|
||||
12
monitoring/.gitignore
vendored
Normal file
12
monitoring/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
# Data volumes (mounted from Docker containers)
|
||||
prometheus_data/
|
||||
grafana_data/
|
||||
alertmanager_data/
|
||||
loki_data/
|
||||
|
||||
# Environment files with sensitive data
|
||||
.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
167
monitoring/DASHBOARD_METRICS_REFERENCE.md
Normal file
167
monitoring/DASHBOARD_METRICS_REFERENCE.md
Normal file
@ -0,0 +1,167 @@
|
||||
# RE Workflow Dashboard - Metrics Reference
|
||||
|
||||
## 📊 Complete KPI List with Data Sources
|
||||
|
||||
### **Section 1: API Overview**
|
||||
|
||||
| Panel Name | Metric Query | Data Source | What It Measures |
|
||||
|------------|--------------|-------------|------------------|
|
||||
| **Request Rate** | `sum(rate(http_requests_total{job="re-workflow-backend"}[5m]))` | Backend metrics | HTTP requests per second (all endpoints) |
|
||||
| **Error Rate** | `sum(rate(http_request_errors_total{job="re-workflow-backend"}[5m])) / sum(rate(http_requests_total{job="re-workflow-backend"}[5m]))` | Backend metrics | Percentage of failed HTTP requests |
|
||||
| **P95 Latency** | `histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job="re-workflow-backend"}[5m])) by (le))` | Backend metrics | 95th percentile response time (seconds) |
|
||||
| **API Status** | `up{job="re-workflow-backend"}` | Prometheus | Backend service up/down status (1=up, 0=down) |
|
||||
| **Request Rate by Method** | `sum(rate(http_requests_total{job="re-workflow-backend"}[5m])) by (method)` | Backend metrics | Requests per method (GET, POST, etc.) |
|
||||
| **Response Time Percentiles** | `histogram_quantile(0.50/0.95/0.99, ...)` | Backend metrics | Response time distribution (P50, P95, P99) |
|
||||
|
||||
---
|
||||
|
||||
### **Section 2: Logs**
|
||||
|
||||
| Panel Name | Metric Query | Data Source | What It Measures |
|
||||
|------------|--------------|-------------|------------------|
|
||||
| **Errors (Time Range)** | `count_over_time({job="re-workflow-backend", level="error"}[...])` | Loki logs | Total error log entries in selected time range |
|
||||
| **Warnings (Time Range)** | `count_over_time({job="re-workflow-backend", level="warn"}[...])` | Loki logs | Total warning log entries in selected time range |
|
||||
| **TAT Breaches (Time Range)** | Log filter for TAT breaches | Loki logs | TAT breach events logged |
|
||||
| **Auth Failures (Time Range)** | Log filter for auth failures | Loki logs | Authentication failure events |
|
||||
| **Recent Errors & Warnings** | `{job="re-workflow-backend"} \|= "error" or "warn"` | Loki logs | Live log stream of errors and warnings |
|
||||
|
||||
---
|
||||
|
||||
### **Section 3: Node.js Runtime** (Process-Level Metrics)
|
||||
|
||||
| Panel Name | Metric Query | Data Source | What It Measures |
|
||||
|------------|--------------|-------------|------------------|
|
||||
| **Node.js Process Memory (Heap)** | `process_resident_memory_bytes{job="re-workflow-backend"}` <br> `nodejs_heap_size_used_bytes{job="re-workflow-backend"}` <br> `nodejs_heap_size_total_bytes{job="re-workflow-backend"}` | Node.js metrics (prom-client) | Node.js process memory usage: <br>- RSS (Resident Set Size) <br>- Heap Used <br>- Heap Total |
|
||||
| **Node.js Event Loop Lag** | `nodejs_eventloop_lag_seconds{job="re-workflow-backend"}` | Node.js metrics | Event loop lag in seconds (high = performance issue) |
|
||||
| **Node.js Active Handles & Requests** | `nodejs_active_handles_total{job="re-workflow-backend"}` <br> `nodejs_active_requests_total{job="re-workflow-backend"}` | Node.js metrics | Active file handles and pending async requests |
|
||||
| **Node.js Process CPU Usage** | `rate(process_cpu_seconds_total{job="re-workflow-backend"}[5m])` | Node.js metrics | CPU usage by Node.js process only (0-1 = 0-100%) |
|
||||
|
||||
**Key Point**: These metrics track the **Node.js application process** specifically, not the entire host system.
|
||||
|
||||
---
|
||||
|
||||
### **Section 4: Redis & Queue Status**
|
||||
|
||||
| Panel Name | Metric Query | Data Source | What It Measures |
|
||||
|------------|--------------|-------------|------------------|
|
||||
| **Redis Status** | `redis_up` | Redis Exporter | Redis server status (1=up, 0=down) |
|
||||
| **Redis Connections** | `redis_connected_clients` | Redis Exporter | Number of active client connections to Redis |
|
||||
| **Redis Memory** | `redis_memory_used_bytes` | Redis Exporter | Memory used by Redis (bytes) |
|
||||
| **TAT Queue Waiting** | `queue_jobs_waiting{queue_name="tatQueue"}` | Backend queue metrics | Jobs waiting in TAT notification queue |
|
||||
| **Pause/Resume Queue Waiting** | `queue_jobs_waiting{queue_name="pauseResumeQueue"}` | Backend queue metrics | Jobs waiting in pause/resume queue |
|
||||
| **TAT Queue Failed** | `queue_jobs_failed{queue_name="tatQueue"}` | Backend queue metrics | Failed TAT notification jobs (should be 0) |
|
||||
| **Pause/Resume Queue Failed** | `queue_jobs_failed{queue_name="pauseResumeQueue"}` | Backend queue metrics | Failed pause/resume jobs (should be 0) |
|
||||
| **All Queues - Job Status** | `queue_jobs_waiting` <br> `queue_jobs_active` <br> `queue_jobs_delayed` | Backend queue metrics | Timeline of job status across all queues (stacked) |
|
||||
| **Redis Commands Rate** | `rate(redis_commands_processed_total[1m])` | Redis Exporter | Redis commands executed per second |
|
||||
|
||||
**Key Point**: Queue metrics are collected by the backend every 15 seconds via BullMQ queue API.
|
||||
|
||||
---
|
||||
|
||||
### **Section 5: System Resources (Host)** (Host-Level Metrics)
|
||||
|
||||
| Panel Name | Metric Query | Data Source | What It Measures |
|
||||
|------------|--------------|-------------|------------------|
|
||||
| **Host CPU Usage (All Cores)** | `100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)` | Node Exporter | Total CPU usage across all cores on host machine (%) |
|
||||
| **Host Memory Usage (RAM)** | `(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100` | Node Exporter | RAM usage on host machine (%) |
|
||||
| **Host Disk Usage (/root)** | `100 - ((node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100)` | Node Exporter | Disk usage of root filesystem (%) |
|
||||
| **Disk Space Left** | `node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"}` | Node Exporter | Available disk space in gigabytes |
|
||||
|
||||
**Key Point**: These metrics track the **entire host system**, not just the Node.js process.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Data Source Summary
|
||||
|
||||
| Exporter/Service | Port | Metrics Provided | Collection Interval |
|
||||
|------------------|------|------------------|---------------------|
|
||||
| **RE Workflow Backend** | 5000 | HTTP metrics, custom business metrics, Node.js runtime | 10s (Prometheus scrape) |
|
||||
| **Node Exporter** | 9100 | Host system metrics (CPU, memory, disk, network) | 15s (Prometheus scrape) |
|
||||
| **Redis Exporter** | 9121 | Redis server metrics (connections, memory, commands) | 15s (Prometheus scrape) |
|
||||
| **Queue Metrics** | 5000 | BullMQ queue job counts (via backend) | 15s (internal collection) |
|
||||
| **Loki** | 3100 | Application logs | Real-time streaming |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Renamed Panels for Clarity
|
||||
|
||||
### Before → After
|
||||
|
||||
**Node.js Runtime Section:**
|
||||
- ❌ "Memory Usage" → ✅ "Node.js Process Memory (Heap)"
|
||||
- ❌ "CPU Usage" → ✅ "Node.js Process CPU Usage"
|
||||
- ❌ "Event Loop Lag" → ✅ "Node.js Event Loop Lag"
|
||||
- ❌ "Active Handles & Requests" → ✅ "Node.js Active Handles & Requests"
|
||||
|
||||
**System Resources Section:**
|
||||
- ❌ "System CPU Usage" → ✅ "Host CPU Usage (All Cores)"
|
||||
- ❌ "System Memory Usage" → ✅ "Host Memory Usage (RAM)"
|
||||
- ❌ "System Disk Usage" → ✅ "Host Disk Usage (/root)"
|
||||
|
||||
---
|
||||
|
||||
## 📈 Understanding the Difference
|
||||
|
||||
### **Process vs Host Metrics**
|
||||
|
||||
| Aspect | Node.js Process Metrics | Host System Metrics |
|
||||
|--------|------------------------|---------------------|
|
||||
| **Scope** | Single Node.js application | Entire server/container |
|
||||
| **CPU** | CPU used by Node.js only | CPU used by all processes |
|
||||
| **Memory** | Node.js heap memory | Total RAM on machine |
|
||||
| **Purpose** | Application performance | Infrastructure health |
|
||||
| **Example Use** | Detect memory leaks in app | Ensure server has capacity |
|
||||
|
||||
**Example Scenario:**
|
||||
- **Node.js Process CPU**: 15% → Your app is using 15% of one CPU core
|
||||
- **Host CPU Usage**: 75% → The entire server is at 75% CPU (all processes combined)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Alert Thresholds (Recommended)
|
||||
|
||||
| Metric | Warning | Critical | Action |
|
||||
|--------|---------|----------|--------|
|
||||
| **Node.js Process Memory** | 80% of heap | 90% of heap | Investigate memory leaks |
|
||||
| **Host Memory Usage** | 70% | 85% | Scale up or optimize |
|
||||
| **Host CPU Usage** | 60% | 80% | Scale horizontally |
|
||||
| **Redis Memory** | 500MB | 1GB | Review Redis usage |
|
||||
| **Queue Jobs Waiting** | >10 | >50 | Check worker health |
|
||||
| **Queue Jobs Failed** | >0 | >5 | Immediate investigation |
|
||||
| **Event Loop Lag** | >100ms | >500ms | Performance optimization needed |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### No Data Showing?
|
||||
|
||||
1. **Check Prometheus Targets**: http://localhost:9090/targets
|
||||
- All targets should show "UP" status
|
||||
|
||||
2. **Test Metric Availability**:
|
||||
```promql
|
||||
up{job="re-workflow-backend"}
|
||||
```
|
||||
Should return `1`
|
||||
|
||||
3. **Check Time Range**: Set to "Last 15 minutes" in Grafana
|
||||
|
||||
4. **Verify Backend**: http://localhost:5000/metrics should show all metrics
|
||||
|
||||
### Metrics Not Updating?
|
||||
|
||||
1. **Backend**: Ensure backend is running with metrics collection enabled
|
||||
2. **Prometheus**: Check scrape interval in prometheus.yml
|
||||
3. **Queue Metrics**: Verify queue metrics collection started (check backend logs for "Queue Metrics ✅")
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- **Prometheus Query Language**: https://prometheus.io/docs/prometheus/latest/querying/basics/
|
||||
- **Grafana Dashboard Guide**: https://grafana.com/docs/grafana/latest/dashboards/
|
||||
- **Node Exporter Metrics**: https://github.com/prometheus/node_exporter
|
||||
- **Redis Exporter Metrics**: https://github.com/oliver006/redis_exporter
|
||||
- **BullMQ Monitoring**: https://docs.bullmq.io/guide/metrics
|
||||
|
||||
@ -34,6 +34,18 @@ Complete monitoring solution with **Grafana**, **Prometheus**, **Loki**, and **P
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📦 What's Included
|
||||
|
||||
The monitoring stack includes:
|
||||
- **Redis** - In-memory data store for BullMQ job queues
|
||||
- **Prometheus** - Metrics collection and storage
|
||||
- **Grafana** - Visualization and dashboards
|
||||
- **Loki** - Log aggregation
|
||||
- **Promtail** - Log shipping agent
|
||||
- **Node Exporter** - Host system metrics
|
||||
- **Redis Exporter** - Redis server metrics
|
||||
- **Alertmanager** - Alert routing and notifications
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@ -79,6 +91,37 @@ LOKI_HOST=http://localhost:3100
|
||||
|
||||
## 📊 Available Dashboards
|
||||
|
||||
### **RE Workflow Overview** (Enhanced!)
|
||||
**URL**: http://localhost:3001/d/re-workflow-overview
|
||||
|
||||
**Sections:**
|
||||
|
||||
1. **📊 API Overview**
|
||||
- Request rate, error rate, response times
|
||||
- HTTP status codes distribution
|
||||
|
||||
2. **🔴 Redis & Queue Status** (NEW!)
|
||||
- Redis connection status (Up/Down)
|
||||
- Redis active connections
|
||||
- Redis memory usage
|
||||
- TAT Queue waiting/failed jobs
|
||||
- Pause/Resume Queue waiting/failed jobs
|
||||
- All queues job status timeline
|
||||
- Redis commands rate
|
||||
|
||||
3. **💻 System Resources** (NEW!)
|
||||
- System CPU Usage (gauge)
|
||||
- System Memory Usage (gauge)
|
||||
- System Disk Usage (gauge)
|
||||
- Disk Space Left (GB available)
|
||||
|
||||
4. **🔄 Business Metrics**
|
||||
- Workflow operations
|
||||
- TAT breaches
|
||||
- Node.js process metrics
|
||||
|
||||
**Refresh Rate**: Auto-refresh every 30 seconds
|
||||
|
||||
### 1. RE Workflow Overview
|
||||
Pre-configured dashboard with:
|
||||
- **API Metrics**: Request rate, error rate, latency percentiles
|
||||
|
||||
176
monitoring/REDIS_MIGRATION.md
Normal file
176
monitoring/REDIS_MIGRATION.md
Normal file
@ -0,0 +1,176 @@
|
||||
# 🔄 Redis Migration Guide
|
||||
|
||||
## Overview
|
||||
Redis is now part of the monitoring stack and running locally in Docker.
|
||||
|
||||
---
|
||||
|
||||
## ✅ What Was Done
|
||||
|
||||
1. **Added Redis to monitoring stack**
|
||||
- Image: `redis:7-alpine`
|
||||
- Container name: `re_redis`
|
||||
- Port: `6379` (mapped to host)
|
||||
- Password: Uses `REDIS_PASSWORD` from environment (default: `Redis@123`)
|
||||
- Data persistence: Volume `re_redis_data`
|
||||
|
||||
2. **Updated Redis Exporter**
|
||||
- Now connects to local Redis container
|
||||
- Automatically starts after Redis is healthy
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Update Backend Configuration
|
||||
|
||||
### Step 1: Update `.env` file
|
||||
|
||||
Open `Re_Backend/.env` and change:
|
||||
|
||||
```bash
|
||||
# OLD (External Redis)
|
||||
REDIS_HOST=160.187.166.17
|
||||
|
||||
# NEW (Local Docker Redis)
|
||||
REDIS_HOST=localhost
|
||||
```
|
||||
|
||||
Or if you want to use Docker network (recommended for production):
|
||||
```bash
|
||||
REDIS_HOST=re_redis # Use container name if backend is also in Docker
|
||||
```
|
||||
|
||||
### Step 2: Restart Backend
|
||||
|
||||
```powershell
|
||||
# Stop backend (Ctrl+C in terminal)
|
||||
# Then restart
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Verify Everything Works
|
||||
|
||||
### 1. Check Redis is Running
|
||||
```powershell
|
||||
docker ps --filter "name=redis"
|
||||
```
|
||||
|
||||
Should show:
|
||||
```
|
||||
re_redis Up (healthy)
|
||||
re_redis_exporter Up
|
||||
```
|
||||
|
||||
### 2. Test Redis Connection
|
||||
```powershell
|
||||
# Test from host
|
||||
redis-cli -h localhost -p 6379 -a Redis@123 ping
|
||||
# Should return: PONG
|
||||
```
|
||||
|
||||
### 3. Check Backend Logs
|
||||
```
|
||||
[info]: [Redis] ✅ Connected successfully
|
||||
[info]: [TAT Queue] ✅ Queue initialized
|
||||
[info]: [Pause Resume Queue] ✅ Queue initialized
|
||||
```
|
||||
|
||||
### 4. Refresh Grafana Dashboard
|
||||
- Go to: http://localhost:3001/d/re-workflow-overview
|
||||
- **Redis Status** should show "Up" (green)
|
||||
- **Redis Connections** should show a number
|
||||
- **Redis Memory** should show bytes used
|
||||
- **Queue metrics** should work as before
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Benefits of Local Redis
|
||||
|
||||
✅ **Simpler Setup** - Everything in one place
|
||||
✅ **Faster Performance** - Local network, no external latency
|
||||
✅ **Data Persistence** - Redis data saved in Docker volume
|
||||
✅ **Easy Monitoring** - Redis Exporter automatically connected
|
||||
✅ **Environment Isolation** - No conflicts with external Redis
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Docker Commands
|
||||
|
||||
### Start all monitoring services (including Redis)
|
||||
```powershell
|
||||
cd Re_Backend/monitoring
|
||||
docker-compose -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
|
||||
### Stop all services
|
||||
```powershell
|
||||
docker-compose -f docker-compose.monitoring.yml down
|
||||
```
|
||||
|
||||
### View Redis logs
|
||||
```powershell
|
||||
docker logs re_redis
|
||||
```
|
||||
|
||||
### Redis CLI access
|
||||
```powershell
|
||||
docker exec -it re_redis redis-cli -a Redis@123
|
||||
```
|
||||
|
||||
### Check Redis data
|
||||
```powershell
|
||||
# Inside redis-cli
|
||||
INFO
|
||||
DBSIZE
|
||||
KEYS *
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Data Persistence
|
||||
|
||||
Redis data is persisted in Docker volume:
|
||||
```
|
||||
Volume: re_redis_data
|
||||
Location: Docker managed volume
|
||||
```
|
||||
|
||||
To backup Redis data:
|
||||
```powershell
|
||||
docker exec re_redis redis-cli -a Redis@123 SAVE
|
||||
docker cp re_redis:/data/dump.rdb ./redis-backup.rdb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ If You Want to Keep External Redis
|
||||
|
||||
If you prefer to keep using the external Redis server, simply:
|
||||
|
||||
1. Update `docker-compose.monitoring.yml`:
|
||||
```yaml
|
||||
redis-exporter:
|
||||
environment:
|
||||
- REDIS_ADDR=redis://160.187.166.17:6379
|
||||
command:
|
||||
- '--redis.addr=160.187.166.17:6379'
|
||||
- '--redis.password=Redis@123'
|
||||
```
|
||||
|
||||
2. Don't change `.env` in backend
|
||||
|
||||
3. Remove the `redis` service from docker-compose if you don't need it locally
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Summary
|
||||
|
||||
Your setup now includes:
|
||||
- ✅ Redis running locally in Docker
|
||||
- ✅ Redis Exporter connected and working
|
||||
- ✅ Backend ready to connect (just update `REDIS_HOST=localhost` in `.env`)
|
||||
- ✅ All monitoring metrics available in Grafana
|
||||
|
||||
**Next step**: Update `Re_Backend/.env` with `REDIS_HOST=localhost` and restart your backend!
|
||||
|
||||
1
monitoring/delete
Normal file
1
monitoring/delete
Normal file
@ -0,0 +1 @@
|
||||
Redis
|
||||
@ -10,6 +10,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===========================================================================
|
||||
# REDIS - In-Memory Data Store (for BullMQ queues)
|
||||
# ===========================================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: re_redis
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-Redis@123}
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- monitoring_network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# ===========================================================================
|
||||
# PROMETHEUS - Metrics Collection
|
||||
# ===========================================================================
|
||||
@ -127,6 +147,27 @@ services:
|
||||
- monitoring_network
|
||||
restart: unless-stopped
|
||||
|
||||
# ===========================================================================
|
||||
# REDIS EXPORTER - Redis Metrics
|
||||
# ===========================================================================
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:v1.55.0
|
||||
container_name: re_redis_exporter
|
||||
ports:
|
||||
- "9121:9121"
|
||||
environment:
|
||||
- REDIS_ADDR=redis://redis:6379
|
||||
- REDIS_PASSWORD=Redis@123
|
||||
command:
|
||||
- '--redis.addr=redis:6379'
|
||||
- '--redis.password=Redis@123'
|
||||
networks:
|
||||
- monitoring_network
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# ===========================================================================
|
||||
# ALERTMANAGER - Alert Notifications (Optional)
|
||||
# ===========================================================================
|
||||
@ -157,6 +198,8 @@ networks:
|
||||
# VOLUMES
|
||||
# ===========================================================================
|
||||
volumes:
|
||||
redis_data:
|
||||
name: re_redis_data
|
||||
prometheus_data:
|
||||
name: re_prometheus_data
|
||||
loki_data:
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(http_requests_total{job=\"re-workflow-backend\"}[5m]))",
|
||||
"expr": "sum(rate(http_requests_total{job=\"re-workflow-backend\"}[5m])) or vector(0)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@ -83,7 +83,7 @@
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(http_request_errors_total{job=\"re-workflow-backend\"}[5m])) / sum(rate(http_requests_total{job=\"re-workflow-backend\"}[5m]))",
|
||||
"expr": "(sum(rate(http_request_errors_total{job=\"re-workflow-backend\"}[5m])) / sum(rate(http_requests_total{job=\"re-workflow-backend\"}[5m]))) or vector(0)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@ -119,7 +119,7 @@
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"re-workflow-backend\"}[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"re-workflow-backend\"}[5m])) by (le)) or vector(0)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@ -476,23 +476,28 @@
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "process_resident_memory_bytes{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "RSS Memory",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "nodejs_heap_size_used_bytes{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "Heap Used",
|
||||
"refId": "B"
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "nodejs_heap_size_total_bytes{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "Heap Total",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "nodejs_external_memory_bytes{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "External Memory",
|
||||
"refId": "C"
|
||||
},
|
||||
{
|
||||
"expr": "process_resident_memory_bytes{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "RSS Memory",
|
||||
"refId": "D"
|
||||
}
|
||||
],
|
||||
"title": "Memory Usage",
|
||||
"title": "Node.js Process Memory (Heap)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
@ -535,9 +540,19 @@
|
||||
"expr": "nodejs_eventloop_lag_seconds{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "Event Loop Lag",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "nodejs_eventloop_lag_mean_seconds{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "Mean Lag",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "nodejs_eventloop_lag_p99_seconds{job=\"re-workflow-backend\"}",
|
||||
"legendFormat": "P99 Lag",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Event Loop Lag",
|
||||
"title": "Node.js Event Loop Lag",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
@ -587,7 +602,7 @@
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Active Handles & Requests",
|
||||
"title": "Node.js Active Handles & Requests",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
@ -628,12 +643,436 @@
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(process_cpu_seconds_total{job=\"re-workflow-backend\"}[5m])",
|
||||
"legendFormat": "CPU Usage",
|
||||
"legendFormat": "Process CPU Usage",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CPU Usage",
|
||||
"title": "Node.js Process CPU Usage",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 },
|
||||
"id": 400,
|
||||
"panels": [],
|
||||
"title": "🔴 Redis & Queue Status",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "Down" }, "1": { "color": "green", "index": 0, "text": "Up" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 39 },
|
||||
"id": 401,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "value"
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "redis_up",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "Redis Status",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 3, "y": 39 },
|
||||
"id": 402,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "value"
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "redis_connected_clients",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "Redis Connections",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 500000000 }, { "color": "red", "value": 1000000000 }] },
|
||||
"unit": "decbytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 6, "y": 39 },
|
||||
"id": 403,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "value"
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "redis_memory_used_bytes",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "Redis Memory",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 10 }, { "color": "red", "value": 50 }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 9, "y": 39 },
|
||||
"id": 404,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto",
|
||||
"showPercentChange": false
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "queue_jobs_delayed{queue_name=\"tatQueue\"}",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "TAT Alerts - Scheduled",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "blue", "value": 1 }, { "color": "yellow", "value": 5 }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 12, "y": 39 },
|
||||
"id": 405,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto",
|
||||
"showPercentChange": false
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "queue_jobs_delayed{queue_name=\"pauseResumeQueue\"}",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "Resume Jobs - Scheduled",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "match": "null", "result": { "text": "0" } }, "type": "special" }
|
||||
],
|
||||
"noValue": "0",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 15, "y": 39 },
|
||||
"id": 406,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto",
|
||||
"showPercentChange": false
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "queue_jobs_failed{queue_name=\"tatQueue\"}",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "TAT Alerts - Failed",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "match": "null", "result": { "text": "0" } }, "type": "special" }
|
||||
],
|
||||
"noValue": "0",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 18, "y": 39 },
|
||||
"id": 407,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto",
|
||||
"showPercentChange": false
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "queue_jobs_failed{queue_name=\"pauseResumeQueue\"}",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "Resume Jobs - Failed",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "normal" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 43 },
|
||||
"id": 408,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{ "expr": "queue_jobs_waiting", "legendFormat": "{{queue_name}} - Waiting", "refId": "A" },
|
||||
{ "expr": "queue_jobs_active", "legendFormat": "{{queue_name}} - Active", "refId": "B" },
|
||||
{ "expr": "queue_jobs_delayed", "legendFormat": "{{queue_name}} - Delayed", "refId": "C" }
|
||||
],
|
||||
"title": "All Queues - Job Status",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "ops"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 43 },
|
||||
"id": 409,
|
||||
"options": {
|
||||
"legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [{ "expr": "rate(redis_commands_processed_total[1m])", "legendFormat": "Commands/sec", "refId": "A" }],
|
||||
"title": "Redis Commands Rate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 51 },
|
||||
"id": 500,
|
||||
"panels": [],
|
||||
"title": "💻 System Resources (Host)",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 60 }, { "color": "red", "value": 80 }] },
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 52 },
|
||||
"id": 501,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"targets": [{ "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "refId": "A" }],
|
||||
"title": "Host CPU Usage (All Cores)",
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] },
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 52 },
|
||||
"id": 502,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"targets": [{ "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", "refId": "A" }],
|
||||
"title": "Host Memory Usage (RAM)",
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 75 }, { "color": "red", "value": 90 }] },
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 52 },
|
||||
"id": 503,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"targets": [{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "100 - ((avg(node_filesystem_avail_bytes{fstype=~\"ext4|xfs|overlay2\"}) / avg(node_filesystem_size_bytes{fstype=~\"ext4|xfs|overlay2\"})) * 100)",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}],
|
||||
"title": "Container Storage Usage",
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"decimals": 2,
|
||||
"mappings": [],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "decgbytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 52 },
|
||||
"id": 504,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "value"
|
||||
},
|
||||
"pluginVersion": "10.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "avg(node_filesystem_avail_bytes{fstype=~\"ext4|xfs|overlay2\"})",
|
||||
"refId": "A",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"title": "Container Storage Available",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
|
||||
@ -72,13 +72,14 @@ scrape_configs:
|
||||
# service: 'postgresql'
|
||||
|
||||
# ============================================
|
||||
# Redis Metrics (if using redis_exporter)
|
||||
# Redis Metrics
|
||||
# ============================================
|
||||
# - job_name: 'redis'
|
||||
# static_configs:
|
||||
# - targets: ['redis-exporter:9121']
|
||||
# labels:
|
||||
# service: 'redis'
|
||||
- job_name: 'redis'
|
||||
static_configs:
|
||||
- targets: ['redis-exporter:9121']
|
||||
labels:
|
||||
service: 'redis'
|
||||
environment: 'development'
|
||||
|
||||
# ============================================
|
||||
# Loki Metrics
|
||||
|
||||
1352
package-lock.json
generated
1352
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,6 +23,7 @@
|
||||
"@anthropic-ai/sdk": "^0.68.0",
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@ -39,6 +40,7 @@
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^7.0.11",
|
||||
"openai": "^6.8.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@ -62,7 +64,7 @@
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pg": "^8.15.6",
|
||||
|
||||
@ -107,7 +107,7 @@ export class AuthController {
|
||||
async refreshToken(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Try to get refresh token from request body first, then from cookies
|
||||
let refreshToken: string;
|
||||
let refreshToken: string | undefined;
|
||||
|
||||
if (req.body?.refreshToken) {
|
||||
const validated = validateRefreshToken(req.body);
|
||||
@ -115,8 +115,16 @@ export class AuthController {
|
||||
} else if ((req as any).cookies?.refreshToken) {
|
||||
// Fallback to cookie if available (requires cookie-parser middleware)
|
||||
refreshToken = (req as any).cookies.refreshToken;
|
||||
} else {
|
||||
throw new Error('Refresh token is required');
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Refresh token is required in request body or cookies',
|
||||
message: 'Request body validation failed',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newAccessToken = await this.authService.refreshAccessToken(refreshToken);
|
||||
@ -126,7 +134,7 @@ export class AuthController {
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax' as const,
|
||||
sameSite: isProduction ? 'none' as const : 'lax' as const, // 'none' for cross-domain in production
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
|
||||
@ -340,7 +348,7 @@ export class AuthController {
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax' as const,
|
||||
sameSite: isProduction ? 'none' as const : 'lax' as const, // 'none' for cross-domain in production
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
|
||||
};
|
||||
|
||||
|
||||
285
src/emailtemplates/EMAIL_STRATEGY.md
Normal file
285
src/emailtemplates/EMAIL_STRATEGY.md
Normal file
@ -0,0 +1,285 @@
|
||||
# Email Notification Strategy - Quick Reference
|
||||
|
||||
## 🎯 **9 Email Scenarios (Priority Order)**
|
||||
|
||||
### **✅ CRITICAL (Always Send if Admin Enabled)**
|
||||
1. **TAT Breached (100%)** → Approver + Management
|
||||
2. **Request Rejected** → Initiator
|
||||
|
||||
### **✅ HIGH PRIORITY (Check Preferences)**
|
||||
3. **Request Created** → Initiator
|
||||
4. **Approval Request** → Approver (action required)
|
||||
5. **Request Approved** → Initiator
|
||||
6. **TAT Reminder (75%)** → Approver
|
||||
7. **Workflow Resumed** → Approver + Initiator
|
||||
|
||||
### **✅ MEDIUM PRIORITY (Check Preferences)**
|
||||
8. **TAT Reminder (50%)** → Approver
|
||||
|
||||
### **✅ LOW PRIORITY (Check Preferences)**
|
||||
9. **Request Closed** → All Participants
|
||||
|
||||
---
|
||||
|
||||
## 🔐 **Preference Control Flow**
|
||||
|
||||
### **Before Sending ANY Email:**
|
||||
|
||||
```typescript
|
||||
import { shouldSendEmail, EmailNotificationType } from '@/emailtemplates/emailPreferences.helper';
|
||||
|
||||
// Check preferences
|
||||
const canSendEmail = await shouldSendEmail(
|
||||
userId,
|
||||
EmailNotificationType.APPROVAL_REQUEST
|
||||
);
|
||||
|
||||
if (!canSendEmail) {
|
||||
logger.info(`Email skipped for user ${userId} due to preferences`);
|
||||
return; // Don't send email
|
||||
}
|
||||
|
||||
// Proceed with email
|
||||
await emailService.sendEmail(...);
|
||||
```
|
||||
|
||||
### **Preference Logic:**
|
||||
|
||||
```
|
||||
Admin Enabled? ──NO──> ❌ Stop (Don't send)
|
||||
│
|
||||
YES
|
||||
│
|
||||
▼
|
||||
User Enabled? ──NO──> ❌ Stop (Don't send)
|
||||
│
|
||||
YES
|
||||
│
|
||||
▼
|
||||
✅ Send Email
|
||||
```
|
||||
|
||||
### **Critical Email Override:**
|
||||
|
||||
```typescript
|
||||
// For CRITICAL emails (Rejection, TAT Breach)
|
||||
const canSend = await shouldSendEmailWithOverride(
|
||||
userId,
|
||||
EmailNotificationType.REQUEST_REJECTED
|
||||
);
|
||||
// This checks admin only, ignores user preference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **TAT Email Schedule (3 Reminders)**
|
||||
|
||||
| TAT Progress | Template | Priority | Subject | When |
|
||||
|--------------|----------|----------|---------|------|
|
||||
| **50%** | TATReminder | Medium | TAT Reminder - Early Warning | Half TAT elapsed |
|
||||
| **75%** | TATReminder | High | TAT Reminder - Action Needed | 3/4 TAT elapsed |
|
||||
| **100%** | TATBreached | Critical | TAT BREACHED - Urgent | Deadline passed |
|
||||
|
||||
### **TAT Reminder Template Customization:**
|
||||
|
||||
```typescript
|
||||
// 50% Reminder
|
||||
await emailNotificationService.sendTATReminder(
|
||||
workflow,
|
||||
approver,
|
||||
{
|
||||
threshold: 50,
|
||||
timeRemaining: '12 hours',
|
||||
urgency: 'early-warning'
|
||||
}
|
||||
);
|
||||
|
||||
// 75% Reminder
|
||||
await emailNotificationService.sendTATReminder(
|
||||
workflow,
|
||||
approver,
|
||||
{
|
||||
threshold: 75,
|
||||
timeRemaining: '6 hours',
|
||||
urgency: 'urgent'
|
||||
}
|
||||
);
|
||||
|
||||
// 100% Breach
|
||||
await emailNotificationService.sendTATBreached(
|
||||
workflow,
|
||||
approver,
|
||||
{
|
||||
timeOverdue: '2 hours overdue',
|
||||
notifyManagement: true
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚫 **Never Send Email For:**
|
||||
|
||||
### **In-App Only (Real-Time Collaboration):**
|
||||
|
||||
| Notification Type | Why In-App Only | Frequency |
|
||||
|-------------------|-----------------|-----------|
|
||||
| @Mentions | Real-time chat | High (50-200/day) |
|
||||
| Comments | Active discussion | High (100-300/day) |
|
||||
| Documents Added | Minor update | Medium (20-50/day) |
|
||||
| Status Changes | Internal updates | Medium (30-80/day) |
|
||||
| AI Generated | Background process | Low (10-20/day) |
|
||||
| Summary Generated | Automatic | Low (10-20/day) |
|
||||
|
||||
**Reason:** These would flood inboxes and cause email fatigue!
|
||||
|
||||
---
|
||||
|
||||
## 💾 **Important: Activity Logs**
|
||||
|
||||
### **ALWAYS Capture Activities:**
|
||||
|
||||
```typescript
|
||||
// Even if email/notification disabled, ALWAYS log activity
|
||||
await activityService.log({
|
||||
requestId,
|
||||
type: 'approval',
|
||||
user: { userId, name },
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Request approved',
|
||||
details: 'Approved by John Doe'
|
||||
});
|
||||
|
||||
// This is independent of email/notification preferences
|
||||
// Activity logs are for audit trail and compliance
|
||||
```
|
||||
|
||||
**Activities Logged Regardless of Preferences:**
|
||||
- ✅ Request created
|
||||
- ✅ Assignments
|
||||
- ✅ Approvals
|
||||
- ✅ Rejections
|
||||
- ✅ TAT events (50%, 75%, 100%)
|
||||
- ✅ Pauses/Resumes
|
||||
- ✅ Comments
|
||||
- ✅ Documents
|
||||
- ✅ Status changes
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Implementation Checklist**
|
||||
|
||||
### **Before Starting:**
|
||||
- [ ] Create `emailPreferences.helper.ts` ✅ Done
|
||||
- [ ] Add system config entries for email controls
|
||||
- [ ] Update user preferences schema
|
||||
- [ ] Test preference checking logic
|
||||
|
||||
### **For Each Email Type:**
|
||||
1. [ ] Check admin preference
|
||||
2. [ ] Check user preference
|
||||
3. [ ] Send email only if both enabled
|
||||
4. [ ] Log activity regardless
|
||||
5. [ ] Handle errors gracefully
|
||||
6. [ ] Track metrics (sent/failed/skipped)
|
||||
|
||||
### **Critical Emails (Special Handling):**
|
||||
- [ ] Request Rejected - Override user preference
|
||||
- [ ] TAT Breached - Override user preference
|
||||
- [ ] Still respect admin disable
|
||||
|
||||
---
|
||||
|
||||
## 📋 **User Preference UI (Frontend)**
|
||||
|
||||
### **Admin Settings Page:**
|
||||
|
||||
```
|
||||
Email Notifications (Global Control)
|
||||
├── [✓] Enable Email Notifications
|
||||
│
|
||||
├── Request Lifecycle
|
||||
│ ├── [✓] Request Created
|
||||
│ ├── [✓] Approval Requests
|
||||
│ ├── [✓] Request Approved
|
||||
│ └── [✓] Request Rejected (Critical - Recommended)
|
||||
│
|
||||
├── TAT Alerts
|
||||
│ ├── [✓] TAT 50% Reminder
|
||||
│ ├── [✓] TAT 75% Reminder
|
||||
│ └── [✓] TAT Breached (Critical - Recommended)
|
||||
│
|
||||
└── Workflow Control
|
||||
├── [✓] Workflow Resumed
|
||||
└── [✓] Request Closed
|
||||
```
|
||||
|
||||
### **User Preferences Page:**
|
||||
|
||||
```
|
||||
My Email Preferences
|
||||
├── [✓] Receive Email Notifications
|
||||
│
|
||||
├── Request Updates
|
||||
│ ├── [✓] When my request is created
|
||||
│ ├── [✓] When assigned to approve
|
||||
│ ├── [✓] When my request is approved
|
||||
│ ├── [⚠] When my request is rejected (Cannot disable - Critical)
|
||||
│
|
||||
├── TAT Alerts
|
||||
│ ├── [✓] TAT 50% reminder
|
||||
│ ├── [✓] TAT 75% reminder
|
||||
│ └── [⚠] TAT breached (Cannot disable - Critical)
|
||||
│
|
||||
└── Other
|
||||
├── [✓] Workflow resumed
|
||||
└── [ ] Request closed (Optional)
|
||||
```
|
||||
|
||||
**Note:** Critical emails cannot be disabled by users!
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Email Volume with Preferences**
|
||||
|
||||
### **Without Preferences (All Emails):**
|
||||
- ~75-205 emails/day
|
||||
|
||||
### **With Smart Defaults:**
|
||||
- Users who want all: ~75-205 emails/day
|
||||
- Users who opt for critical only: ~10-20 emails/day
|
||||
- Average user: ~30-50 emails/day
|
||||
|
||||
### **Email Savings:**
|
||||
- @Mentions: 50-200/day saved ✅
|
||||
- Comments: 100-300/day saved ✅
|
||||
- Documents: 20-50/day saved ✅
|
||||
- Status changes: 30-80/day saved ✅
|
||||
|
||||
**Total saved: 200-630 potential emails/day!**
|
||||
|
||||
---
|
||||
|
||||
## ✅ **Summary**
|
||||
|
||||
### **Email Strategy:**
|
||||
1. ✅ **9 email scenarios** (high-impact only)
|
||||
2. ✅ **TAT reminders at 50%, 75%, 100%**
|
||||
3. ✅ **Admin-level control** (can disable globally)
|
||||
4. ✅ **User-level control** (individual preferences)
|
||||
5. ✅ **Critical override** (rejection, breach always sent)
|
||||
6. ✅ **Activity logs** always captured
|
||||
7. ✅ **In-app only** for collaboration (@mentions, comments)
|
||||
|
||||
### **Implementation Order:**
|
||||
1. Phase 0: Preference system ← **START HERE**
|
||||
2. Phase 1: Email service
|
||||
3. Phase 2: Critical emails (rejection, breach)
|
||||
4. Phase 3: High priority emails
|
||||
5. Phase 4: Medium/low priority emails
|
||||
6. Phase 5: User preference UI
|
||||
|
||||
---
|
||||
|
||||
**Ready to implement Phase 0 (Preference System)?** 🚀
|
||||
|
||||
1062
src/emailtemplates/IMPLEMENTATION_PLAN.md
Normal file
1062
src/emailtemplates/IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
373
src/emailtemplates/README.md
Normal file
373
src/emailtemplates/README.md
Normal file
@ -0,0 +1,373 @@
|
||||
# 📧 Royal Enfield Workflow - Email Templates
|
||||
|
||||
Professional, responsive, and dynamic email templates for the Royal Enfield Workflow System.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
✅ **Dynamic & Adaptive** - Templates adjust based on request data, priority, and workflow state
|
||||
✅ **Single Action Button** - Only "View Request Details" button that redirects to request URL
|
||||
✅ **Fully Responsive** - Mobile-friendly design that works across all devices
|
||||
✅ **Professional Design** - Royal Enfield branded with color-coded scenarios
|
||||
✅ **Email Client Compatible** - Table-based layout for maximum compatibility
|
||||
✅ **Placeholder-Based** - Easy to integrate with backend templating engines
|
||||
|
||||
---
|
||||
|
||||
## 📁 Template Files (12 Templates)
|
||||
|
||||
### Core Workflow Templates
|
||||
1. **RequestCreated.html** - Request submission confirmation
|
||||
2. **ApprovalRequest.html** - Single approver notification
|
||||
3. **MultiApproverRequest.html** - Multi-level approver notification with chain
|
||||
4. **ApprovalConfirmation.html** - Approval confirmation notification
|
||||
5. **RejectionNotification.html** - Rejection notification
|
||||
|
||||
### TAT Management Templates
|
||||
6. **TATReminder.html** - TAT deadline reminder (80% threshold)
|
||||
7. **TATBreached.html** - TAT breach escalation
|
||||
|
||||
### Workflow Control Templates
|
||||
8. **WorkflowPaused.html** - Workflow pause notification
|
||||
9. **WorkflowResumed.html** - Workflow resume notification
|
||||
|
||||
### Participant Management Templates
|
||||
10. **ParticipantAdded.html** - New approver/spectator welcome
|
||||
11. **ApproverSkipped.html** - Approver skip notification
|
||||
12. **RequestClosed.html** - Request closure summary
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Visual Design
|
||||
|
||||
Each template uses color-coded gradients to indicate the scenario:
|
||||
|
||||
| Template | Header Color | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| RequestCreated | Purple (#667eea) | Information |
|
||||
| ApprovalRequest | Purple (#667eea) | Action Required |
|
||||
| MultiApproverRequest | Purple (#667eea) | Action Required |
|
||||
| ApprovalConfirmation | Green (#28a745) | Success |
|
||||
| RejectionNotification | Red (#dc3545) | Error/Rejection |
|
||||
| TATReminder | Orange (#ff9800) | Warning |
|
||||
| TATBreached | Red (#dc3545) | Critical |
|
||||
| WorkflowPaused | Gray (#6c757d) | Neutral |
|
||||
| WorkflowResumed | Green (#28a745) | Success |
|
||||
| ParticipantAdded | Purple (#667eea) | Information |
|
||||
| ApproverSkipped | Cyan (#17a2b8) | Information |
|
||||
| RequestClosed | Purple (#6f42c1) | Complete |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 View Details Button
|
||||
|
||||
All templates feature a single action button:
|
||||
- **Text:** "View Request Details" / "Review Request Now" / "Take Action Now"
|
||||
- **Link Format:** `{baseURL}/request/{requestNumber}`
|
||||
- **Example:** `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
|
||||
|
||||
No approval/rejection buttons in emails - all actions happen within the application.
|
||||
|
||||
---
|
||||
|
||||
## 📋 How to Use Templates
|
||||
|
||||
### 1. Load Template File
|
||||
```javascript
|
||||
const fs = require('fs');
|
||||
const template = fs.readFileSync('./emailtemplates/ApprovalRequest.html', 'utf8');
|
||||
```
|
||||
|
||||
### 2. Replace Placeholders
|
||||
```javascript
|
||||
let emailContent = template
|
||||
.replace(/\[ApproverName\]/g, approverName)
|
||||
.replace(/\[InitiatorName\]/g, initiatorName)
|
||||
.replace(/\[RequestId\]/g, requestId)
|
||||
.replace(/\[ViewDetailsLink\]/g, `${baseURL}/request/${requestNumber}`)
|
||||
.replace(/\[CompanyName\]/g, 'Royal Enfield');
|
||||
```
|
||||
|
||||
### 3. Handle Dynamic Sections
|
||||
```javascript
|
||||
// Priority Section Example
|
||||
if (priority === 'HIGH' || priority === 'CRITICAL') {
|
||||
const priorityHTML = `
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">High Priority</h3>
|
||||
<p style="margin: 0; color: #856404; font-size: 14px; line-height: 1.6;">
|
||||
This request has been marked as ${priority} priority and requires prompt attention.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
emailContent = emailContent.replace('[PrioritySection]', priorityHTML);
|
||||
} else {
|
||||
emailContent = emailContent.replace('[PrioritySection]', '');
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Send Email
|
||||
```javascript
|
||||
await sendEmail({
|
||||
to: recipientEmail,
|
||||
subject: `[${requestId}] New Approval Request`,
|
||||
html: emailContent
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Complete Documentation
|
||||
|
||||
See **TEMPLATE_MAPPING.md** for:
|
||||
- Complete list of all placeholders for each template
|
||||
- Dynamic section handling instructions
|
||||
- Priority-based sending strategy
|
||||
- Usage scenarios and triggers
|
||||
- Security considerations
|
||||
- Implementation examples
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Integration Steps
|
||||
|
||||
### Step 1: Email Service Setup
|
||||
```typescript
|
||||
// services/email.service.ts
|
||||
import nodemailer from 'nodemailer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
class EmailService {
|
||||
private transporter;
|
||||
|
||||
constructor() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async sendEmail(to: string, subject: string, html: string) {
|
||||
return await this.transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM,
|
||||
to,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
}
|
||||
|
||||
loadTemplate(templateName: string): string {
|
||||
const templatePath = path.join(__dirname, '../emailtemplates', `${templateName}.html`);
|
||||
return fs.readFileSync(templatePath, 'utf8');
|
||||
}
|
||||
|
||||
replaceplaceholders(template: string, data: Record<string, string>): string {
|
||||
let result = template;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const regex = new RegExp(`\\[${key}\\]`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = new EmailService();
|
||||
```
|
||||
|
||||
### Step 2: Create Email Handlers
|
||||
```typescript
|
||||
// services/emailNotification.service.ts
|
||||
export async function sendApprovalRequest(approverEmail: string, requestData: any) {
|
||||
const template = emailService.loadTemplate('ApprovalRequest');
|
||||
|
||||
const placeholders = {
|
||||
ApproverName: approverEmail.split('@')[0],
|
||||
InitiatorName: requestData.initiatorName,
|
||||
RequestId: requestData.requestNumber,
|
||||
RequestDate: formatDate(requestData.createdAt),
|
||||
RequestTime: formatTime(requestData.createdAt),
|
||||
RequestType: requestData.requestType,
|
||||
RequestDescription: requestData.description,
|
||||
PrioritySection: getPrioritySection(requestData.priority),
|
||||
ViewDetailsLink: `${process.env.BASE_URL}/request/${requestData.requestNumber}`,
|
||||
CompanyName: 'Royal Enfield'
|
||||
};
|
||||
|
||||
const html = emailService.replaceplaceholders(template, placeholders);
|
||||
|
||||
await emailService.sendEmail(
|
||||
approverEmail,
|
||||
`[${requestData.requestNumber}] New Approval Request`,
|
||||
html
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add to Workflow Events
|
||||
```typescript
|
||||
// In workflow.service.ts
|
||||
await sendApprovalRequest(approverEmail, requestData);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Environment Variables Required
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```env
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@domain.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
|
||||
# Email Settings
|
||||
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
||||
BASE_URL=https://workflow.royalenfield.com
|
||||
COMPANY_NAME=Royal Enfield
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Templates
|
||||
|
||||
### Preview in Browser
|
||||
Open any `.html` file directly in a browser to see the design. Placeholders will be visible as `[PlaceholderName]`.
|
||||
|
||||
### Test Email Sending
|
||||
```typescript
|
||||
// test/email.test.ts
|
||||
import { emailService } from '../services/email.service';
|
||||
|
||||
describe('Email Templates', () => {
|
||||
it('should send approval request email', async () => {
|
||||
const template = emailService.loadTemplate('ApprovalRequest');
|
||||
const testData = {
|
||||
ApproverName: 'John Doe',
|
||||
InitiatorName: 'Jane Smith',
|
||||
RequestId: 'REQ-2025-12-0013',
|
||||
// ... other test data
|
||||
};
|
||||
|
||||
const html = emailService.replaceplaceholders(template, testData);
|
||||
|
||||
// Send to test email
|
||||
await emailService.sendEmail('test@example.com', 'Test Email', html);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Template Selection Logic
|
||||
|
||||
```typescript
|
||||
function getTemplateForScenario(scenario: string, hasMultipleApprovers: boolean): string {
|
||||
switch(scenario) {
|
||||
case 'request_created':
|
||||
return 'RequestCreated';
|
||||
case 'approval_required':
|
||||
return hasMultipleApprovers ? 'MultiApproverRequest' : 'ApprovalRequest';
|
||||
case 'request_approved':
|
||||
return 'ApprovalConfirmation';
|
||||
case 'request_rejected':
|
||||
return 'RejectionNotification';
|
||||
case 'tat_reminder':
|
||||
return 'TATReminder';
|
||||
case 'tat_breached':
|
||||
return 'TATBreached';
|
||||
case 'workflow_paused':
|
||||
return 'WorkflowPaused';
|
||||
case 'workflow_resumed':
|
||||
return 'WorkflowResumed';
|
||||
case 'participant_added':
|
||||
return 'ParticipantAdded';
|
||||
case 'approver_skipped':
|
||||
return 'ApproverSkipped';
|
||||
case 'request_closed':
|
||||
return 'RequestClosed';
|
||||
default:
|
||||
throw new Error(`Unknown scenario: ${scenario}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Priority Queue Implementation
|
||||
|
||||
Use Bull/BullMQ for reliable email delivery:
|
||||
|
||||
```typescript
|
||||
import Queue from 'bull';
|
||||
|
||||
const emailQueue = new Queue('email-notifications', {
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: process.env.REDIS_PORT
|
||||
}
|
||||
});
|
||||
|
||||
// Add email to queue with priority
|
||||
export async function queueEmail(emailData: any, priority: 'critical' | 'high' | 'medium' | 'low') {
|
||||
const priorityMap = { critical: 1, high: 2, medium: 3, low: 4 };
|
||||
|
||||
await emailQueue.add('send-email', emailData, {
|
||||
priority: priorityMap[priority],
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process email queue
|
||||
emailQueue.process('send-email', async (job) => {
|
||||
const { to, subject, html } = job.data;
|
||||
await emailService.sendEmail(to, subject, html);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Customization
|
||||
|
||||
For template customization or issues:
|
||||
1. Check **TEMPLATE_MAPPING.md** for complete documentation
|
||||
2. Test templates in email clients (Gmail, Outlook, Apple Mail)
|
||||
3. Validate HTML using online validators
|
||||
4. Use email testing services (Litmus, Email on Acid)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Change Log
|
||||
|
||||
**Version 2.0** (Dec 4, 2025)
|
||||
- ✅ Removed all action buttons (Approve/Reject)
|
||||
- ✅ Added single "View Request Details" button
|
||||
- ✅ Made templates fully dynamic with conditional sections
|
||||
- ✅ Added 12 templates for all scenarios
|
||||
- ✅ Improved mobile responsiveness
|
||||
- ✅ Professional design without emojis
|
||||
- ✅ Added comprehensive documentation
|
||||
|
||||
**Version 1.0** (Initial Release)
|
||||
- Basic templates with action buttons
|
||||
- 4 templates (Approval, Rejection, Multi-Approver, Confirmation)
|
||||
|
||||
---
|
||||
|
||||
**Maintained by:** Royal Enfield Development Team
|
||||
**Last Updated:** December 4, 2025
|
||||
**Email Template Version:** 2.0
|
||||
|
||||
293
src/emailtemplates/RICHTEXT_SUPPORT.md
Normal file
293
src/emailtemplates/RICHTEXT_SUPPORT.md
Normal file
@ -0,0 +1,293 @@
|
||||
# Rich Text Editor Support in Email Templates
|
||||
|
||||
## ✅ **Rich Text Format Ready**
|
||||
|
||||
Your email templates now support HTML content from rich text editors like:
|
||||
- Quill
|
||||
- TinyMCE
|
||||
- CKEditor
|
||||
- Draft.js
|
||||
- Slate
|
||||
- Tiptap
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **Supported HTML Elements**
|
||||
|
||||
### **Text Formatting**
|
||||
✅ `<strong>`, `<b>` - **Bold text**
|
||||
✅ `<em>`, `<i>` - *Italic text*
|
||||
✅ `<u>` - <u>Underlined text</u>
|
||||
✅ `<p>` - Paragraphs with spacing
|
||||
✅ `<br>` - Line breaks
|
||||
|
||||
### **Lists**
|
||||
✅ `<ul>`, `<li>` - Bulleted lists
|
||||
✅ `<ol>`, `<li>` - Numbered lists
|
||||
|
||||
### **Headings**
|
||||
✅ `<h1>` - 20px heading
|
||||
✅ `<h2>` - 18px heading
|
||||
✅ `<h3>` - 16px heading
|
||||
✅ `<h4>` - 14px heading
|
||||
|
||||
### **Links**
|
||||
✅ `<a href="">` - Clickable links (Royal Enfield Red color)
|
||||
|
||||
### **Special Elements**
|
||||
✅ `<blockquote>` - Quote blocks with gold accent
|
||||
✅ `<code>` - Inline code snippets
|
||||
✅ `<pre>` - Code blocks
|
||||
✅ `<hr>` - Horizontal dividers
|
||||
✅ `<img>` - Images (auto-scaled for mobile)
|
||||
|
||||
---
|
||||
|
||||
## 📝 **Usage Example**
|
||||
|
||||
### **From Rich Text Editor:**
|
||||
|
||||
Your rich text editor might output:
|
||||
```html
|
||||
<p>This is a <strong>purchase request</strong> for new equipment.</p>
|
||||
<ul>
|
||||
<li>Item 1: Motorcycle helmet</li>
|
||||
<li>Item 2: Safety gear</li>
|
||||
</ul>
|
||||
<p>Total cost: <strong>$5,000</strong></p>
|
||||
```
|
||||
|
||||
### **In Your Code:**
|
||||
|
||||
```typescript
|
||||
import { getApprovalRequestEmail } from '@/emailtemplates';
|
||||
|
||||
const data = {
|
||||
// ... other fields
|
||||
requestDescription: editorHtmlContent, // Pass HTML directly
|
||||
// ... other fields
|
||||
};
|
||||
|
||||
const html = getApprovalRequestEmail(data);
|
||||
```
|
||||
|
||||
### **Result in Email:**
|
||||
|
||||
The HTML will be properly styled with:
|
||||
- Royal Enfield brand colors
|
||||
- Proper spacing and line heights
|
||||
- Mobile-responsive fonts
|
||||
- Email-safe formatting
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **Security - HTML Sanitization**
|
||||
|
||||
**IMPORTANT:** Always sanitize HTML content before using in emails!
|
||||
|
||||
### **Recommended: Use DOMPurify or similar**
|
||||
|
||||
```typescript
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
// Sanitize rich text content
|
||||
const sanitizedDescription = DOMPurify.sanitize(editorHtmlContent, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'b', 'em', 'i', 'u',
|
||||
'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3', 'h4',
|
||||
'blockquote', 'code', 'pre', 'hr', 'img'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'target'],
|
||||
ALLOW_DATA_ATTR: false
|
||||
});
|
||||
|
||||
const data = {
|
||||
requestDescription: sanitizedDescription,
|
||||
// ... other fields
|
||||
};
|
||||
```
|
||||
|
||||
### **Install DOMPurify:**
|
||||
|
||||
```bash
|
||||
npm install isomorphic-dompurify
|
||||
npm install --save-dev @types/dompurify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Rich Text Styling**
|
||||
|
||||
### **Brand Colors Applied:**
|
||||
|
||||
| Element | Color | Usage |
|
||||
|---------|-------|-------|
|
||||
| Links | #DB281B (RE Red) | Clickable links |
|
||||
| Strong text | #1a1a1a (Black) | Bold emphasis |
|
||||
| Blockquotes | Gold border (#DEB219) | Quoted text |
|
||||
| Code | #DB281B (RE Red) | Code snippets |
|
||||
| Headings | #1a1a1a (Black) | Section titles |
|
||||
|
||||
### **Spacing:**
|
||||
|
||||
- Paragraphs: 12px bottom margin
|
||||
- Lists: 25px left padding, 8px item spacing
|
||||
- Headings: 12px bottom margin
|
||||
- Blockquotes: 12px padding, gold left border
|
||||
- Images: Auto-scaled, 12px margin
|
||||
|
||||
---
|
||||
|
||||
## 📱 **Mobile Responsive**
|
||||
|
||||
Rich text content automatically adjusts for mobile:
|
||||
|
||||
| Element | Desktop | Mobile |
|
||||
|---------|---------|--------|
|
||||
| Paragraphs | 14px | 13px |
|
||||
| Headings H1 | 20px | 18px |
|
||||
| Headings H2 | 18px | 16px |
|
||||
| Lists | 14px | 13px |
|
||||
| Images | 100% width | 100% width |
|
||||
|
||||
---
|
||||
|
||||
## 💡 **Example Rich Text Content**
|
||||
|
||||
### **Complex Example:**
|
||||
|
||||
```html
|
||||
<h2>Equipment Purchase Request</h2>
|
||||
|
||||
<p>We need to purchase the following items for the <strong>testing department</strong>:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>2× Royal Enfield Himalayan</strong> - Latest model</li>
|
||||
<li><strong>Safety gear</strong> - Helmets and protective equipment</li>
|
||||
<li><strong>Maintenance tools</strong> - Standard toolkit</li>
|
||||
</ul>
|
||||
|
||||
<h3>Budget Breakdown</h3>
|
||||
|
||||
<p>Total estimated cost: <strong>$15,000</strong></p>
|
||||
|
||||
<blockquote>
|
||||
This purchase is critical for our Q1 testing schedule and has been approved by the department head.
|
||||
</blockquote>
|
||||
|
||||
<p>Please review and approve at your earliest convenience.</p>
|
||||
|
||||
<p>For questions, contact: <a href="mailto:john@example.com">john@example.com</a></p>
|
||||
```
|
||||
|
||||
### **Will Render As:**
|
||||
|
||||
✅ Styled headings with proper hierarchy
|
||||
✅ Formatted lists with Royal Enfield accents
|
||||
✅ Bold text in black
|
||||
✅ Gold-bordered quote box
|
||||
✅ Red links
|
||||
✅ Proper spacing throughout
|
||||
✅ Mobile-optimized fonts
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ **Best Practices**
|
||||
|
||||
### **1. Sanitize User Input**
|
||||
```typescript
|
||||
const cleanHTML = DOMPurify.sanitize(userInput);
|
||||
```
|
||||
|
||||
### **2. Use wrapRichText Helper**
|
||||
```typescript
|
||||
${wrapRichText(data.requestDescription)}
|
||||
```
|
||||
|
||||
### **3. Test with Rich Content**
|
||||
Test emails with:
|
||||
- Multiple paragraphs
|
||||
- Lists (bulleted and numbered)
|
||||
- Links
|
||||
- Bold and italic text
|
||||
- Headings at different levels
|
||||
|
||||
### **4. Avoid These in Emails**
|
||||
❌ JavaScript or `<script>` tags
|
||||
❌ External stylesheets
|
||||
❌ Forms or input elements
|
||||
❌ iframes
|
||||
❌ SVG graphics (use PNG/JPG instead)
|
||||
❌ Web fonts (use system fonts)
|
||||
|
||||
### **5. Image Handling**
|
||||
If users can add images in rich text:
|
||||
- ✅ Use CDN or hosted images (not base64)
|
||||
- ✅ Set max-width: 100% for mobile
|
||||
- ✅ Add alt text for accessibility
|
||||
- ✅ Optimize image file sizes
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **Test Rich Text**
|
||||
|
||||
Update the test file to include rich HTML:
|
||||
|
||||
```typescript
|
||||
const approvalRequestData: ApprovalRequestData = {
|
||||
// ... other fields
|
||||
requestDescription: `
|
||||
<h3>Equipment Purchase</h3>
|
||||
<p>Requesting approval for <strong>new equipment</strong>:</p>
|
||||
<ul>
|
||||
<li>2× Motorcycles</li>
|
||||
<li>Safety gear</li>
|
||||
</ul>
|
||||
<p>Total: <strong>$15,000</strong></p>
|
||||
`,
|
||||
// ... other fields
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Email Client Compatibility**
|
||||
|
||||
### **Full Rich Text Support:**
|
||||
✅ Gmail (Desktop & Mobile)
|
||||
✅ Outlook 2016+ (Desktop & Mobile)
|
||||
✅ Apple Mail (macOS & iOS)
|
||||
✅ Yahoo Mail
|
||||
✅ ProtonMail
|
||||
|
||||
### **Partial Support:**
|
||||
⚠️ Outlook 2007-2013 (limited CSS)
|
||||
⚠️ Lotus Notes (basic HTML only)
|
||||
|
||||
### **Not Supported:**
|
||||
❌ Plain text email clients
|
||||
|
||||
---
|
||||
|
||||
## ✅ **Templates Updated**
|
||||
|
||||
Templates now include:
|
||||
- ✅ `getResponsiveStyles()` - Mobile + Rich text CSS
|
||||
- ✅ `wrapRichText()` - Wraps HTML with styling
|
||||
- ✅ `getRichTextStyles()` - Styles for editor content
|
||||
- ✅ Responsive classes on all elements
|
||||
- ✅ Royal Enfield brand colors in rich text
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Ready to Use**
|
||||
|
||||
Your email templates can now handle:
|
||||
1. ✅ Plain text descriptions
|
||||
2. ✅ Rich HTML from editors
|
||||
3. ✅ Mobile responsive viewing
|
||||
4. ✅ Royal Enfield branded styling
|
||||
5. ✅ Safe, sanitized content
|
||||
|
||||
**Just pass the HTML from your rich text editor directly!** 🎯
|
||||
|
||||
429
src/emailtemplates/TEMPLATE_MAPPING.md
Normal file
429
src/emailtemplates/TEMPLATE_MAPPING.md
Normal file
@ -0,0 +1,429 @@
|
||||
# Email Templates - Complete Mapping Guide
|
||||
|
||||
## 📧 Template Overview
|
||||
|
||||
All email templates have been designed to be **dynamic** and **consistent**. Each template includes:
|
||||
- ✅ Only ONE action button: **"View Request Details"** (redirects to `/request/[RequestNumber]`)
|
||||
- ✅ Fully responsive design (mobile-friendly)
|
||||
- ✅ Professional Royal Enfield branding
|
||||
- ✅ Dynamic placeholders for personalization
|
||||
- ✅ Consistent footer with disclaimer
|
||||
|
||||
---
|
||||
|
||||
## 📋 Template Inventory (10 Templates)
|
||||
|
||||
### 1. **RequestCreated.html**
|
||||
**Trigger:** When a new workflow request is created
|
||||
**Recipients:** Initiator
|
||||
**Purpose:** Confirm request submission and inform about next steps
|
||||
|
||||
**Placeholders:**
|
||||
- `[InitiatorName]` - Name of the person who created the request
|
||||
- `[FirstApproverName]` - Name of the first approver
|
||||
- `[RequestId]` - Request number (e.g., REQ-2025-12-0013)
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[RequestType]` - Type of request
|
||||
- `[Priority]` - Priority level (LOW, MEDIUM, HIGH, CRITICAL)
|
||||
- `[RequestDate]` - Creation date
|
||||
- `[RequestTime]` - Creation time
|
||||
- `[TotalApprovers]` - Total number of approvers
|
||||
- `[ExpectedTAT]` - Expected turnaround time in hours
|
||||
- `[ViewDetailsLink]` - Link to request (baseURL/request/requestNumber)
|
||||
- `[CompanyName]` - Company name (e.g., Royal Enfield)
|
||||
|
||||
**Priority:** High (send immediately after request creation)
|
||||
|
||||
---
|
||||
|
||||
### 2. **ApprovalRequest.html**
|
||||
**Trigger:** When a request is assigned to an approver (single approver)
|
||||
**Recipients:** Approver
|
||||
**Purpose:** Notify approver about pending approval request
|
||||
|
||||
**Placeholders:**
|
||||
- `[ApproverName]` - Name of the approver
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestDate]` - Submission date
|
||||
- `[RequestTime]` - Submission time
|
||||
- `[RequestType]` - Type of request
|
||||
- `[RequestDescription]` - Brief description
|
||||
- `[PrioritySection]` - Dynamic section (show only if priority is HIGH/CRITICAL)
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Dynamic Sections:**
|
||||
- `[PrioritySection]`: Show alert box if priority is HIGH or CRITICAL
|
||||
|
||||
**Priority:** High (send immediately when assigned)
|
||||
|
||||
---
|
||||
|
||||
### 3. **MultiApproverRequest.html**
|
||||
**Trigger:** When a request is assigned to an approver (multi-level workflow)
|
||||
**Recipients:** Approver in multi-level workflow
|
||||
**Purpose:** Notify approver with approval chain visibility
|
||||
|
||||
**Placeholders:**
|
||||
- `[ApproverName]` - Name of the approver
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestDate]` - Submission date
|
||||
- `[RequestTime]` - Submission time
|
||||
- `[RequestType]` - Type of request
|
||||
- `[ApproverLevel]` - Current approver's level (e.g., 2)
|
||||
- `[TotalApprovers]` - Total approvers (e.g., 5)
|
||||
- `[ApproversList]` - Dynamic HTML showing approval chain with status
|
||||
- `[RequestDescription]` - Brief description
|
||||
- `[PrioritySection]` - Dynamic section for high priority
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Dynamic Sections:**
|
||||
- `[ApproversList]`: Generate HTML for each approver with status (✓ Approved, numbered for current, grayed for pending)
|
||||
- `[PrioritySection]`: Show if HIGH/CRITICAL priority
|
||||
|
||||
**Priority:** High (send immediately when assigned)
|
||||
|
||||
---
|
||||
|
||||
### 4. **ApprovalConfirmation.html**
|
||||
**Trigger:** When an approver approves a request
|
||||
**Recipients:** Initiator (and optionally other stakeholders)
|
||||
**Purpose:** Confirm approval action
|
||||
|
||||
**Placeholders:**
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[ApproverName]` - Name of the approver who approved
|
||||
- `[RequestId]` - Request number
|
||||
- `[ApprovalDate]` - Approval date
|
||||
- `[ApprovalTime]` - Approval time
|
||||
- `[RequestType]` - Type of request
|
||||
- `[ApproverComments]` - Comments from approver
|
||||
- `[StatusSection]` - Dynamic section for workflow status
|
||||
- `[NextStepsSection]` - Dynamic section for next steps
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Dynamic Sections:**
|
||||
- `[StatusSection]`: Show if more approvals are pending
|
||||
- `[NextStepsSection]`: Show what happens next (if final approval, show closure instructions)
|
||||
|
||||
**Priority:** High (send immediately after approval)
|
||||
|
||||
---
|
||||
|
||||
### 5. **RejectionNotification.html**
|
||||
**Trigger:** When an approver rejects a request
|
||||
**Recipients:** Initiator (and optionally other stakeholders)
|
||||
**Purpose:** Notify about rejection
|
||||
|
||||
**Placeholders:**
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[ApproverName]` - Name of the approver who rejected
|
||||
- `[RequestId]` - Request number
|
||||
- `[RejectionDate]` - Rejection date
|
||||
- `[RejectionTime]` - Rejection time
|
||||
- `[RequestType]` - Type of request
|
||||
- `[RejectionReason]` - Reason for rejection
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Priority:** Critical (send immediately after rejection)
|
||||
|
||||
---
|
||||
|
||||
### 6. **TATReminder.html**
|
||||
**Trigger:** When TAT deadline is approaching (e.g., 80% of TAT elapsed)
|
||||
**Recipients:** Approver
|
||||
**Purpose:** Remind approver about pending action
|
||||
|
||||
**Placeholders:**
|
||||
- `[ApproverName]` - Name of the approver
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[AssignedDate]` - Date when assigned to approver
|
||||
- `[TATDeadline]` - TAT deadline date and time
|
||||
- `[TimeRemaining]` - Time remaining (e.g., "4 hours")
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Priority:** High (send based on TAT threshold - e.g., when 80% elapsed)
|
||||
|
||||
---
|
||||
|
||||
### 7. **TATBreached.html**
|
||||
**Trigger:** When TAT deadline has passed
|
||||
**Recipients:** Approver, Management (optionally)
|
||||
**Purpose:** Escalate breached TAT
|
||||
|
||||
**Placeholders:**
|
||||
- `[ApproverName]` - Name of the approver
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[Priority]` - Priority level
|
||||
- `[AssignedDate]` - Date when assigned
|
||||
- `[TATDeadline]` - Original TAT deadline
|
||||
- `[TimeOverdue]` - Time overdue (e.g., "6 hours overdue")
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Priority:** Critical (send immediately when TAT breached)
|
||||
|
||||
---
|
||||
|
||||
### 8. **WorkflowPaused.html**
|
||||
**Trigger:** When a workflow is paused
|
||||
**Recipients:** Initiator, Current Approver, Spectators
|
||||
**Purpose:** Inform about workflow pause
|
||||
|
||||
**Placeholders:**
|
||||
- `[RecipientName]` - Name of the recipient
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[PausedByName]` - Name of person who paused
|
||||
- `[PausedDate]` - Pause date
|
||||
- `[PausedTime]` - Pause time
|
||||
- `[ResumeDate]` - Scheduled resume date
|
||||
- `[PauseReason]` - Reason for pause
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Priority:** Medium (send when workflow is paused)
|
||||
|
||||
---
|
||||
|
||||
### 9. **WorkflowResumed.html**
|
||||
**Trigger:** When a workflow is resumed (auto or manual)
|
||||
**Recipients:** Initiator, Current Approver, Spectators
|
||||
**Purpose:** Inform about workflow resumption
|
||||
|
||||
**Placeholders:**
|
||||
- `[RecipientName]` - Name of the recipient
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[ResumedByText]` - Text explaining resume (e.g., "automatically" or "by John Doe")
|
||||
- `[ResumedDate]` - Resume date
|
||||
- `[ResumedTime]` - Resume time
|
||||
- `[PausedDuration]` - Duration of pause (e.g., "3 days")
|
||||
- `[CurrentApprover]` - Name of current approver
|
||||
- `[NewTATDeadline]` - Updated TAT deadline
|
||||
- `[ActionRequiredSection]` - Dynamic section for approvers only
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Dynamic Sections:**
|
||||
- `[ActionRequiredSection]`: Show only to the current approver
|
||||
|
||||
**Priority:** High (send immediately when resumed)
|
||||
|
||||
---
|
||||
|
||||
### 10. **ParticipantAdded.html**
|
||||
**Trigger:** When someone is added as Approver/Spectator to existing request
|
||||
**Recipients:** Newly added participant
|
||||
**Purpose:** Welcome participant and explain their role
|
||||
|
||||
**Placeholders:**
|
||||
- `[ParticipantName]` - Name of the participant
|
||||
- `[ParticipantRole]` - Role (Approver, Spectator)
|
||||
- `[AddedByName]` - Name of person who added them
|
||||
- `[RoleDescription]` - Description of role
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[RequestType]` - Type of request
|
||||
- `[CurrentStatus]` - Current workflow status
|
||||
- `[AddedDate]` - Date added
|
||||
- `[AddedTime]` - Time added
|
||||
- `[RequestDescription]` - Brief description
|
||||
- `[PermissionsContent]` - Dynamic HTML explaining permissions
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Dynamic Sections:**
|
||||
- `[PermissionsContent]`: Different content for Approver vs Spectator
|
||||
|
||||
**Priority:** Medium (send when participant is added)
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Additional Templates
|
||||
|
||||
### 11. **ApproverSkipped.html**
|
||||
**Trigger:** When an approver is skipped in the workflow
|
||||
**Recipients:** Skipped approver, Initiator, other stakeholders
|
||||
**Purpose:** Notify about level skip
|
||||
|
||||
**Placeholders:**
|
||||
- `[RecipientName]` - Name of the recipient
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[SkippedApproverName]` - Name of skipped approver
|
||||
- `[SkippedByName]` - Name of person who skipped
|
||||
- `[SkippedDate]` - Skip date
|
||||
- `[SkippedTime]` - Skip time
|
||||
- `[NextApproverName]` - Next approver in line
|
||||
- `[SkipReason]` - Reason for skipping
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Priority:** Medium (send when approver is skipped)
|
||||
|
||||
---
|
||||
|
||||
### 12. **RequestClosed.html**
|
||||
**Trigger:** When initiator closes the request after all approvals
|
||||
**Recipients:** All participants (approvers, spectators)
|
||||
**Purpose:** Notify about request closure and provide summary
|
||||
|
||||
**Placeholders:**
|
||||
- `[RecipientName]` - Name of the recipient
|
||||
- `[RequestId]` - Request number
|
||||
- `[RequestTitle]` - Title of the request
|
||||
- `[InitiatorName]` - Name of the initiator
|
||||
- `[CreatedDate]` - Request creation date
|
||||
- `[ClosedDate]` - Closure date
|
||||
- `[ClosedTime]` - Closure time
|
||||
- `[TotalDuration]` - Total duration from creation to closure
|
||||
- `[ConclusionSection]` - Dynamic section for conclusion remarks
|
||||
- `[TotalApprovers]` - Total number of approvers
|
||||
- `[TotalApprovals]` - Total approvals received
|
||||
- `[WorkNotesCount]` - Number of work notes
|
||||
- `[DocumentsCount]` - Number of documents
|
||||
- `[ViewDetailsLink]` - Link to request
|
||||
- `[CompanyName]` - Company name
|
||||
|
||||
**Dynamic Sections:**
|
||||
- `[ConclusionSection]`: Show if conclusion remarks provided
|
||||
|
||||
**Priority:** Low (send when request is closed)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Dynamic Placeholder Format
|
||||
|
||||
### Standard Placeholders
|
||||
Format: `[PlaceholderName]`
|
||||
Example: `[InitiatorName]` → Replace with actual name
|
||||
|
||||
### Dynamic Sections
|
||||
Format: `[SectionName]`
|
||||
These should be replaced with HTML blocks or empty strings based on conditions.
|
||||
|
||||
**Example for Priority Section:**
|
||||
```html
|
||||
<!-- If Priority is HIGH or CRITICAL, replace [PrioritySection] with: -->
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">High Priority</h3>
|
||||
<p style="margin: 0; color: #856404; font-size: 14px; line-height: 1.6;">
|
||||
This request has been marked as HIGH priority and requires prompt attention.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Otherwise, replace [PrioritySection] with empty string -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Priority-Based Sending Strategy
|
||||
|
||||
### Critical Priority (Send Immediately)
|
||||
- TATBreached.html
|
||||
- RejectionNotification.html
|
||||
|
||||
### High Priority (Send within 1 minute)
|
||||
- RequestCreated.html
|
||||
- ApprovalRequest.html
|
||||
- MultiApproverRequest.html
|
||||
- ApprovalConfirmation.html
|
||||
- TATReminder.html
|
||||
- WorkflowResumed.html
|
||||
|
||||
### Medium Priority (Send within 5 minutes)
|
||||
- WorkflowPaused.html
|
||||
- ParticipantAdded.html
|
||||
- ApproverSkipped.html
|
||||
|
||||
### Low Priority (Can be batched)
|
||||
- RequestClosed.html
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Implementation Notes
|
||||
|
||||
### Base URL Configuration
|
||||
All `[ViewDetailsLink]` placeholders should be replaced with:
|
||||
```
|
||||
{baseURL}/request/{requestNumber}
|
||||
```
|
||||
|
||||
Example: `https://workflow.royalenfield.com/request/REQ-2025-12-0013`
|
||||
|
||||
### Company Name
|
||||
Replace `[CompanyName]` with your organization name (e.g., "Royal Enfield")
|
||||
|
||||
### Date/Time Format
|
||||
Recommended format:
|
||||
- Date: `MMM DD, YYYY` (e.g., Dec 04, 2025)
|
||||
- Time: `HH:MM AM/PM` (e.g., 02:30 PM)
|
||||
- Duration: Human-readable (e.g., "2 days 5 hours")
|
||||
|
||||
### Email Queue Implementation
|
||||
Use a priority queue system (e.g., Bull/BullMQ) with priority levels:
|
||||
- Critical: Priority 1
|
||||
- High: Priority 2
|
||||
- Medium: Priority 3
|
||||
- Low: Priority 4
|
||||
|
||||
---
|
||||
|
||||
## 📊 Template Usage Matrix
|
||||
|
||||
| Scenario | Template | Recipients | Priority | Send When |
|
||||
|----------|----------|-----------|----------|-----------|
|
||||
| Request Created | RequestCreated | Initiator | High | Immediate |
|
||||
| Assigned to Approver (Single) | ApprovalRequest | Approver | High | Immediate |
|
||||
| Assigned to Approver (Multi) | MultiApproverRequest | Approver | High | Immediate |
|
||||
| Request Approved | ApprovalConfirmation | Initiator | High | Immediate |
|
||||
| Request Rejected | RejectionNotification | Initiator | Critical | Immediate |
|
||||
| TAT 80% Elapsed | TATReminder | Approver | High | At 80% TAT |
|
||||
| TAT Breached | TATBreached | Approver, Mgmt | Critical | Immediate |
|
||||
| Workflow Paused | WorkflowPaused | All | Medium | On Pause |
|
||||
| Workflow Resumed | WorkflowResumed | All | High | On Resume |
|
||||
| Participant Added | ParticipantAdded | New Participant | Medium | On Addition |
|
||||
| Approver Skipped | ApproverSkipped | All | Medium | On Skip |
|
||||
| Request Closed | RequestClosed | All Participants | Low | On Closure |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Quality Checklist
|
||||
|
||||
Before sending any email:
|
||||
- [ ] All placeholders replaced with actual data
|
||||
- [ ] ViewDetailsLink contains correct request URL
|
||||
- [ ] Dynamic sections properly rendered or removed
|
||||
- [ ] Priority-based styling applied correctly
|
||||
- [ ] Recipient list is accurate
|
||||
- [ ] Email subject is descriptive
|
||||
- [ ] From address is configured correctly
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Considerations
|
||||
|
||||
1. **URL Validation**: Ensure ViewDetailsLink only contains your domain
|
||||
2. **Data Sanitization**: Escape HTML in user-provided content (names, comments, descriptions)
|
||||
3. **Email Spoofing**: Use SPF, DKIM, and DMARC records
|
||||
4. **Unsubscribe**: Add unsubscribe link if required by regulations
|
||||
5. **Privacy**: Don't include sensitive data in URLs or plain text
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** December 4, 2025
|
||||
**Template Version:** 2.0
|
||||
**Total Templates:** 12
|
||||
|
||||
318
src/emailtemplates/USAGE.md
Normal file
318
src/emailtemplates/USAGE.md
Normal file
@ -0,0 +1,318 @@
|
||||
# Email Templates - Usage Guide
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
emailtemplates/
|
||||
├── index.ts ✅ Central export file
|
||||
├── types.ts ✅ TypeScript type definitions
|
||||
├── helpers.ts ✅ Reusable helper functions
|
||||
│
|
||||
├── requestCreated.template.ts ✅ Request created email
|
||||
├── approvalRequest.template.ts ✅ Single approver email
|
||||
├── multiApproverRequest.template.ts ✅ Multi-approver email
|
||||
│
|
||||
├── approvalConfirmation.template.ts 🔨 TODO
|
||||
├── rejectionNotification.template.ts 🔨 TODO
|
||||
├── tatReminder.template.ts 🔨 TODO
|
||||
├── tatBreached.template.ts 🔨 TODO
|
||||
├── workflowPaused.template.ts 🔨 TODO
|
||||
├── workflowResumed.template.ts 🔨 TODO
|
||||
├── participantAdded.template.ts 🔨 TODO
|
||||
├── approverSkipped.template.ts 🔨 TODO
|
||||
└── requestClosed.template.ts 🔨 TODO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Import the template function
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getRequestCreatedEmail,
|
||||
getApprovalRequestEmail,
|
||||
RequestCreatedData,
|
||||
ApprovalRequestData
|
||||
} from '@/emailtemplates';
|
||||
```
|
||||
|
||||
### 2. Prepare the data
|
||||
|
||||
```typescript
|
||||
const data: RequestCreatedData = {
|
||||
recipientName: 'John Doe',
|
||||
requestId: 'REQ-2025-12-0013',
|
||||
requestTitle: 'New Equipment Purchase',
|
||||
initiatorName: 'John Doe',
|
||||
firstApproverName: 'Jane Smith',
|
||||
requestType: 'Purchase',
|
||||
priority: 'HIGH',
|
||||
requestDate: 'Dec 04, 2025',
|
||||
requestTime: '02:30 PM',
|
||||
totalApprovers: 3,
|
||||
expectedTAT: 48,
|
||||
viewDetailsLink: 'https://workflow.royalenfield.com/request/REQ-2025-12-0013',
|
||||
companyName: 'Royal Enfield'
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Generate the HTML
|
||||
|
||||
```typescript
|
||||
const htmlContent = getRequestCreatedEmail(data);
|
||||
```
|
||||
|
||||
### 4. Send the email
|
||||
|
||||
```typescript
|
||||
await emailService.sendEmail({
|
||||
to: 'john.doe@example.com',
|
||||
subject: '[REQ-2025-12-0013] Request Created Successfully',
|
||||
html: htmlContent
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📧 Complete Example
|
||||
|
||||
```typescript
|
||||
import nodemailer from 'nodemailer';
|
||||
import { getApprovalRequestEmail, ApprovalRequestData } from '@/emailtemplates';
|
||||
|
||||
// Setup email transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
// Function to send approval request
|
||||
async function sendApprovalRequest(request: any, approver: any) {
|
||||
// Prepare data
|
||||
const data: ApprovalRequestData = {
|
||||
recipientName: approver.name,
|
||||
requestId: request.requestNumber,
|
||||
approverName: approver.name,
|
||||
initiatorName: request.initiator.name,
|
||||
requestType: request.type,
|
||||
requestDescription: request.description,
|
||||
priority: request.priority,
|
||||
requestDate: new Date(request.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}),
|
||||
requestTime: new Date(request.createdAt).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
viewDetailsLink: `${process.env.BASE_URL}/request/${request.requestNumber}`,
|
||||
companyName: process.env.COMPANY_NAME || 'Royal Enfield'
|
||||
};
|
||||
|
||||
// Generate HTML
|
||||
const html = getApprovalRequestEmail(data);
|
||||
|
||||
// Send email
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM,
|
||||
to: approver.email,
|
||||
subject: `[${request.requestNumber}] Approval Request - Action Required`,
|
||||
html: html
|
||||
});
|
||||
|
||||
console.log(`✅ Approval request email sent to ${approver.email}`);
|
||||
}
|
||||
|
||||
// Usage
|
||||
await sendApprovalRequest(requestData, approverData);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Template Features
|
||||
|
||||
### Type Safety
|
||||
All templates have strongly typed data structures:
|
||||
```typescript
|
||||
// TypeScript will catch errors at compile time
|
||||
const data: RequestCreatedData = {
|
||||
recipientName: 'John',
|
||||
requestId: 'REQ-001',
|
||||
// TypeScript error if you miss required fields!
|
||||
// TypeScript error if you use wrong types!
|
||||
};
|
||||
```
|
||||
|
||||
### Dynamic Sections
|
||||
Templates automatically show/hide sections based on data:
|
||||
```typescript
|
||||
// Priority alert only shows for HIGH/CRITICAL
|
||||
getPrioritySection(data.priority); // Returns HTML or empty string
|
||||
|
||||
// Approval chain visualization
|
||||
getApprovalChain(data.approversList); // Generates chain HTML
|
||||
|
||||
// Next steps based on approval status
|
||||
getNextStepsSection(isFinalApproval, nextApproverName);
|
||||
```
|
||||
|
||||
### Reusable Helpers
|
||||
Common functions in `helpers.ts`:
|
||||
- `getPrioritySection()` - Priority alert boxes
|
||||
- `getApprovalChain()` - Approval chain visualization
|
||||
- `getNextStepsSection()` - Next steps guidance
|
||||
- `getPermissionsContent()` - Role-based permissions
|
||||
- `getConclusionSection()` - Conclusion remarks
|
||||
- `getEmailFooter()` - Consistent footer
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Environment Variables
|
||||
|
||||
Required in your `.env`:
|
||||
|
||||
```env
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your-email@domain.com
|
||||
SMTP_PASSWORD=your-app-password
|
||||
|
||||
# Email Settings
|
||||
EMAIL_FROM=Royal Enfield Workflow <notifications@royalenfield.com>
|
||||
|
||||
# Application Settings
|
||||
BASE_URL=https://workflow.royalenfield.com
|
||||
COMPANY_NAME=Royal Enfield
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### 1. Date Formatting
|
||||
Use consistent date formatting:
|
||||
```typescript
|
||||
const date = new Date(request.createdAt);
|
||||
|
||||
// Date format: "Dec 04, 2025"
|
||||
const requestDate = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
// Time format: "02:30 PM"
|
||||
const requestTime = date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
```
|
||||
|
||||
### 2. URL Generation
|
||||
Always use environment variable for base URL:
|
||||
```typescript
|
||||
const viewDetailsLink = `${process.env.BASE_URL}/request/${requestNumber}`;
|
||||
```
|
||||
|
||||
### 3. Error Handling
|
||||
Wrap email sending in try-catch:
|
||||
```typescript
|
||||
try {
|
||||
const html = getApprovalRequestEmail(data);
|
||||
await sendEmail(recipientEmail, subject, html);
|
||||
console.log('✅ Email sent successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to send email:', error);
|
||||
// Log to monitoring service
|
||||
// Add to retry queue
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Email Queue (Recommended)
|
||||
Use a queue for reliable delivery:
|
||||
```typescript
|
||||
import Queue from 'bull';
|
||||
|
||||
const emailQueue = new Queue('emails', {
|
||||
redis: { host: 'localhost', port: 6379 }
|
||||
});
|
||||
|
||||
// Add to queue
|
||||
await emailQueue.add('send-approval-request', {
|
||||
recipientEmail: approver.email,
|
||||
data: approvalRequestData
|
||||
}, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 }
|
||||
});
|
||||
|
||||
// Process queue
|
||||
emailQueue.process('send-approval-request', async (job) => {
|
||||
const { recipientEmail, data } = job.data;
|
||||
const html = getApprovalRequestEmail(data);
|
||||
await sendEmail(recipientEmail, subject, html);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist Before Sending
|
||||
|
||||
- [ ] All required data fields provided
|
||||
- [ ] ViewDetailsLink points to correct environment
|
||||
- [ ] CompanyName is set correctly
|
||||
- [ ] Dates are properly formatted
|
||||
- [ ] Priority section logic is correct
|
||||
- [ ] Email subject includes request number
|
||||
- [ ] Recipient email is valid
|
||||
- [ ] SMTP credentials are configured
|
||||
- [ ] Error handling is in place
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### TypeScript Errors
|
||||
```
|
||||
Property 'requestId' is missing in type
|
||||
```
|
||||
**Solution:** Ensure all required fields in the type definition are provided.
|
||||
|
||||
### Email Not Sending
|
||||
**Check:**
|
||||
1. SMTP credentials in `.env`
|
||||
2. Network connectivity
|
||||
3. Email service logs
|
||||
4. Recipient email address validity
|
||||
|
||||
### HTML Not Rendering
|
||||
**Check:**
|
||||
1. Email client compatibility (Gmail, Outlook, etc.)
|
||||
2. Inline styles are present
|
||||
3. No JavaScript in templates
|
||||
|
||||
---
|
||||
|
||||
## 📞 Need Help?
|
||||
|
||||
For questions or issues:
|
||||
1. Check type definitions in `types.ts`
|
||||
2. Review helper functions in `helpers.ts`
|
||||
3. See examples in this guide
|
||||
4. Review `TEMPLATE_MAPPING.md` for detailed documentation
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** December 4, 2025
|
||||
**Version:** 2.0 (Template Literals)
|
||||
|
||||
127
src/emailtemplates/approvalConfirmation.template.ts
Normal file
127
src/emailtemplates/approvalConfirmation.template.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Approval Confirmation Email Template
|
||||
*/
|
||||
|
||||
import { ApprovalConfirmationData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string {
|
||||
const commentsSection = data.approverComments ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approver Comments:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #28a745; border-radius: 4px;">
|
||||
${wrapRichText(data.approverComments)}
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request Approved</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Approved',
|
||||
...HeaderStyles.success
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
Great news! Your request has been <strong style="color: #28a745;">approved</strong> by <strong>${data.approverName}</strong>.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #155724; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Approved By:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.approverName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Approved On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.approvalDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Time:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.approvalTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${commentsSection}
|
||||
|
||||
${getNextStepsSection(data.isFinalApproval, data.nextApproverName)}
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Thank you for using the ${data.companyName} Workflow System.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
132
src/emailtemplates/approvalRequest.template.ts
Normal file
132
src/emailtemplates/approvalRequest.template.ts
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Approval Request Email Template (Single Approver)
|
||||
*/
|
||||
|
||||
import { ApprovalRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>Approval Request</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Approval Request',
|
||||
...HeaderStyles.info
|
||||
}))}
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="email-content" style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #667eea;">${data.approverName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
<strong style="color: #333333;">${data.initiatorName}</strong> has submitted a request that requires your approval.
|
||||
</p>
|
||||
|
||||
<!-- 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">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Submitted On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Time:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Description (supports rich text HTML) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
|
||||
${wrapRichText(data.requestDescription)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Section (dynamic) -->
|
||||
${getPrioritySection(data.priority)}
|
||||
|
||||
<!-- View Details Button -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Click the button above to review and take action on this request.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
139
src/emailtemplates/approverSkipped.template.ts
Normal file
139
src/emailtemplates/approverSkipped.template.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Approver Skipped Email Template
|
||||
*/
|
||||
|
||||
import { ApproverSkippedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Approver Skipped</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Approval Level Skipped',
|
||||
...HeaderStyles.infoSecondary
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #17a2b8;">${data.recipientName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
An approver has been skipped in the following request by <strong>${data.skippedByName}</strong>. The workflow has moved to the next approval level.
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d1ecf1; border: 1px solid #bee5eb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #0c5460; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>Skipped Approver:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>${data.skippedApproverName}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>Skipped By:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
${data.skippedByName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>Skipped On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
${data.skippedDate} at ${data.skippedTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>Next Approver:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #0c5460; font-size: 14px;">
|
||||
<strong>${data.nextApproverName}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Skipping:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #17a2b8; border-radius: 4px;">
|
||||
${wrapRichText(data.skipReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">What This Means</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>The approval process has moved to the next level</li>
|
||||
<li>The skipped approver's action is no longer required</li>
|
||||
<li>All stakeholders have been notified of this change</li>
|
||||
<li>The workflow continues with remaining approvers</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
The workflow will continue with the next approver.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
108
src/emailtemplates/branding.config.ts
Normal file
108
src/emailtemplates/branding.config.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Email Branding Configuration
|
||||
*
|
||||
* Centralized configuration for email branding, logos, and company information
|
||||
* Customize this file to match your organization's branding
|
||||
*/
|
||||
|
||||
import { EmailHeaderConfig, EmailFooterConfig } from './helpers';
|
||||
|
||||
/**
|
||||
* Company Information
|
||||
*/
|
||||
export const CompanyInfo = {
|
||||
name: 'Royal Enfield',
|
||||
productName: 'RE Flow', // Product name displayed in header
|
||||
website: 'https://www.royalenfield.com',
|
||||
supportEmail: 'support@royalenfield.com',
|
||||
|
||||
// Logo configuration for email headers
|
||||
logo: {
|
||||
url: 'https://www.royalenfield.com/content/dam/RE-Platform-Revamp/re-revamp-commons/logo.webp',
|
||||
alt: 'Royal Enfield Logo',
|
||||
width: 220, // Logo width in pixels (wider for better visibility)
|
||||
height: 65, // Logo height in pixels (proportional ratio ~3.4:1)
|
||||
enabled: true // Logo enabled - displays directly on black background
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Email Footer Configuration
|
||||
* Used across all email templates
|
||||
*/
|
||||
export function getDefaultFooterConfig(): EmailFooterConfig {
|
||||
return {
|
||||
companyName: CompanyInfo.name,
|
||||
companyWebsite: CompanyInfo.website,
|
||||
supportEmail: CompanyInfo.supportEmail,
|
||||
additionalLinks: [
|
||||
{ text: 'Help Center', url: `${CompanyInfo.website}/help` },
|
||||
{ text: 'Privacy Policy', url: `${CompanyInfo.website}/privacy` },
|
||||
{ text: 'Terms of Service', url: `${CompanyInfo.website}/terms` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Email Header with Logo and Product Name (if enabled in branding config)
|
||||
*
|
||||
* Usage:
|
||||
* ${getEmailHeader(getBrandedHeader({
|
||||
* title: 'Request Approved',
|
||||
* ...HeaderStyles.success
|
||||
* }))}
|
||||
*/
|
||||
export function getBrandedHeader(config: Omit<EmailHeaderConfig, 'logoUrl' | 'logoAlt' | 'logoWidth' | 'logoHeight' | 'productName'>): EmailHeaderConfig {
|
||||
return {
|
||||
...config,
|
||||
productName: CompanyInfo.productName,
|
||||
...(CompanyInfo.logo.enabled && {
|
||||
logoUrl: CompanyInfo.logo.url,
|
||||
logoAlt: CompanyInfo.logo.alt,
|
||||
logoWidth: CompanyInfo.logo.width,
|
||||
logoHeight: CompanyInfo.logo.height
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Color Schemes (Optional)
|
||||
* Override default header colors for specific templates
|
||||
*/
|
||||
export const CustomHeaderStyles = {
|
||||
// You can override or add custom styles here
|
||||
// Example:
|
||||
// brandPrimary: {
|
||||
// gradientFrom: '#FF0000',
|
||||
// gradientTo: '#AA0000'
|
||||
// }
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate view details link for emails
|
||||
*
|
||||
* @param requestNumber - Request number (e.g., REQ-2025-12-0013)
|
||||
* @param frontendUrl - Frontend base URL (from process.env.FRONTEND_URL in your service)
|
||||
* @returns Full URL to request detail page
|
||||
*
|
||||
* Usage in email service:
|
||||
* const link = getViewDetailsLink('REQ-2025-12-0013', process.env.FRONTEND_URL);
|
||||
*
|
||||
* Result: https://workflow.royalenfield.com/request/REQ-2025-12-0013
|
||||
*/
|
||||
export function getViewDetailsLink(requestNumber: string, frontendUrl: string): string {
|
||||
return `${frontendUrl}/request/${requestNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: To use environment variables, import this in your service file
|
||||
* and override these values with process.env at runtime:
|
||||
*
|
||||
* Example in your email service:
|
||||
*
|
||||
* import { CompanyInfo, BaseURL } from './emailtemplates/branding.config';
|
||||
*
|
||||
* // Override with environment variables
|
||||
* CompanyInfo.name = process.env.COMPANY_NAME || CompanyInfo.name;
|
||||
* BaseURL = process.env.BASE_URL || BaseURL;
|
||||
*/
|
||||
218
src/emailtemplates/emailPreferences.helper.ts
Normal file
218
src/emailtemplates/emailPreferences.helper.ts
Normal file
@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Email Preferences Helper
|
||||
*
|
||||
* Handles admin-level and user-level email notification preferences
|
||||
* Logic: Email only sent if BOTH admin AND user have it enabled
|
||||
*/
|
||||
|
||||
import { User } from '@models/User';
|
||||
import { SYSTEM_CONFIG } from '../config/system.config';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Email notification types that can be controlled
|
||||
*/
|
||||
export enum EmailNotificationType {
|
||||
REQUEST_CREATED = 'request_created',
|
||||
APPROVAL_REQUEST = 'approval_request',
|
||||
REQUEST_APPROVED = 'request_approved',
|
||||
REQUEST_REJECTED = 'request_rejected',
|
||||
TAT_REMINDER = 'tat_reminder', // Generic TAT reminder (any threshold)
|
||||
TAT_BREACHED = 'tat_breached', // 100% breach
|
||||
WORKFLOW_RESUMED = 'workflow_resumed',
|
||||
REQUEST_CLOSED = 'request_closed',
|
||||
WORKFLOW_PAUSED = 'workflow_paused',
|
||||
PARTICIPANT_ADDED = 'participant_added',
|
||||
APPROVER_SKIPPED = 'approver_skipped'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email should be sent based on admin and user preferences
|
||||
*
|
||||
* @param userId - User ID to check preferences for
|
||||
* @param emailType - Type of email notification
|
||||
* @returns true if email should be sent, false otherwise
|
||||
*/
|
||||
export async function shouldSendEmail(
|
||||
userId: string,
|
||||
emailType: EmailNotificationType
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Step 1: Check admin-level configuration (System Config)
|
||||
const adminEmailEnabled = await isAdminEmailEnabled(emailType);
|
||||
|
||||
if (!adminEmailEnabled) {
|
||||
logger.info(`[Email] Admin disabled emails for ${emailType} - skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Check user-level preferences
|
||||
const userEmailEnabled = await isUserEmailEnabled(userId, emailType);
|
||||
|
||||
if (!userEmailEnabled) {
|
||||
logger.info(`[Email] User ${userId} disabled emails for ${emailType} - skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both admin AND user have enabled - send email
|
||||
logger.info(`[Email] Email enabled for user ${userId}, type: ${emailType}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`[Email] Error checking email preferences for ${userId}:`, error);
|
||||
// On error, default to NOT sending email (safe default)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if admin has enabled emails globally
|
||||
* Uses SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL
|
||||
*/
|
||||
async function isAdminEmailEnabled(emailType: EmailNotificationType): Promise<boolean> {
|
||||
// Check global email setting from system config
|
||||
const adminEmailEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_EMAIL;
|
||||
|
||||
if (!adminEmailEnabled) {
|
||||
logger.info('[Email] Admin has disabled email notifications globally');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has enabled emails
|
||||
* Uses existing User.emailNotificationsEnabled field
|
||||
*/
|
||||
async function isUserEmailEnabled(userId: string, emailType: EmailNotificationType): Promise<boolean> {
|
||||
try {
|
||||
// Fetch user and check emailNotificationsEnabled field
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: ['userId', 'emailNotificationsEnabled']
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[Email] User ${userId} not found - defaulting to enabled`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check user's global email notification setting
|
||||
const enabled = (user as any).emailNotificationsEnabled !== false;
|
||||
|
||||
if (!enabled) {
|
||||
logger.info(`[Email] User ${userId} has disabled email notifications globally`);
|
||||
}
|
||||
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
logger.warn('[Email] Error checking user email preference (defaulting to enabled):', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if in-app notification should be sent
|
||||
* Uses existing User.inAppNotificationsEnabled field
|
||||
*/
|
||||
export async function shouldSendInAppNotification(
|
||||
userId: string,
|
||||
notificationType: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Check admin config first (if SystemConfig model exists)
|
||||
const adminEnabled = await isAdminInAppEnabled(notificationType);
|
||||
|
||||
if (!adminEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch user and check inAppNotificationsEnabled field
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: ['userId', 'inAppNotificationsEnabled']
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`[Notification] User ${userId} not found - defaulting to enabled`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check user's global in-app notification setting
|
||||
const enabled = (user as any).inAppNotificationsEnabled !== false;
|
||||
|
||||
if (!enabled) {
|
||||
logger.info(`[Notification] User ${userId} has disabled in-app notifications globally`);
|
||||
}
|
||||
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
logger.warn('[Notification] Error checking in-app notification preference (defaulting to enabled):', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if admin has enabled in-app notifications
|
||||
* Uses SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP
|
||||
*/
|
||||
async function isAdminInAppEnabled(notificationType: string): Promise<boolean> {
|
||||
// Check global in-app setting from system config
|
||||
const adminInAppEnabled = SYSTEM_CONFIG.NOTIFICATIONS.ENABLE_IN_APP;
|
||||
|
||||
if (!adminInAppEnabled) {
|
||||
logger.info('[Notification] Admin has disabled in-app notifications globally');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch check for multiple users
|
||||
* Returns array of user IDs who should receive the email
|
||||
*/
|
||||
export async function filterUsersForEmail(
|
||||
userIds: string[],
|
||||
emailType: EmailNotificationType
|
||||
): Promise<string[]> {
|
||||
const enabledUsers: string[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
const shouldSend = await shouldSendEmail(userId, emailType);
|
||||
if (shouldSend) {
|
||||
enabledUsers.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return enabledUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Critical emails that should ALWAYS be sent (override preferences)
|
||||
* These are too important to be disabled
|
||||
*/
|
||||
export const CRITICAL_EMAILS = [
|
||||
EmailNotificationType.REQUEST_REJECTED,
|
||||
EmailNotificationType.TAT_BREACHED
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if email should be sent, with critical email override
|
||||
*/
|
||||
export async function shouldSendEmailWithOverride(
|
||||
userId: string,
|
||||
emailType: EmailNotificationType
|
||||
): Promise<boolean> {
|
||||
// Critical emails always sent (override user preference)
|
||||
if (CRITICAL_EMAILS.includes(emailType)) {
|
||||
const adminEnabled = await isAdminEmailEnabled(emailType);
|
||||
if (adminEnabled) {
|
||||
logger.info(`[Email] Critical email ${emailType} - sending despite user preference`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-critical emails - check both admin and user preferences
|
||||
return await shouldSendEmail(userId, emailType);
|
||||
}
|
||||
|
||||
613
src/emailtemplates/helpers.ts
Normal file
613
src/emailtemplates/helpers.ts
Normal file
@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Email Template Helper Functions
|
||||
*
|
||||
* Reusable functions for generating dynamic email sections
|
||||
*/
|
||||
|
||||
import { ApprovalChainItem } from './types';
|
||||
|
||||
/**
|
||||
* Get rich text styles for email-safe HTML rendering
|
||||
* Styles common HTML elements from rich text editors
|
||||
*/
|
||||
export function getRichTextStyles(): string {
|
||||
return `
|
||||
<style>
|
||||
/* Rich text content styles */
|
||||
.rich-text-content p {
|
||||
margin: 0 0 12px 0;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rich-text-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-content strong,
|
||||
.rich-text-content b {
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.rich-text-content em,
|
||||
.rich-text-content i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-content u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rich-text-content a {
|
||||
color: #667eea;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rich-text-content ul,
|
||||
.rich-text-content ol {
|
||||
margin: 0 0 12px 0;
|
||||
padding-left: 25px;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rich-text-content li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rich-text-content h1,
|
||||
.rich-text-content h2,
|
||||
.rich-text-content h3,
|
||||
.rich-text-content h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rich-text-content h1 { font-size: 20px; }
|
||||
.rich-text-content h2 { font-size: 18px; }
|
||||
.rich-text-content h3 { font-size: 16px; }
|
||||
.rich-text-content h4 { font-size: 14px; }
|
||||
|
||||
.rich-text-content blockquote {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 12px 15px;
|
||||
border-left: 3px solid #667eea;
|
||||
background-color: #f8f9fa;
|
||||
color: #666666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-content code {
|
||||
padding: 2px 6px;
|
||||
background-color: #f4f4f4;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.rich-text-content pre {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rich-text-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e9ecef;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.rich-text-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Mobile adjustments for rich text */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.rich-text-content p,
|
||||
.rich-text-content ul,
|
||||
.rich-text-content ol {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.rich-text-content h1 { font-size: 18px !important; }
|
||||
.rich-text-content h2 { font-size: 16px !important; }
|
||||
.rich-text-content h3 { font-size: 15px !important; }
|
||||
.rich-text-content h4 { font-size: 14px !important; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap rich text content with proper styling
|
||||
* Use this for descriptions and comments from rich text editors
|
||||
*/
|
||||
export function wrapRichText(htmlContent: string): string {
|
||||
return `
|
||||
<div class="rich-text-content" style="color: #666666; font-size: 14px; line-height: 1.6;">
|
||||
${htmlContent}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all email styles (responsive + rich text)
|
||||
* Optimized for screens up to 600px width
|
||||
*/
|
||||
export function getResponsiveStyles(): string {
|
||||
return `
|
||||
${getRichTextStyles()}
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse !important;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media only screen and (max-width: 600px) {
|
||||
/* Container adjustments */
|
||||
.email-container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Header adjustments */
|
||||
.email-header {
|
||||
padding: 25px 15px 30px !important;
|
||||
}
|
||||
|
||||
/* Content adjustments */
|
||||
.email-content {
|
||||
padding: 30px 20px !important;
|
||||
}
|
||||
|
||||
/* Footer adjustments */
|
||||
.email-footer {
|
||||
padding: 25px 20px !important;
|
||||
}
|
||||
|
||||
/* Logo responsive */
|
||||
.logo-img {
|
||||
width: 180px !important;
|
||||
height: auto !important;
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
/* Typography adjustments */
|
||||
.header-title {
|
||||
font-size: 20px !important;
|
||||
letter-spacing: 1px !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Detail tables */
|
||||
.detail-box {
|
||||
padding: 20px 15px !important;
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
font-size: 13px !important;
|
||||
padding: 6px 0 !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
/* Button adjustments */
|
||||
.cta-button {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding: 16px 20px !important;
|
||||
font-size: 16px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* Section adjustments */
|
||||
.info-section {
|
||||
padding: 15px !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
font-size: 13px !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.info-section ul {
|
||||
padding-left: 15px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (320px) */
|
||||
@media only screen and (max-width: 400px) {
|
||||
.logo-img {
|
||||
width: 150px !important;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
padding: 25px 15px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email header configuration types
|
||||
*/
|
||||
export interface EmailHeaderConfig {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
logoUrl?: string;
|
||||
logoAlt?: string;
|
||||
logoWidth?: number;
|
||||
logoHeight?: number;
|
||||
productName?: string; // e.g., "RE Flow"
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate professional email header with Royal Enfield branding
|
||||
* Black background with logo, product name, and gold accent line
|
||||
* Fully responsive for mobile devices
|
||||
*/
|
||||
export function getEmailHeader(config: EmailHeaderConfig): string {
|
||||
const logoWidth = config.logoWidth || 220;
|
||||
const logoHeight = config.logoHeight || 65;
|
||||
|
||||
const logoSection = config.logoUrl ? `
|
||||
<div style="margin-bottom: ${config.productName ? '15px' : '20px'};">
|
||||
<img
|
||||
src="${config.logoUrl}"
|
||||
alt="${config.logoAlt || 'Company Logo'}"
|
||||
class="logo-img"
|
||||
style="width: ${logoWidth}px; height: auto; max-width: 100%; display: inline-block;"
|
||||
/>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const productNameSection = config.productName ? `
|
||||
<p style="margin: 0 0 20px; color: #DEB219; font-size: 16px; font-weight: 600; letter-spacing: 2px; text-transform: uppercase;">
|
||||
${config.productName}
|
||||
</p>
|
||||
` : '';
|
||||
|
||||
const dividerLine = `
|
||||
<div style="width: 100%; height: 3px; background-color: #DEB219; margin: ${config.logoUrl || config.productName ? '20px 0 25px' : '0 0 25px'};"></div>
|
||||
`;
|
||||
|
||||
const subtitleSection = config.subtitle ? `
|
||||
<p class="header-subtitle" style="margin: 12px 0 0; color: #DEB219; font-size: 14px; font-weight: 500; letter-spacing: 0.5px; text-transform: uppercase;">${config.subtitle}</p>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="email-header" style="background-color: #1a1a1a; padding: ${config.logoUrl || config.productName ? '30px 30px 35px' : '35px 30px'}; text-align: center; border-radius: 8px 8px 0 0;">
|
||||
${logoSection}
|
||||
${productNameSection}
|
||||
${dividerLine}
|
||||
<h1 class="header-title" style="margin: 0; color: #ffffff; font-size: 26px; font-weight: 600; letter-spacing: 1.5px; line-height: 1.3; text-transform: uppercase;">${config.title}</h1>
|
||||
${subtitleSection}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Royal Enfield Brand Colors (Official)
|
||||
*/
|
||||
export const BrandColors = {
|
||||
red: '#DB281B', // Royal Enfield Red (Official)
|
||||
black: '#1a1a1a', // Royal Enfield Black
|
||||
gold: '#DEB219', // Royal Enfield Gold
|
||||
darkRed: '#A51F16', // Darker red for accents
|
||||
darkGold: '#C89F16', // Darker gold for accents
|
||||
white: '#ffffff' // White for text
|
||||
};
|
||||
|
||||
/**
|
||||
* Predefined header styles using Royal Enfield brand colors
|
||||
*/
|
||||
export const HeaderStyles = {
|
||||
// Primary - Red & Black (Main brand colors)
|
||||
primary: {
|
||||
gradientFrom: BrandColors.red,
|
||||
gradientTo: BrandColors.darkRed
|
||||
},
|
||||
// Information/Neutral - Black & Gold accent
|
||||
info: {
|
||||
gradientFrom: BrandColors.black,
|
||||
gradientTo: '#2d2d2d'
|
||||
},
|
||||
// Success - Black with gold accent
|
||||
success: {
|
||||
gradientFrom: BrandColors.black,
|
||||
gradientTo: '#2d2d2d'
|
||||
},
|
||||
// Error/Critical - Red (Brand red)
|
||||
error: {
|
||||
gradientFrom: BrandColors.red,
|
||||
gradientTo: BrandColors.darkRed
|
||||
},
|
||||
// Warning - Gold
|
||||
warning: {
|
||||
gradientFrom: BrandColors.gold,
|
||||
gradientTo: BrandColors.darkGold
|
||||
},
|
||||
// Neutral - Black
|
||||
neutral: {
|
||||
gradientFrom: BrandColors.black,
|
||||
gradientTo: '#2d2d2d'
|
||||
},
|
||||
// Info Secondary - Dark grey
|
||||
infoSecondary: {
|
||||
gradientFrom: '#424242',
|
||||
gradientTo: '#1a1a1a'
|
||||
},
|
||||
// Complete - Black with gold
|
||||
complete: {
|
||||
gradientFrom: BrandColors.black,
|
||||
gradientTo: '#2d2d2d'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate priority alert section
|
||||
*/
|
||||
export function getPrioritySection(priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'): string {
|
||||
if (priority === 'HIGH' || priority === 'CRITICAL') {
|
||||
return `
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">High Priority</h3>
|
||||
<p style="margin: 0; color: #856404; font-size: 14px; line-height: 1.6;">
|
||||
This request has been marked as ${priority} priority and requires prompt attention.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate approval chain visualization
|
||||
*/
|
||||
export function getApprovalChain(approvers: ApprovalChainItem[]): string {
|
||||
return approvers.map(approver => {
|
||||
let icon = '';
|
||||
let textColor = '#333333';
|
||||
let status = '';
|
||||
|
||||
switch (approver.status) {
|
||||
case 'approved':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #28a745; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">✓</span>`;
|
||||
status = approver.date ? `Approved on ${approver.date}` : 'Approved';
|
||||
break;
|
||||
case 'current':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #667eea; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
|
||||
textColor = '#667eea';
|
||||
status = 'Pending (Your Turn)';
|
||||
break;
|
||||
case 'pending':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #ffc107; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
|
||||
status = 'Pending';
|
||||
break;
|
||||
case 'awaiting':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #cccccc; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
|
||||
textColor = '#999999';
|
||||
status = 'Awaiting';
|
||||
break;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="padding: 10px 0; border-bottom: 1px solid #e9ecef;">
|
||||
${icon}
|
||||
<strong style="color: ${textColor};">${approver.name}</strong> - ${status}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next steps section for approval confirmation
|
||||
*/
|
||||
export function getNextStepsSection(isFinalApproval: boolean, nextApproverName?: string): string {
|
||||
if (isFinalApproval) {
|
||||
return `
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
|
||||
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.6;">
|
||||
All approvals are complete! Please review the request and add any conclusion remarks before closing it.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
} else if (nextApproverName) {
|
||||
return `
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
|
||||
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.6;">
|
||||
The request has been forwarded to <strong>${nextApproverName}</strong> for the next level of approval.
|
||||
You'll be notified when the request progresses.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate permissions section for participant added
|
||||
*/
|
||||
export function getPermissionsContent(role: 'Approver' | 'Spectator'): string {
|
||||
if (role === 'Approver') {
|
||||
return `
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>Review request details and documents</li>
|
||||
<li>Approve or reject the request</li>
|
||||
<li>Add comments and work notes</li>
|
||||
<li>View approval workflow and history</li>
|
||||
<li>Receive real-time notifications</li>
|
||||
</ul>
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>View request details and documents</li>
|
||||
<li>Add comments and work notes</li>
|
||||
<li>View approval workflow progress</li>
|
||||
<li>Receive status update notifications</li>
|
||||
<li>Cannot approve or reject the request</li>
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate conclusion section for closed requests (supports rich text)
|
||||
*/
|
||||
export function getConclusionSection(conclusionRemark?: string): string {
|
||||
if (conclusionRemark) {
|
||||
return `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Conclusion Remarks:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6f42c1; border-radius: 4px;">
|
||||
${wrapRichText(conclusionRemark)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate role description for participant added
|
||||
*/
|
||||
export function getRoleDescription(role: 'Approver' | 'Spectator'): string {
|
||||
if (role === 'Approver') {
|
||||
return 'You can now review and take action on this request.';
|
||||
} else {
|
||||
return 'You can now view this request and participate in discussions.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate action required section for workflow resumed
|
||||
*/
|
||||
export function getActionRequiredSection(isApprover: boolean): string {
|
||||
if (isApprover) {
|
||||
return `
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">Action Required</h3>
|
||||
<p style="margin: 0; color: #856404; font-size: 14px; line-height: 1.6;">
|
||||
This request requires your immediate attention. Please review and take action to keep the workflow moving forward.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Common email footer with optional branding
|
||||
*/
|
||||
export interface EmailFooterConfig {
|
||||
companyName: string;
|
||||
companyWebsite?: string;
|
||||
supportEmail?: string;
|
||||
additionalLinks?: Array<{ text: string; url: string }>;
|
||||
}
|
||||
|
||||
export function getEmailFooter(config: EmailFooterConfig | string): string {
|
||||
// Backward compatibility - if string is passed, use it as companyName
|
||||
const footerConfig: EmailFooterConfig = typeof config === 'string'
|
||||
? { companyName: config }
|
||||
: config;
|
||||
|
||||
const supportSection = footerConfig.supportEmail ? `
|
||||
<p style="margin: 10px 0 0; color: #666666; font-size: 13px;">
|
||||
Need help? Contact us at <a href="mailto:${footerConfig.supportEmail}" style="color: #667eea; text-decoration: none;">${footerConfig.supportEmail}</a>
|
||||
</p>
|
||||
` : '';
|
||||
|
||||
const linksSection = footerConfig.additionalLinks && footerConfig.additionalLinks.length > 0 ? `
|
||||
<p style="margin: 15px 0 0; color: #999999; font-size: 12px;">
|
||||
${footerConfig.additionalLinks.map(link =>
|
||||
`<a href="${link.url}" style="color: #667eea; text-decoration: none; margin: 0 10px;">${link.text}</a>`
|
||||
).join(' | ')}
|
||||
</p>
|
||||
` : '';
|
||||
|
||||
const companyLink = footerConfig.companyWebsite
|
||||
? `<a href="${footerConfig.companyWebsite}" style="color: #999999; text-decoration: none;">${footerConfig.companyName}</a>`
|
||||
: footerConfig.companyName;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding: 30px; background-color: #f8f9fa; border-radius: 0 0 8px 8px; text-align: center; border-top: 1px solid #e9ecef;">
|
||||
<p style="margin: 0 0 10px; color: #666666; font-size: 13px; line-height: 1.6;">
|
||||
This is an automated notification. Please do not reply to this email.
|
||||
</p>
|
||||
${supportSection}
|
||||
${linksSection}
|
||||
<p style="margin: ${linksSection ? '15px' : '10px'} 0 0; color: #999999; font-size: 12px;">
|
||||
© 2025 ${companyLink}. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
30
src/emailtemplates/index.ts
Normal file
30
src/emailtemplates/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Email Templates - Central Export
|
||||
*
|
||||
* Import all email template functions from individual files
|
||||
* and export them for easy access throughout the application
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export * from './types';
|
||||
|
||||
// Export helpers
|
||||
export * from './helpers';
|
||||
|
||||
// Export branding configuration
|
||||
export * from './branding.config';
|
||||
|
||||
// Export all template functions
|
||||
export { getRequestCreatedEmail } from './requestCreated.template';
|
||||
export { getApprovalRequestEmail } from './approvalRequest.template';
|
||||
export { getMultiApproverRequestEmail } from './multiApproverRequest.template';
|
||||
export { getApprovalConfirmationEmail } from './approvalConfirmation.template';
|
||||
export { getRejectionNotificationEmail } from './rejectionNotification.template';
|
||||
export { getTATReminderEmail } from './tatReminder.template';
|
||||
export { getTATBreachedEmail } from './tatBreached.template';
|
||||
export { getWorkflowPausedEmail } from './workflowPaused.template';
|
||||
export { getWorkflowResumedEmail } from './workflowResumed.template';
|
||||
export { getParticipantAddedEmail } from './participantAdded.template';
|
||||
export { getApproverSkippedEmail } from './approverSkipped.template';
|
||||
export { getRequestClosedEmail } from './requestClosed.template';
|
||||
|
||||
143
src/emailtemplates/multiApproverRequest.template.ts
Normal file
143
src/emailtemplates/multiApproverRequest.template.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Multi-Approver Request Email Template
|
||||
*/
|
||||
|
||||
import { MultiApproverRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>Multi-Level Approval Request</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Multi-Level Approval Request',
|
||||
...HeaderStyles.info
|
||||
}))}
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #667eea;">${data.approverName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
<strong style="color: #333333;">${data.initiatorName}</strong> has submitted a request that requires approval from multiple approvers. Your review and approval are needed to proceed.
|
||||
</p>
|
||||
|
||||
<!-- Request Details Box with Level Info -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Your Level:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
Approver ${data.approverLevel} of ${data.totalApprovers}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Approval Chain -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approval Chain:</h3>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
${getApprovalChain(data.approversList)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Description (supports rich text HTML) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
|
||||
${wrapRichText(data.requestDescription)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Section (dynamic) -->
|
||||
${getPrioritySection(data.priority)}
|
||||
|
||||
<!-- Sequential Approval Note -->
|
||||
<div style="padding: 15px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<p style="margin: 0; color: #856404; font-size: 13px; line-height: 1.6;">
|
||||
<strong>Note:</strong> This request requires approval from all designated approvers. The process will continue to the next approver only after you approve.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- View Details Button -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Click the button above to review and take action on this request.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
142
src/emailtemplates/participantAdded.template.ts
Normal file
142
src/emailtemplates/participantAdded.template.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Participant Added Email Template
|
||||
*/
|
||||
|
||||
import { ParticipantAddedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Added to Request</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: `You've Been Added as ${data.participantRole}`,
|
||||
...HeaderStyles.info
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #667eea;">${data.participantName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
You have been added as <strong>${data.participantRole}</strong> to the following request by <strong>${data.addedByName}</strong>. ${getRoleDescription(data.participantRole)}
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Current Status:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.currentStatus}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Your Role:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<strong>${data.participantRole}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Added On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.addedDate} at ${data.addedTime}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
|
||||
${wrapRichText(data.requestDescription)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Your Permissions</h3>
|
||||
${getPermissionsContent(data.participantRole)}
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
You can now access this request and participate in discussions.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
75
src/emailtemplates/quick-preview.ts
Normal file
75
src/emailtemplates/quick-preview.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Quick Email Preview Generator
|
||||
*
|
||||
* Generates preview URLs immediately without creating real requests
|
||||
*/
|
||||
|
||||
import { emailNotificationService } from '../services/emailNotification.service';
|
||||
|
||||
async function generatePreviews() {
|
||||
console.log('\n' + '═'.repeat(80));
|
||||
console.log(' 📧 EMAIL PREVIEW GENERATOR');
|
||||
console.log('═'.repeat(80) + '\n');
|
||||
|
||||
// Sample data
|
||||
const initiator = {
|
||||
userId: 'user-1',
|
||||
email: 'john.doe@royalenfield.com',
|
||||
displayName: 'John Doe'
|
||||
};
|
||||
|
||||
const approver = {
|
||||
userId: 'user-2',
|
||||
email: 'jane.smith@royalenfield.com',
|
||||
displayName: 'Jane Smith'
|
||||
};
|
||||
|
||||
const requestData = {
|
||||
requestNumber: 'REQ-2025-12-0013',
|
||||
title: 'Equipment Purchase Request',
|
||||
description: '<p>Need approval for <strong>new equipment</strong></p>',
|
||||
requestType: 'Purchase',
|
||||
priority: 'HIGH',
|
||||
createdAt: new Date(),
|
||||
tatHours: 48,
|
||||
totalApprovers: 1
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Generating email previews...\n');
|
||||
|
||||
// 1. Request Created
|
||||
console.log('1️⃣ Request Created Email...');
|
||||
await emailNotificationService.sendRequestCreated(
|
||||
requestData,
|
||||
initiator,
|
||||
approver
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// 2. Approval Request
|
||||
console.log('2️⃣ Approval Request Email...');
|
||||
await emailNotificationService.sendApprovalRequest(
|
||||
requestData,
|
||||
approver,
|
||||
initiator,
|
||||
false
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('✅ Preview URLs generated above!');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('\n💡 Click the preview URLs to see the emails\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
generatePreviews()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
|
||||
131
src/emailtemplates/rejectionNotification.template.ts
Normal file
131
src/emailtemplates/rejectionNotification.template.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Rejection Notification Email Template
|
||||
*/
|
||||
|
||||
import { RejectionNotificationData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getRejectionNotificationEmail(data: RejectionNotificationData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request Rejected</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Rejected',
|
||||
...HeaderStyles.error
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #dc3545;">${data.initiatorName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
We regret to inform you that your request has been <strong style="color: #dc3545;">rejected</strong> by <strong>${data.approverName}</strong>.
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #721c24; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Rejected By:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.approverName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Rejected On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.rejectionDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Time:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.rejectionTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px;">
|
||||
${wrapRichText(data.rejectionReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Can Do:</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">
|
||||
<li>Review the rejection reason carefully</li>
|
||||
<li>Make necessary adjustments to your request</li>
|
||||
<li>Submit a new request with the required changes</li>
|
||||
<li>Contact ${data.approverName} for more clarification if needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
If you have any questions, please don't hesitate to reach out.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
145
src/emailtemplates/requestClosed.template.ts
Normal file
145
src/emailtemplates/requestClosed.template.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Request Closed Email Template
|
||||
*/
|
||||
|
||||
import { RequestClosedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getRequestClosedEmail(data: RequestClosedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 Closed</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Closed',
|
||||
...HeaderStyles.complete
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #6f42c1;">${data.recipientName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
The following request has been successfully <strong>closed</strong> by the initiator. All approvals have been completed and the workflow is now complete.
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Created On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.createdDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Closed On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<strong>${data.closedDate} at ${data.closedTime}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Total Duration:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.totalDuration}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Final Status:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<strong style="color: #28a745;">Closed</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${getConclusionSection(data.conclusionRemark)}
|
||||
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Workflow Statistics</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>Total Approvers: ${data.totalApprovers}</li>
|
||||
<li>Total Approvals: ${data.totalApprovals}</li>
|
||||
<li>Work Notes: ${data.workNotesCount}</li>
|
||||
<li>Documents Attached: ${data.documentsCount}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Thank you for your participation in this workflow.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
140
src/emailtemplates/requestCreated.template.ts
Normal file
140
src/emailtemplates/requestCreated.template.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Request Created Email Template
|
||||
*/
|
||||
|
||||
import { RequestCreatedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 Created Successfully</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Created Successfully',
|
||||
...HeaderStyles.info
|
||||
}))}
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="email-content" style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #667eea;">${data.initiatorName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
Your request has been successfully created and submitted for approval. It has been assigned to <strong>${data.firstApproverName}</strong> for review.
|
||||
</p>
|
||||
|
||||
<!-- 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">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Priority:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.priority}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Created On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestDate} at ${data.requestTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Total Approvers:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.totalApprovers}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- What Happens Next -->
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">What Happens Next?</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>Your request is now with the first approver for review</li>
|
||||
<li>You'll receive notifications as approvers take action</li>
|
||||
<li>You can track the status and add comments anytime</li>
|
||||
<li>Expected TAT: ${data.expectedTAT} hours</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- View Details Button -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Thank you for using the ${data.companyName} Workflow System.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
148
src/emailtemplates/tatBreached.template.ts
Normal file
148
src/emailtemplates/tatBreached.template.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* TAT Breached Email Template
|
||||
*/
|
||||
|
||||
import { TATBreachedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getTATBreachedEmail(data: TATBreachedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TAT Breached - Urgent Action Required</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'TAT Breached',
|
||||
subtitle: 'Immediate Action Required',
|
||||
...HeaderStyles.error
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #dc3545;">${data.approverName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
The TAT (Turn Around Time) for the following request has been <strong style="color: #dc3545;">BREACHED</strong>. This request requires your immediate attention.
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #721c24; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Priority:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.priority}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Assigned On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.assignedDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>TAT Deadline:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
${data.tatDeadline}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>Time Overdue:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #721c24; font-size: 14px;">
|
||||
<strong>${data.timeOverdue}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="padding: 20px; background-color: #f8d7da; border-left: 4px solid #dc3545; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #721c24; font-size: 16px; font-weight: 600;">Critical Alert</h3>
|
||||
<p style="margin: 0; color: #721c24; font-size: 14px; line-height: 1.6;">
|
||||
The TAT for this request has expired. Please take immediate action to avoid further delays. Management and the initiator have been notified of this breach.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">Impact Notice</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">
|
||||
<li>This breach is being tracked in system metrics</li>
|
||||
<li>The workflow is experiencing delays</li>
|
||||
<li>Stakeholders are awaiting your decision</li>
|
||||
<li>Your manager may have been notified</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Your immediate action is critical to resolve this delay.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
164
src/emailtemplates/tatReminder.template.ts
Normal file
164
src/emailtemplates/tatReminder.template.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* TAT Reminder Email Template
|
||||
*/
|
||||
|
||||
import { TATReminderData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
/**
|
||||
* Get urgency styling based on threshold percentage
|
||||
*/
|
||||
function getUrgencyStyle(threshold: number) {
|
||||
if (threshold >= 75) {
|
||||
return {
|
||||
bgColor: '#fff3cd',
|
||||
borderColor: '#ffc107',
|
||||
textColor: '#856404',
|
||||
title: 'Urgent - Action Required'
|
||||
};
|
||||
} else if (threshold >= 50) {
|
||||
return {
|
||||
bgColor: '#e7f3ff',
|
||||
borderColor: '#0066cc',
|
||||
textColor: '#004085',
|
||||
title: 'Action Required Soon'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
bgColor: '#e7f3ff',
|
||||
borderColor: '#0066cc',
|
||||
textColor: '#004085',
|
||||
title: 'Early Warning'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getTATReminderEmail(data: TATReminderData): string {
|
||||
const urgencyStyle = getUrgencyStyle(data.thresholdPercentage);
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 Reminder - Action Required</title>
|
||||
${getResponsiveStyles()}
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'TAT Reminder',
|
||||
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
|
||||
...HeaderStyles.warning
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td class="email-content" style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #ff9800;">${data.approverName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
This is a ${data.urgencyLevel === 'high' ? 'urgent' : 'friendly'} reminder that you have a pending request that requires your attention. <strong>${data.thresholdPercentage}%</strong> of the TAT has elapsed.
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #fff8e1; border: 1px solid #ffc107; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #f57c00; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>Assigned On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
${data.assignedDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>TAT Deadline:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>${data.tatDeadline}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>Time Remaining:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #f57c00; font-size: 14px;">
|
||||
<strong>${data.timeRemaining}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Dynamic Urgency Note based on threshold -->
|
||||
<div style="padding: 20px; background-color: ${urgencyStyle.bgColor}; border-left: 4px solid ${urgencyStyle.borderColor}; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: ${urgencyStyle.textColor}; font-size: 16px; font-weight: 600;">${urgencyStyle.title}</h3>
|
||||
<p style="margin: 0; color: ${urgencyStyle.textColor}; font-size: 14px; line-height: 1.6;">
|
||||
${data.thresholdPercentage}% of the TAT has elapsed. Please review and take action on this request ${data.thresholdPercentage >= 75 ? 'immediately' : 'at your earliest convenience'} to avoid TAT breach. The initiator and other stakeholders are waiting for your decision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Your prompt action is greatly appreciated.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
159
src/emailtemplates/test-email.ts
Normal file
159
src/emailtemplates/test-email.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Email Template Testing Script
|
||||
*
|
||||
* Test email templates with nodemailer test account
|
||||
* Preview URL will be shown in console
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
getRequestCreatedEmail,
|
||||
getApprovalRequestEmail,
|
||||
getMultiApproverRequestEmail,
|
||||
RequestCreatedData,
|
||||
ApprovalRequestData,
|
||||
MultiApproverRequestData
|
||||
} from './index';
|
||||
|
||||
/**
|
||||
* Send test email and get preview URL
|
||||
*/
|
||||
async function sendTestEmail() {
|
||||
try {
|
||||
console.log('🚀 Creating nodemailer test account...');
|
||||
|
||||
// Create a test account automatically (free, no signup needed)
|
||||
const testAccount = await nodemailer.createTestAccount();
|
||||
|
||||
console.log('✅ Test account created:', testAccount.user);
|
||||
|
||||
// Create transporter with test SMTP details
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: testAccount.smtp.host,
|
||||
port: testAccount.smtp.port,
|
||||
secure: testAccount.smtp.secure,
|
||||
auth: {
|
||||
user: testAccount.user,
|
||||
pass: testAccount.pass
|
||||
}
|
||||
});
|
||||
|
||||
// Sample data for Request Created email
|
||||
const requestCreatedData: RequestCreatedData = {
|
||||
recipientName: 'John Doe',
|
||||
requestId: 'REQ-2025-12-0013',
|
||||
requestTitle: 'New Equipment Purchase Request',
|
||||
initiatorName: 'John Doe',
|
||||
firstApproverName: 'Jane Smith',
|
||||
requestType: 'Purchase',
|
||||
priority: 'HIGH',
|
||||
requestDate: 'Dec 04, 2025',
|
||||
requestTime: '02:30 PM',
|
||||
totalApprovers: 3,
|
||||
expectedTAT: 48,
|
||||
viewDetailsLink: 'http://localhost:3000/request/REQ-2025-12-0013',
|
||||
companyName: 'Royal Enfield'
|
||||
};
|
||||
|
||||
// Sample data for Approval Request email
|
||||
const approvalRequestData: ApprovalRequestData = {
|
||||
recipientName: 'Jane Smith',
|
||||
requestId: 'REQ-2025-12-0013',
|
||||
approverName: 'Jane Smith',
|
||||
initiatorName: 'John Doe',
|
||||
requestType: 'Purchase',
|
||||
requestDescription: 'Requesting approval for new equipment purchase worth $5,000. This includes 2 new motorcycles for the testing department.',
|
||||
priority: 'HIGH',
|
||||
requestDate: 'Dec 04, 2025',
|
||||
requestTime: '02:30 PM',
|
||||
viewDetailsLink: 'http://localhost:3000/request/REQ-2025-12-0013',
|
||||
companyName: 'Royal Enfield'
|
||||
};
|
||||
|
||||
// Sample data for Multi-Approver Request email
|
||||
const multiApproverData: MultiApproverRequestData = {
|
||||
...approvalRequestData,
|
||||
approverLevel: 2,
|
||||
totalApprovers: 3,
|
||||
approversList: [
|
||||
{
|
||||
name: 'Sarah Johnson',
|
||||
status: 'approved',
|
||||
date: 'Dec 03, 2025',
|
||||
levelNumber: 1
|
||||
},
|
||||
{
|
||||
name: 'Jane Smith',
|
||||
status: 'current',
|
||||
levelNumber: 2
|
||||
},
|
||||
{
|
||||
name: 'Michael Brown',
|
||||
status: 'awaiting',
|
||||
levelNumber: 3
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
console.log('\n📧 Sending test emails...\n');
|
||||
|
||||
// Test 1: Request Created Email
|
||||
const html1 = getRequestCreatedEmail(requestCreatedData);
|
||||
const info1 = await transporter.sendMail({
|
||||
from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
|
||||
to: 'initiator@example.com',
|
||||
subject: '[REQ-2025-12-0013] Request Created Successfully',
|
||||
html: html1
|
||||
});
|
||||
|
||||
console.log('✅ Test 1: Request Created Email');
|
||||
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info1));
|
||||
console.log('');
|
||||
|
||||
// Test 2: Approval Request Email (Single)
|
||||
const html2 = getApprovalRequestEmail(approvalRequestData);
|
||||
const info2 = await transporter.sendMail({
|
||||
from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
|
||||
to: 'approver@example.com',
|
||||
subject: '[REQ-2025-12-0013] Approval Request - Action Required',
|
||||
html: html2
|
||||
});
|
||||
|
||||
console.log('✅ Test 2: Approval Request Email');
|
||||
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info2));
|
||||
console.log('');
|
||||
|
||||
// Test 3: Multi-Approver Request Email
|
||||
const html3 = getMultiApproverRequestEmail(multiApproverData);
|
||||
const info3 = await transporter.sendMail({
|
||||
from: '"Royal Enfield Workflow" <noreply@royalenfield.com>',
|
||||
to: 'approver-level2@example.com',
|
||||
subject: '[REQ-2025-12-0013] Multi-Level Approval Request - Your Turn',
|
||||
html: html3
|
||||
});
|
||||
|
||||
console.log('✅ Test 3: Multi-Approver Request Email');
|
||||
console.log(' Preview URL:', nodemailer.getTestMessageUrl(info3));
|
||||
console.log('');
|
||||
|
||||
console.log('🎉 All test emails sent successfully!');
|
||||
console.log('\n💡 Click the Preview URLs above to view the emails in your browser');
|
||||
console.log('📌 These are real previews hosted by Ethereal Email (nodemailer test service)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error sending test email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
sendTestEmail()
|
||||
.then(() => {
|
||||
console.log('\n✅ Test completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
255
src/emailtemplates/test-real-scenario.ts
Normal file
255
src/emailtemplates/test-real-scenario.ts
Normal file
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Real Scenario Email Test
|
||||
*
|
||||
* Test emails with realistic workflow data
|
||||
* Simulates: User 10 creates request, User 12 is approver
|
||||
*/
|
||||
|
||||
import { emailNotificationService } from '../services/emailNotification.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Simulate real workflow scenario
|
||||
*/
|
||||
async function testRealScenario() {
|
||||
console.log('🧪 Testing Real Workflow Scenario\n');
|
||||
console.log('Scenario: User 10 creates request, assigns to User 12 as approver\n');
|
||||
|
||||
// Mock user data (simulating real database records)
|
||||
const user10 = {
|
||||
userId: 'user-10-uuid',
|
||||
email: 'john.doe@royalenfield.com',
|
||||
displayName: 'John Doe',
|
||||
department: 'Engineering',
|
||||
designation: 'Senior Engineer'
|
||||
};
|
||||
|
||||
const user12 = {
|
||||
userId: 'user-12-uuid',
|
||||
email: 'jane.smith@royalenfield.com',
|
||||
displayName: 'Jane Smith',
|
||||
department: 'Management',
|
||||
designation: 'Engineering Manager',
|
||||
levelNumber: 1
|
||||
};
|
||||
|
||||
// Mock request data (simulating real workflow request)
|
||||
const requestData = {
|
||||
requestId: 'req-uuid-12345',
|
||||
requestNumber: 'REQ-2025-12-0013',
|
||||
title: 'New Equipment Purchase Request - Testing Department',
|
||||
description: `
|
||||
<h3>Equipment Purchase Request</h3>
|
||||
<p>Requesting approval for the following equipment for the <strong>testing department</strong>:</p>
|
||||
<ul>
|
||||
<li><strong>2× Royal Enfield Himalayan</strong> (Latest 2025 model)</li>
|
||||
<li><strong>Safety gear package</strong> - Helmets, jackets, gloves</li>
|
||||
<li><strong>Maintenance toolkit</strong> - Standard service equipment</li>
|
||||
</ul>
|
||||
<h4>Budget Breakdown</h4>
|
||||
<p>Total estimated cost: <strong>₹15,00,000</strong></p>
|
||||
<blockquote>
|
||||
This purchase is critical for our Q1 2025 testing schedule and has been pre-approved by the department head.
|
||||
</blockquote>
|
||||
<p>Please review and approve at your earliest convenience.</p>
|
||||
<p>For questions, contact: <a href="mailto:john.doe@royalenfield.com">john.doe@royalenfield.com</a></p>
|
||||
`,
|
||||
requestType: 'Purchase',
|
||||
priority: 'HIGH',
|
||||
createdAt: new Date(),
|
||||
tatHours: 48,
|
||||
totalApprovers: 3,
|
||||
initiatorName: user10.displayName,
|
||||
initiatorId: user10.userId
|
||||
};
|
||||
|
||||
// Mock approval chain for multi-level scenario
|
||||
const approvalChain = [
|
||||
{
|
||||
levelNumber: 1,
|
||||
approverName: 'Jane Smith',
|
||||
approverEmail: 'jane.smith@royalenfield.com',
|
||||
status: 'PENDING',
|
||||
approvedAt: null
|
||||
},
|
||||
{
|
||||
levelNumber: 2,
|
||||
approverName: 'Michael Brown',
|
||||
approverEmail: 'michael.brown@royalenfield.com',
|
||||
status: 'PENDING',
|
||||
approvedAt: null
|
||||
},
|
||||
{
|
||||
levelNumber: 3,
|
||||
approverName: 'Sarah Johnson',
|
||||
approverEmail: 'sarah.johnson@royalenfield.com',
|
||||
status: 'PENDING',
|
||||
approvedAt: null
|
||||
}
|
||||
];
|
||||
|
||||
try {
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 1: Request Created Email (to Initiator - User 10)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
await emailNotificationService.sendRequestCreated(
|
||||
requestData,
|
||||
user10,
|
||||
user12
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 2: Multi-Level Approval Request Email (to Approver - User 12)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
await emailNotificationService.sendApprovalRequest(
|
||||
requestData,
|
||||
user12,
|
||||
user10,
|
||||
true, // isMultiLevel
|
||||
approvalChain
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 3: TAT Reminder at 40% (Configurable Threshold)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
await emailNotificationService.sendTATReminder(
|
||||
requestData,
|
||||
user12,
|
||||
{
|
||||
thresholdPercentage: 40,
|
||||
timeRemaining: '28 hours',
|
||||
tatDeadline: new Date(Date.now() + 28 * 60 * 60 * 1000),
|
||||
assignedDate: requestData.createdAt
|
||||
}
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 4: TAT Reminder at 80% (Configurable Threshold)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
await emailNotificationService.sendTATReminder(
|
||||
requestData,
|
||||
user12,
|
||||
{
|
||||
thresholdPercentage: 80,
|
||||
timeRemaining: '9 hours',
|
||||
tatDeadline: new Date(Date.now() + 9 * 60 * 60 * 1000),
|
||||
assignedDate: requestData.createdAt
|
||||
}
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 5: Approval Confirmation (User 12 approved)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
const approvedUser12 = {
|
||||
...user12,
|
||||
approvedAt: new Date(),
|
||||
comments: `
|
||||
<p>Approved! The equipment purchase is justified for the testing schedule.</p>
|
||||
<p><strong>Conditions:</strong></p>
|
||||
<ul>
|
||||
<li>Purchase must be completed by January 15, 2025</li>
|
||||
<li>Get quotes from at least 2 vendors</li>
|
||||
<li>Include extended warranty</li>
|
||||
</ul>
|
||||
`
|
||||
};
|
||||
|
||||
await emailNotificationService.sendApprovalConfirmation(
|
||||
requestData,
|
||||
approvedUser12,
|
||||
user10,
|
||||
false, // not final approval
|
||||
{ displayName: 'Michael Brown', email: 'michael.brown@royalenfield.com' }
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 6: TAT Breached (100%)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
await emailNotificationService.sendTATBreached(
|
||||
requestData,
|
||||
user12,
|
||||
{
|
||||
timeOverdue: '6 hours overdue',
|
||||
tatDeadline: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
assignedDate: requestData.createdAt
|
||||
}
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('─'.repeat(80));
|
||||
console.log('📧 Test 7: Request Closed (with conclusion)');
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
const closedRequestData = {
|
||||
...requestData,
|
||||
closedAt: new Date(),
|
||||
totalApprovals: 3
|
||||
};
|
||||
|
||||
await emailNotificationService.sendRequestClosed(
|
||||
closedRequestData,
|
||||
user10,
|
||||
{
|
||||
conclusionRemark: `
|
||||
<h3>Project Successfully Completed</h3>
|
||||
<p>All equipment has been purchased and delivered to the testing department.</p>
|
||||
<h4>Final Summary:</h4>
|
||||
<ul>
|
||||
<li>2× Royal Enfield Himalayan delivered on Jan 10, 2025</li>
|
||||
<li>Safety gear distributed to team members</li>
|
||||
<li>Maintenance toolkit installed in workshop</li>
|
||||
</ul>
|
||||
<p><strong>Total cost:</strong> ₹14,85,000 (within budget)</p>
|
||||
<blockquote>
|
||||
Testing department is now fully equipped for Q1 2025 schedule. All deliverables met.
|
||||
</blockquote>
|
||||
`,
|
||||
workNotesCount: 15,
|
||||
documentsCount: 8
|
||||
}
|
||||
);
|
||||
|
||||
console.log('\n');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('✅ All Real Scenario Tests Complete!');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('\n💡 Check the Preview URLs above to see emails with REAL data!');
|
||||
console.log('📌 All emails use actual request information, rich text content, and workflow data');
|
||||
console.log('\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
console.log('\n');
|
||||
console.log('═'.repeat(80));
|
||||
console.log(' REAL SCENARIO EMAIL TEST');
|
||||
console.log(' User 10 (John Doe) → Creates Request');
|
||||
console.log(' User 12 (Jane Smith) → Assigned as Approver');
|
||||
console.log('═'.repeat(80));
|
||||
console.log('\n');
|
||||
|
||||
testRealScenario()
|
||||
.then(() => {
|
||||
console.log('✅ Test completed successfully\n');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
139
src/emailtemplates/types.ts
Normal file
139
src/emailtemplates/types.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Email Template Types
|
||||
*
|
||||
* Type definitions for all email template data structures
|
||||
*/
|
||||
|
||||
export interface BaseEmailData {
|
||||
recipientName: string;
|
||||
requestId: string;
|
||||
requestTitle?: string;
|
||||
viewDetailsLink: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export interface RequestCreatedData extends BaseEmailData {
|
||||
initiatorName: string;
|
||||
firstApproverName: string;
|
||||
requestType: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
requestDate: string;
|
||||
requestTime: string;
|
||||
totalApprovers: number;
|
||||
expectedTAT: number;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestData extends BaseEmailData {
|
||||
approverName: string;
|
||||
initiatorName: string;
|
||||
requestDate: string;
|
||||
requestTime: string;
|
||||
requestType: string;
|
||||
requestDescription: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
}
|
||||
|
||||
export interface MultiApproverRequestData extends ApprovalRequestData {
|
||||
approverLevel: number;
|
||||
totalApprovers: number;
|
||||
approversList: ApprovalChainItem[];
|
||||
}
|
||||
|
||||
export interface ApprovalChainItem {
|
||||
name: string;
|
||||
status: 'approved' | 'pending' | 'current' | 'awaiting';
|
||||
date?: string;
|
||||
levelNumber: number;
|
||||
}
|
||||
|
||||
export interface ApprovalConfirmationData extends BaseEmailData {
|
||||
initiatorName: string;
|
||||
approverName: string;
|
||||
approvalDate: string;
|
||||
approvalTime: string;
|
||||
requestType: string;
|
||||
approverComments?: string;
|
||||
isFinalApproval: boolean;
|
||||
nextApproverName?: string;
|
||||
}
|
||||
|
||||
export interface RejectionNotificationData extends BaseEmailData {
|
||||
initiatorName: string;
|
||||
approverName: string;
|
||||
rejectionDate: string;
|
||||
rejectionTime: string;
|
||||
requestType: string;
|
||||
rejectionReason: string;
|
||||
}
|
||||
|
||||
export interface TATReminderData extends BaseEmailData {
|
||||
approverName: string;
|
||||
initiatorName: string;
|
||||
assignedDate: string;
|
||||
tatDeadline: string;
|
||||
timeRemaining: string;
|
||||
thresholdPercentage: number; // Dynamic: 40%, 50%, 75%, 80%, etc.
|
||||
urgencyLevel?: 'low' | 'medium' | 'high'; // Based on threshold
|
||||
}
|
||||
|
||||
export interface TATBreachedData extends BaseEmailData {
|
||||
approverName: string;
|
||||
initiatorName: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
assignedDate: string;
|
||||
tatDeadline: string;
|
||||
timeOverdue: string;
|
||||
}
|
||||
|
||||
export interface WorkflowPausedData extends BaseEmailData {
|
||||
pausedByName: string;
|
||||
pausedDate: string;
|
||||
pausedTime: string;
|
||||
resumeDate: string;
|
||||
pauseReason: string;
|
||||
}
|
||||
|
||||
export interface WorkflowResumedData extends BaseEmailData {
|
||||
resumedByText: string;
|
||||
resumedDate: string;
|
||||
resumedTime: string;
|
||||
pausedDuration: string;
|
||||
currentApprover: string;
|
||||
newTATDeadline: string;
|
||||
isApprover: boolean;
|
||||
}
|
||||
|
||||
export interface ParticipantAddedData extends BaseEmailData {
|
||||
participantName: string;
|
||||
participantRole: 'Approver' | 'Spectator';
|
||||
addedByName: string;
|
||||
initiatorName: string;
|
||||
requestType: string;
|
||||
currentStatus: string;
|
||||
addedDate: string;
|
||||
addedTime: string;
|
||||
requestDescription: string;
|
||||
}
|
||||
|
||||
export interface ApproverSkippedData extends BaseEmailData {
|
||||
skippedApproverName: string;
|
||||
skippedByName: string;
|
||||
skippedDate: string;
|
||||
skippedTime: string;
|
||||
nextApproverName: string;
|
||||
skipReason: string;
|
||||
}
|
||||
|
||||
export interface RequestClosedData extends BaseEmailData {
|
||||
initiatorName: string;
|
||||
createdDate: string;
|
||||
closedDate: string;
|
||||
closedTime: string;
|
||||
totalDuration: string;
|
||||
conclusionRemark?: string;
|
||||
totalApprovers: number;
|
||||
totalApprovals: number;
|
||||
workNotesCount: number;
|
||||
documentsCount: number;
|
||||
}
|
||||
|
||||
131
src/emailtemplates/workflowPaused.template.ts
Normal file
131
src/emailtemplates/workflowPaused.template.ts
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Workflow Paused Email Template
|
||||
*/
|
||||
|
||||
import { WorkflowPausedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Paused</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Workflow Paused',
|
||||
...HeaderStyles.neutral
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #6c757d;">${data.recipientName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
The following request has been <strong>paused</strong> by <strong>${data.pausedByName}</strong>. The workflow will resume automatically on the scheduled date or can be manually resumed.
|
||||
</p>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Paused By:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.pausedByName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Paused On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.pausedDate} at ${data.pausedTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Resume Date:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<strong>${data.resumeDate}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Pause:</h3>
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6c757d; border-radius: 4px;">
|
||||
${wrapRichText(data.pauseReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">What This Means</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>The approval process is temporarily on hold</li>
|
||||
<li>TAT timers are suspended until resumed</li>
|
||||
<li>No action is required during the pause period</li>
|
||||
<li>You'll be notified when the workflow resumes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
You'll receive another notification when the workflow resumes.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
134
src/emailtemplates/workflowResumed.template.ts
Normal file
134
src/emailtemplates/workflowResumed.template.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Workflow Resumed Email Template
|
||||
*/
|
||||
|
||||
import { WorkflowResumedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workflow Resumed</title>
|
||||
</head>
|
||||
<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">
|
||||
<tr>
|
||||
<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">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Workflow Resumed',
|
||||
...HeaderStyles.success
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #28a745;">${data.recipientName}</strong>,
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||
Good news! The following request has been <strong style="color: #28a745;">resumed</strong> ${data.resumedByText}. The approval process is now active again and requires your attention.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #155724; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Resumed On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.resumedDate} at ${data.resumedTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Paused Duration:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
${data.pausedDuration}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>Current Approver:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>${data.currentApprover}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>New TAT Deadline:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<strong>${data.newTATDeadline}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${getActionRequiredSection(data.isApprover)}
|
||||
|
||||
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">What Happens Now</h3>
|
||||
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
|
||||
<li>The approval process has restarted</li>
|
||||
<li>TAT timers are now active again</li>
|
||||
<li>The approver can now take action on this request</li>
|
||||
<li>All stakeholders have been notified</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="text-align: center;">
|
||||
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
|
||||
View Request Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
|
||||
Thank you for your patience during the pause period.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
${getEmailFooter(data.companyName)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -21,9 +21,9 @@ import client from 'prom-client';
|
||||
const register = new client.Registry();
|
||||
|
||||
// Add default Node.js metrics (memory, CPU, event loop, GC, etc.)
|
||||
// Collect with standard metric names (no prefix to avoid double-prefixing issue)
|
||||
client.collectDefaultMetrics({
|
||||
register,
|
||||
prefix: 'nodejs_',
|
||||
labels: { app: 're-workflow', service: 'backend' },
|
||||
});
|
||||
|
||||
@ -132,6 +132,54 @@ export const aiServiceDuration = new client.Histogram({
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE METRICS (BullMQ/Redis Queues)
|
||||
// ============================================================================
|
||||
|
||||
// Queue job counts by status
|
||||
export const queueJobsWaiting = new client.Gauge({
|
||||
name: 'queue_jobs_waiting',
|
||||
help: 'Number of jobs waiting in queue',
|
||||
labelNames: ['queue_name'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const queueJobsActive = new client.Gauge({
|
||||
name: 'queue_jobs_active',
|
||||
help: 'Number of jobs currently being processed',
|
||||
labelNames: ['queue_name'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const queueJobsCompleted = new client.Gauge({
|
||||
name: 'queue_jobs_completed',
|
||||
help: 'Number of completed jobs',
|
||||
labelNames: ['queue_name'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const queueJobsFailed = new client.Gauge({
|
||||
name: 'queue_jobs_failed',
|
||||
help: 'Number of failed jobs',
|
||||
labelNames: ['queue_name'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const queueJobsDelayed = new client.Gauge({
|
||||
name: 'queue_jobs_delayed',
|
||||
help: 'Number of delayed jobs',
|
||||
labelNames: ['queue_name'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// Queue processing rate
|
||||
export const queueJobProcessingRate = new client.Gauge({
|
||||
name: 'queue_job_processing_rate',
|
||||
help: 'Jobs processed per minute',
|
||||
labelNames: ['queue_name'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MIDDLEWARE
|
||||
// ============================================================================
|
||||
@ -286,6 +334,53 @@ export function recordAIServiceCall(provider: string, operation: string, success
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUEUE METRICS COLLECTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Update queue metrics for a specific queue
|
||||
* Call this periodically or on queue events
|
||||
*/
|
||||
export async function updateQueueMetrics(queueName: string, queue: any): Promise<void> {
|
||||
try {
|
||||
const [waiting, active, completed, failed, delayed] = await Promise.all([
|
||||
queue.getWaitingCount(),
|
||||
queue.getActiveCount(),
|
||||
queue.getCompletedCount(),
|
||||
queue.getFailedCount(),
|
||||
queue.getDelayedCount(),
|
||||
]);
|
||||
|
||||
queueJobsWaiting.set({ queue_name: queueName }, waiting);
|
||||
queueJobsActive.set({ queue_name: queueName }, active);
|
||||
queueJobsCompleted.set({ queue_name: queueName }, completed);
|
||||
queueJobsFailed.set({ queue_name: queueName }, failed);
|
||||
queueJobsDelayed.set({ queue_name: queueName }, delayed);
|
||||
} catch (error) {
|
||||
// Silently fail to avoid breaking metrics collection
|
||||
console.error(`[Metrics] Failed to update queue metrics for ${queueName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize periodic queue metrics collection
|
||||
* Should be called after queues are initialized
|
||||
*/
|
||||
export function startQueueMetricsCollection(queues: { name: string; queue: any }[], intervalMs: number = 15000): NodeJS.Timeout {
|
||||
const collect = async () => {
|
||||
for (const { name, queue } of queues) {
|
||||
await updateQueueMetrics(name, queue);
|
||||
}
|
||||
};
|
||||
|
||||
// Collect immediately
|
||||
collect();
|
||||
|
||||
// Then collect periodically
|
||||
return setInterval(collect, intervalMs);
|
||||
}
|
||||
|
||||
// Export the registry for advanced use cases
|
||||
export { register };
|
||||
|
||||
|
||||
@ -4,14 +4,26 @@ import logger from '../utils/logger';
|
||||
|
||||
export async function handlePauseResumeJob(job: Job): Promise<void> {
|
||||
try {
|
||||
const { type } = job.data;
|
||||
const { type, requestId, levelId, scheduledResumeDate } = job.data;
|
||||
|
||||
if (type === 'check_and_resume') {
|
||||
logger.info(`[Pause Resume Processor] Processing auto-resume check job ${job.id}`);
|
||||
if (type === 'auto-resume-workflow') {
|
||||
// Dedicated job to resume a specific workflow at scheduled time
|
||||
logger.info(`[Pause Resume Processor] Processing dedicated auto-resume job ${job.id} for workflow ${requestId}`);
|
||||
|
||||
try {
|
||||
await pauseService.resumeWorkflow(requestId);
|
||||
logger.info(`[Pause Resume Processor] ✅ Auto-resumed workflow ${requestId} (scheduled for ${scheduledResumeDate})`);
|
||||
} catch (resumeError: any) {
|
||||
logger.error(`[Pause Resume Processor] Failed to auto-resume workflow ${requestId}:`, resumeError?.message || resumeError);
|
||||
throw resumeError; // Re-throw to trigger retry
|
||||
}
|
||||
} else if (type === 'check_and_resume') {
|
||||
// Legacy: Hourly check job for all paused workflows (fallback)
|
||||
logger.info(`[Pause Resume Processor] Processing bulk auto-resume check job ${job.id}`);
|
||||
const resumedCount = await pauseService.checkAndResumePausedWorkflows();
|
||||
|
||||
if (resumedCount > 0) {
|
||||
logger.info(`[Pause Resume Processor] Auto-resumed ${resumedCount} workflow(s)`);
|
||||
logger.info(`[Pause Resume Processor] Auto-resumed ${resumedCount} workflow(s) via bulk check`);
|
||||
} else {
|
||||
logger.debug('[Pause Resume Processor] No workflows to auto-resume');
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { tatQueue } from '../queues/tatQueue';
|
||||
import { tatWorker } from '../queues/tatWorker';
|
||||
import { pauseResumeQueue } from '../queues/pauseResumeQueue';
|
||||
import { pauseResumeWorker } from '../queues/pauseResumeWorker';
|
||||
import { TatAlert } from '@models/TatAlert';
|
||||
import { ApprovalLevel } from '@models/ApprovalLevel';
|
||||
import dayjs from 'dayjs';
|
||||
@ -240,17 +242,15 @@ router.post('/tat-calculate', async (req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
router.get('/queue-status', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!tatQueue || !tatWorker) {
|
||||
res.json({
|
||||
error: 'Queue or Worker not available',
|
||||
queueAvailable: !!tatQueue,
|
||||
workerAvailable: !!tatWorker
|
||||
});
|
||||
return;
|
||||
}
|
||||
const response: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tatQueue: null,
|
||||
pauseResumeQueue: null
|
||||
};
|
||||
|
||||
// Get job counts
|
||||
const [waiting, delayed, active, completed, failed] = await Promise.all([
|
||||
// TAT Queue Status
|
||||
if (tatQueue && tatWorker) {
|
||||
const [tatWaiting, tatDelayed, tatActive, tatCompleted, tatFailed] = await Promise.all([
|
||||
tatQueue.getJobCounts('waiting'),
|
||||
tatQueue.getJobCounts('delayed'),
|
||||
tatQueue.getJobCounts('active'),
|
||||
@ -258,13 +258,11 @@ router.get('/queue-status', async (req: Request, res: Response): Promise<void> =
|
||||
tatQueue.getJobCounts('failed')
|
||||
]);
|
||||
|
||||
// Get all jobs in various states
|
||||
const waitingJobs = await tatQueue.getJobs(['waiting'], 0, 10);
|
||||
const delayedJobs = await tatQueue.getJobs(['delayed'], 0, 10);
|
||||
const activeJobs = await tatQueue.getJobs(['active'], 0, 10);
|
||||
const tatWaitingJobs = await tatQueue.getJobs(['waiting'], 0, 10);
|
||||
const tatDelayedJobs = await tatQueue.getJobs(['delayed'], 0, 10);
|
||||
const tatActiveJobs = await tatQueue.getJobs(['active'], 0, 10);
|
||||
|
||||
res.json({
|
||||
timestamp: new Date().toISOString(),
|
||||
response.tatQueue = {
|
||||
queue: {
|
||||
name: tatQueue.name,
|
||||
available: true
|
||||
@ -278,15 +276,15 @@ router.get('/queue-status', async (req: Request, res: Response): Promise<void> =
|
||||
autorun: tatWorker.opts.autorun
|
||||
},
|
||||
jobCounts: {
|
||||
waiting: waiting.waiting,
|
||||
delayed: delayed.delayed,
|
||||
active: active.active,
|
||||
completed: completed.completed,
|
||||
failed: failed.failed
|
||||
waiting: tatWaiting.waiting,
|
||||
delayed: tatDelayed.delayed,
|
||||
active: tatActive.active,
|
||||
completed: tatCompleted.completed,
|
||||
failed: tatFailed.failed
|
||||
},
|
||||
recentJobs: {
|
||||
waiting: waitingJobs.map(j => ({ id: j.id, name: j.name, data: j.data })),
|
||||
delayed: delayedJobs.map(j => ({
|
||||
waiting: tatWaitingJobs.map(j => ({ id: j.id, name: j.name, data: j.data })),
|
||||
delayed: tatDelayedJobs.map(j => ({
|
||||
id: j.id,
|
||||
name: j.name,
|
||||
data: j.data,
|
||||
@ -294,9 +292,73 @@ router.get('/queue-status', async (req: Request, res: Response): Promise<void> =
|
||||
timestamp: j.timestamp,
|
||||
scheduledFor: new Date(j.timestamp + (j.opts.delay || 0)).toISOString()
|
||||
})),
|
||||
active: activeJobs.map(j => ({ id: j.id, name: j.name, data: j.data }))
|
||||
active: tatActiveJobs.map(j => ({ id: j.id, name: j.name, data: j.data }))
|
||||
}
|
||||
});
|
||||
};
|
||||
} else {
|
||||
response.tatQueue = {
|
||||
error: 'TAT Queue or Worker not available',
|
||||
queueAvailable: !!tatQueue,
|
||||
workerAvailable: !!tatWorker
|
||||
};
|
||||
}
|
||||
|
||||
// Pause/Resume Queue Status
|
||||
if (pauseResumeQueue && pauseResumeWorker) {
|
||||
const [prWaiting, prDelayed, prActive, prCompleted, prFailed] = await Promise.all([
|
||||
pauseResumeQueue.getJobCounts('waiting'),
|
||||
pauseResumeQueue.getJobCounts('delayed'),
|
||||
pauseResumeQueue.getJobCounts('active'),
|
||||
pauseResumeQueue.getJobCounts('completed'),
|
||||
pauseResumeQueue.getJobCounts('failed')
|
||||
]);
|
||||
|
||||
const prWaitingJobs = await pauseResumeQueue.getJobs(['waiting'], 0, 10);
|
||||
const prDelayedJobs = await pauseResumeQueue.getJobs(['delayed'], 0, 10);
|
||||
const prActiveJobs = await pauseResumeQueue.getJobs(['active'], 0, 10);
|
||||
|
||||
response.pauseResumeQueue = {
|
||||
queue: {
|
||||
name: pauseResumeQueue.name,
|
||||
available: true
|
||||
},
|
||||
worker: {
|
||||
available: true,
|
||||
running: pauseResumeWorker.isRunning(),
|
||||
paused: pauseResumeWorker.isPaused(),
|
||||
closing: pauseResumeWorker.closing,
|
||||
concurrency: pauseResumeWorker.opts.concurrency,
|
||||
autorun: pauseResumeWorker.opts.autorun
|
||||
},
|
||||
jobCounts: {
|
||||
waiting: prWaiting.waiting,
|
||||
delayed: prDelayed.delayed,
|
||||
active: prActive.active,
|
||||
completed: prCompleted.completed,
|
||||
failed: prFailed.failed
|
||||
},
|
||||
recentJobs: {
|
||||
waiting: prWaitingJobs.map(j => ({ id: j.id, name: j.name, data: j.data })),
|
||||
delayed: prDelayedJobs.map(j => ({
|
||||
id: j.id,
|
||||
name: j.name,
|
||||
data: j.data,
|
||||
delay: j.opts.delay,
|
||||
timestamp: j.timestamp,
|
||||
scheduledFor: new Date(j.timestamp + (j.opts.delay || 0)).toISOString()
|
||||
})),
|
||||
active: prActiveJobs.map(j => ({ id: j.id, name: j.name, data: j.data }))
|
||||
}
|
||||
};
|
||||
} else {
|
||||
response.pauseResumeQueue = {
|
||||
error: 'Pause/Resume Queue or Worker not available',
|
||||
queueAvailable: !!pauseResumeQueue,
|
||||
workerAvailable: !!pauseResumeWorker
|
||||
};
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[Debug] Error checking queue status:', error);
|
||||
|
||||
@ -8,6 +8,7 @@ import { initializeHolidaysCache } from './utils/tatTimeUtils';
|
||||
import { seedDefaultConfigurations } from './services/configSeed.service';
|
||||
import { startPauseResumeJob } from './jobs/pauseResumeJob';
|
||||
import './queues/pauseResumeWorker'; // Initialize pause resume worker
|
||||
import { initializeQueueMetrics, stopQueueMetrics } from './utils/queueMetrics';
|
||||
|
||||
const PORT: number = parseInt(process.env.PORT || '5000', 10);
|
||||
|
||||
@ -34,6 +35,9 @@ const startServer = async (): Promise<void> => {
|
||||
// Start scheduled jobs
|
||||
startPauseResumeJob();
|
||||
|
||||
// Initialize queue metrics collection for Prometheus
|
||||
initializeQueueMetrics();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
@ -46,11 +50,13 @@ const startServer = async (): Promise<void> => {
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('🛑 SIGTERM signal received: closing HTTP server');
|
||||
stopQueueMetrics();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('🛑 SIGINT signal received: closing HTTP server');
|
||||
stopQueueMetrics();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
|
||||
@ -2331,14 +2331,6 @@ export class DashboardService {
|
||||
let dateFilter = '';
|
||||
const replacements: any = { approverId };
|
||||
|
||||
logger.info(`[Dashboard] Single approver stats - Received filters:`, {
|
||||
dateRange,
|
||||
startDate,
|
||||
endDate,
|
||||
priority,
|
||||
slaCompliance
|
||||
});
|
||||
|
||||
if (dateRange) {
|
||||
const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate);
|
||||
dateFilter = `
|
||||
@ -2349,12 +2341,6 @@ export class DashboardService {
|
||||
`;
|
||||
replacements.dateStart = dateFilterObj.start;
|
||||
replacements.dateEnd = dateFilterObj.end;
|
||||
logger.info(`[Dashboard] Date filter applied:`, {
|
||||
start: dateFilterObj.start,
|
||||
end: dateFilterObj.end
|
||||
});
|
||||
} else {
|
||||
logger.info(`[Dashboard] No date filter applied - showing all data`);
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
|
||||
221
src/services/email.service.ts
Normal file
221
src/services/email.service.ts
Normal file
@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Email Service
|
||||
*
|
||||
* Core email sending service with nodemailer
|
||||
* Supports both test accounts (preview) and production SMTP
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
interface EmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
cc?: string | string[];
|
||||
bcc?: string | string[];
|
||||
attachments?: any[];
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
private transporter: nodemailer.Transporter | null = null;
|
||||
private useTestAccount: boolean = false;
|
||||
private testAccountInfo: any = null;
|
||||
|
||||
/**
|
||||
* Initialize email service
|
||||
* If SMTP credentials are not configured, uses test account for preview
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPassword = process.env.SMTP_PASSWORD;
|
||||
|
||||
// Check if SMTP is configured
|
||||
if (!smtpHost || !smtpUser || !smtpPassword) {
|
||||
logger.warn('⚠️ SMTP not configured - using test account for preview');
|
||||
await this.initializeTestAccount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Production SMTP configuration
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPassword
|
||||
},
|
||||
pool: true, // Use connection pooling
|
||||
maxConnections: 5,
|
||||
maxMessages: 100,
|
||||
rateDelta: 1000,
|
||||
rateLimit: 5
|
||||
});
|
||||
|
||||
// Verify connection
|
||||
await this.transporter.verify();
|
||||
logger.info('✅ Email service initialized with production SMTP');
|
||||
this.useTestAccount = false;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize production SMTP:', error);
|
||||
logger.warn('⚠️ Falling back to test account');
|
||||
await this.initializeTestAccount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test account for preview (no real SMTP needed)
|
||||
*/
|
||||
private async initializeTestAccount(): Promise<void> {
|
||||
try {
|
||||
this.testAccountInfo = await nodemailer.createTestAccount();
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.testAccountInfo.smtp.host,
|
||||
port: this.testAccountInfo.smtp.port,
|
||||
secure: this.testAccountInfo.smtp.secure,
|
||||
auth: {
|
||||
user: this.testAccountInfo.user,
|
||||
pass: this.testAccountInfo.pass
|
||||
}
|
||||
});
|
||||
|
||||
this.useTestAccount = true;
|
||||
logger.info('✅ Email service initialized with test account (preview mode)');
|
||||
logger.info(`📧 Test account: ${this.testAccountInfo.user}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize test account:', error);
|
||||
throw new Error('Email service initialization failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email with retry logic
|
||||
*/
|
||||
async sendEmail(options: EmailOptions): Promise<{ messageId: string; previewUrl?: string }> {
|
||||
if (!this.transporter) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
||||
const fromAddress = process.env.EMAIL_FROM || 'RE Flow <noreply@royalenfield.com>';
|
||||
|
||||
const mailOptions = {
|
||||
from: fromAddress,
|
||||
to: recipients,
|
||||
cc: options.cc,
|
||||
bcc: options.bcc,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
attachments: options.attachments
|
||||
};
|
||||
|
||||
// Retry logic
|
||||
const maxRetries = parseInt(process.env.EMAIL_RETRY_ATTEMPTS || '3');
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const info = await this.transporter!.sendMail(mailOptions);
|
||||
|
||||
const result: { messageId: string; previewUrl?: string } = {
|
||||
messageId: info.messageId
|
||||
};
|
||||
|
||||
// If using test account, generate preview URL
|
||||
if (this.useTestAccount) {
|
||||
const previewUrl = nodemailer.getTestMessageUrl(info);
|
||||
result.previewUrl = previewUrl || undefined;
|
||||
|
||||
// Always log to console for visibility
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`📧 EMAIL PREVIEW (${options.subject})`);
|
||||
console.log(`To: ${recipients}`);
|
||||
console.log(`Preview URL: ${previewUrl}`);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
logger.info(`✅ Email sent (TEST MODE) to ${recipients}`);
|
||||
logger.info(`📧 Preview URL: ${previewUrl}`);
|
||||
} else {
|
||||
logger.info(`✅ Email sent to ${recipients}: ${options.subject}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.error(`❌ Email send attempt ${attempt}/${maxRetries} failed:`, error);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const delay = parseInt(process.env.EMAIL_RETRY_DELAY || '5000') * attempt;
|
||||
logger.info(`⏳ Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
logger.error(`❌ Failed to send email after ${maxRetries} attempts:`, lastError);
|
||||
throw new Error(`Email delivery failed: ${lastError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email to multiple recipients (batch)
|
||||
*/
|
||||
async sendBatch(emails: EmailOptions[]): Promise<void> {
|
||||
logger.info(`📧 Sending batch of ${emails.length} emails`);
|
||||
|
||||
const batchSize = parseInt(process.env.EMAIL_BATCH_SIZE || '10');
|
||||
|
||||
for (let i = 0; i < emails.length; i += batchSize) {
|
||||
const batch = emails.slice(i, i + batchSize);
|
||||
|
||||
await Promise.allSettled(
|
||||
batch.map(email => this.sendEmail(email))
|
||||
);
|
||||
|
||||
// Small delay between batches to avoid rate limiting
|
||||
if (i + batchSize < emails.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ Batch email sending complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email service is in test mode
|
||||
*/
|
||||
isTestMode(): boolean {
|
||||
return this.useTestAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test account info (for preview URLs)
|
||||
*/
|
||||
getTestAccountInfo(): any {
|
||||
return this.testAccountInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close transporter (cleanup)
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.transporter) {
|
||||
this.transporter.close();
|
||||
logger.info('📧 Email service closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const emailService = new EmailService();
|
||||
|
||||
// Initialize on import (will use test account if SMTP not configured)
|
||||
emailService.initialize().catch(error => {
|
||||
logger.error('Failed to initialize email service:', error);
|
||||
});
|
||||
|
||||
583
src/services/emailNotification.service.ts
Normal file
583
src/services/emailNotification.service.ts
Normal file
@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Email Notification Service
|
||||
*
|
||||
* High-level service for sending templated emails
|
||||
* Integrates: Templates + Preference Checking + Email Service
|
||||
*/
|
||||
|
||||
import { emailService } from './email.service';
|
||||
import {
|
||||
getRequestCreatedEmail,
|
||||
getApprovalRequestEmail,
|
||||
getMultiApproverRequestEmail,
|
||||
getApprovalConfirmationEmail,
|
||||
getRejectionNotificationEmail,
|
||||
getTATReminderEmail,
|
||||
getTATBreachedEmail,
|
||||
getWorkflowPausedEmail,
|
||||
getWorkflowResumedEmail,
|
||||
getParticipantAddedEmail,
|
||||
getApproverSkippedEmail,
|
||||
getRequestClosedEmail,
|
||||
getViewDetailsLink,
|
||||
CompanyInfo,
|
||||
RequestCreatedData,
|
||||
ApprovalRequestData,
|
||||
MultiApproverRequestData,
|
||||
ApprovalConfirmationData,
|
||||
RejectionNotificationData,
|
||||
TATReminderData,
|
||||
TATBreachedData,
|
||||
WorkflowPausedData,
|
||||
WorkflowResumedData,
|
||||
ParticipantAddedData,
|
||||
ApproverSkippedData,
|
||||
RequestClosedData,
|
||||
ApprovalChainItem
|
||||
} from '../emailtemplates';
|
||||
import {
|
||||
shouldSendEmail,
|
||||
shouldSendEmailWithOverride,
|
||||
EmailNotificationType
|
||||
} from '../emailtemplates/emailPreferences.helper';
|
||||
import logger from '@utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export class EmailNotificationService {
|
||||
private frontendUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format date for emails
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
return dayjs(date).format('MMM DD, YYYY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Format time for emails
|
||||
*/
|
||||
private formatTime(date: Date | string): string {
|
||||
return dayjs(date).format('hh:mm A');
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Send Request Created Email
|
||||
*/
|
||||
async sendRequestCreated(
|
||||
requestData: any,
|
||||
initiatorData: any,
|
||||
firstApproverData: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check preferences
|
||||
const canSend = await shouldSendEmail(
|
||||
initiatorData.userId,
|
||||
EmailNotificationType.REQUEST_CREATED
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (preferences): Request Created for ${initiatorData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: RequestCreatedData = {
|
||||
recipientName: initiatorData.displayName || initiatorData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
requestTitle: requestData.title,
|
||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||
firstApproverName: firstApproverData.displayName || firstApproverData.email,
|
||||
requestType: requestData.requestType || 'General',
|
||||
priority: requestData.priority || 'MEDIUM',
|
||||
requestDate: this.formatDate(requestData.createdAt),
|
||||
requestTime: this.formatTime(requestData.createdAt),
|
||||
totalApprovers: requestData.totalApprovers || 1,
|
||||
expectedTAT: requestData.tatHours || 24,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getRequestCreatedEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Request Created Successfully`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: initiatorData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Request Created Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Request Created email:', error);
|
||||
// Don't throw - email failure shouldn't block workflow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Send Approval Request Email
|
||||
*/
|
||||
async sendApprovalRequest(
|
||||
requestData: any,
|
||||
approverData: any,
|
||||
initiatorData: any,
|
||||
isMultiLevel: boolean,
|
||||
approvalChain?: any[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check preferences
|
||||
const canSend = await shouldSendEmail(
|
||||
approverData.userId,
|
||||
EmailNotificationType.APPROVAL_REQUEST
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (preferences): Approval Request for ${approverData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMultiLevel && approvalChain) {
|
||||
// Multi-level approval email
|
||||
const chainData: ApprovalChainItem[] = approvalChain.map((level: any) => ({
|
||||
name: level.approverName || level.approverEmail,
|
||||
status: level.status === 'APPROVED' ? 'approved'
|
||||
: level.levelNumber === approverData.levelNumber ? 'current'
|
||||
: level.levelNumber < approverData.levelNumber ? 'pending'
|
||||
: 'awaiting',
|
||||
date: level.approvedAt ? this.formatDate(level.approvedAt) : undefined,
|
||||
levelNumber: level.levelNumber
|
||||
}));
|
||||
|
||||
const data: MultiApproverRequestData = {
|
||||
recipientName: approverData.displayName || approverData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
approverName: approverData.displayName || approverData.email,
|
||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||
requestType: requestData.requestType || 'General',
|
||||
requestDescription: requestData.description || '',
|
||||
priority: requestData.priority || 'MEDIUM',
|
||||
requestDate: this.formatDate(requestData.createdAt),
|
||||
requestTime: this.formatTime(requestData.createdAt),
|
||||
approverLevel: approverData.levelNumber,
|
||||
totalApprovers: approvalChain.length,
|
||||
approversList: chainData,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getMultiApproverRequestEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Multi-Level Approval Request - Your Turn`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: approverData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Multi-Approver Request Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} else {
|
||||
// Single approver email
|
||||
const data: ApprovalRequestData = {
|
||||
recipientName: approverData.displayName || approverData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
approverName: approverData.displayName || approverData.email,
|
||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||
requestType: requestData.requestType || 'General',
|
||||
requestDescription: requestData.description || '',
|
||||
priority: requestData.priority || 'MEDIUM',
|
||||
requestDate: this.formatDate(requestData.createdAt),
|
||||
requestTime: this.formatTime(requestData.createdAt),
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getApprovalRequestEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Approval Request - Action Required`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: approverData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Approval Request Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Approval Request email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Send Approval Confirmation Email
|
||||
*/
|
||||
async sendApprovalConfirmation(
|
||||
requestData: any,
|
||||
approverData: any,
|
||||
initiatorData: any,
|
||||
isFinalApproval: boolean,
|
||||
nextApproverData?: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canSend = await shouldSendEmail(
|
||||
initiatorData.userId,
|
||||
EmailNotificationType.REQUEST_APPROVED
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (preferences): Approval Confirmation for ${initiatorData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: ApprovalConfirmationData = {
|
||||
recipientName: initiatorData.displayName || initiatorData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||
approverName: approverData.displayName || approverData.email,
|
||||
approvalDate: this.formatDate(approverData.approvedAt || new Date()),
|
||||
approvalTime: this.formatTime(approverData.approvedAt || new Date()),
|
||||
requestType: requestData.requestType || 'General',
|
||||
approverComments: approverData.comments || undefined,
|
||||
isFinalApproval,
|
||||
nextApproverName: nextApproverData?.displayName || nextApproverData?.email,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getApprovalConfirmationEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Request Approved${isFinalApproval ? ' - All Approvals Complete' : ''}`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: initiatorData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Approval Confirmation Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Approval Confirmation email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. Send Rejection Notification Email (CRITICAL)
|
||||
*/
|
||||
async sendRejectionNotification(
|
||||
requestData: any,
|
||||
approverData: any,
|
||||
initiatorData: any,
|
||||
rejectionReason: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Use override for critical emails
|
||||
const canSend = await shouldSendEmailWithOverride(
|
||||
initiatorData.userId,
|
||||
EmailNotificationType.REQUEST_REJECTED
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (admin disabled): Rejection for ${initiatorData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: RejectionNotificationData = {
|
||||
recipientName: initiatorData.displayName || initiatorData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
initiatorName: initiatorData.displayName || initiatorData.email,
|
||||
approverName: approverData.displayName || approverData.email,
|
||||
rejectionDate: this.formatDate(approverData.rejectedAt || new Date()),
|
||||
rejectionTime: this.formatTime(approverData.rejectedAt || new Date()),
|
||||
requestType: requestData.requestType || 'General',
|
||||
rejectionReason,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getRejectionNotificationEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Request Rejected`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: initiatorData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Rejection Notification Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Rejection Notification email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. Send TAT Reminder Email (Dynamic Threshold)
|
||||
*/
|
||||
async sendTATReminder(
|
||||
requestData: any,
|
||||
approverData: any,
|
||||
tatInfo: {
|
||||
thresholdPercentage: number;
|
||||
timeRemaining: string;
|
||||
tatDeadline: Date | string;
|
||||
assignedDate: Date | string;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canSend = await shouldSendEmail(
|
||||
approverData.userId,
|
||||
EmailNotificationType.TAT_REMINDER
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (preferences): TAT Reminder for ${approverData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine urgency level based on threshold
|
||||
const urgencyLevel = tatInfo.thresholdPercentage >= 75 ? 'high'
|
||||
: tatInfo.thresholdPercentage >= 50 ? 'medium'
|
||||
: 'low';
|
||||
|
||||
const data: TATReminderData = {
|
||||
recipientName: approverData.displayName || approverData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
requestTitle: requestData.title,
|
||||
approverName: approverData.displayName || approverData.email,
|
||||
initiatorName: requestData.initiatorName || 'Initiator',
|
||||
assignedDate: this.formatDate(tatInfo.assignedDate),
|
||||
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
||||
timeRemaining: tatInfo.timeRemaining,
|
||||
thresholdPercentage: tatInfo.thresholdPercentage,
|
||||
urgencyLevel: urgencyLevel as any,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getTATReminderEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] TAT Reminder - ${tatInfo.thresholdPercentage}% Elapsed`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: approverData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 TAT Reminder (${tatInfo.thresholdPercentage}%) Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send TAT Reminder email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. Send TAT Breached Email (CRITICAL)
|
||||
*/
|
||||
async sendTATBreached(
|
||||
requestData: any,
|
||||
approverData: any,
|
||||
tatInfo: {
|
||||
timeOverdue: string;
|
||||
tatDeadline: Date | string;
|
||||
assignedDate: Date | string;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Use override for critical emails
|
||||
const canSend = await shouldSendEmailWithOverride(
|
||||
approverData.userId,
|
||||
EmailNotificationType.TAT_BREACHED
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (admin disabled): TAT Breach for ${approverData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: TATBreachedData = {
|
||||
recipientName: approverData.displayName || approverData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
requestTitle: requestData.title,
|
||||
approverName: approverData.displayName || approverData.email,
|
||||
initiatorName: requestData.initiatorName || 'Initiator',
|
||||
priority: requestData.priority || 'MEDIUM',
|
||||
assignedDate: this.formatDate(tatInfo.assignedDate),
|
||||
tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline),
|
||||
timeOverdue: tatInfo.timeOverdue,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getTATBreachedEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] TAT BREACHED - Immediate Action Required`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: approverData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 TAT Breached Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send TAT Breached email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 7. Send Workflow Resumed Email
|
||||
*/
|
||||
async sendWorkflowResumed(
|
||||
requestData: any,
|
||||
approverData: any,
|
||||
initiatorData: any,
|
||||
resumedByData: any,
|
||||
pauseDuration: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canSend = await shouldSendEmail(
|
||||
approverData.userId,
|
||||
EmailNotificationType.WORKFLOW_RESUMED
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (preferences): Workflow Resumed for ${approverData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isAutoResumed = !resumedByData || resumedByData.userId === 'system';
|
||||
const resumedByText = isAutoResumed
|
||||
? 'automatically'
|
||||
: `by ${resumedByData.displayName || resumedByData.email}`;
|
||||
|
||||
const data: WorkflowResumedData = {
|
||||
recipientName: approverData.displayName || approverData.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,
|
||||
newTATDeadline: requestData.tatDeadline
|
||||
? this.formatDate(requestData.tatDeadline) + ' ' + this.formatTime(requestData.tatDeadline)
|
||||
: 'To be determined',
|
||||
isApprover: true,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getWorkflowResumedEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Workflow Resumed - Action Required`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: approverData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Workflow Resumed Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Workflow Resumed email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8. Send Request Closed Email
|
||||
*/
|
||||
async sendRequestClosed(
|
||||
requestData: any,
|
||||
recipientData: any,
|
||||
closureData: {
|
||||
conclusionRemark?: string;
|
||||
workNotesCount: number;
|
||||
documentsCount: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const canSend = await shouldSendEmail(
|
||||
recipientData.userId,
|
||||
EmailNotificationType.REQUEST_CLOSED
|
||||
);
|
||||
|
||||
if (!canSend) {
|
||||
logger.info(`Email skipped (preferences): Request Closed for ${recipientData.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const createdDate = requestData.createdAt ? dayjs(requestData.createdAt) : dayjs();
|
||||
const closedDate = requestData.closedAt ? dayjs(requestData.closedAt) : dayjs();
|
||||
const duration = closedDate.diff(createdDate, 'day');
|
||||
const totalDuration = `${duration} day${duration !== 1 ? 's' : ''}`;
|
||||
|
||||
const data: RequestClosedData = {
|
||||
recipientName: recipientData.displayName || recipientData.email,
|
||||
requestId: requestData.requestNumber,
|
||||
requestTitle: requestData.title,
|
||||
initiatorName: requestData.initiatorName || 'Initiator',
|
||||
createdDate: this.formatDate(requestData.createdAt),
|
||||
closedDate: this.formatDate(requestData.closedAt || new Date()),
|
||||
closedTime: this.formatTime(requestData.closedAt || new Date()),
|
||||
totalDuration,
|
||||
conclusionRemark: closureData.conclusionRemark,
|
||||
totalApprovers: requestData.totalApprovers || 0,
|
||||
totalApprovals: requestData.totalApprovals || 0,
|
||||
workNotesCount: closureData.workNotesCount,
|
||||
documentsCount: closureData.documentsCount,
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name
|
||||
};
|
||||
|
||||
const html = getRequestClosedEmail(data);
|
||||
const subject = `[${requestData.requestNumber}] Request Closed`;
|
||||
|
||||
const result = await emailService.sendEmail({
|
||||
to: recipientData.email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
if (result.previewUrl) {
|
||||
logger.info(`📧 Request Closed Email Preview: ${result.previewUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Request Closed email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send emails to multiple recipients (for Request Closed)
|
||||
*/
|
||||
async sendRequestClosedToAll(
|
||||
requestData: any,
|
||||
participants: any[],
|
||||
closureData: any
|
||||
): Promise<void> {
|
||||
logger.info(`📧 Sending Request Closed emails to ${participants.length} participants`);
|
||||
|
||||
for (const participant of participants) {
|
||||
await this.sendRequestClosed(requestData, participant, closureData);
|
||||
// Small delay to avoid rate limiting
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
// Add more email methods as needed...
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const emailNotificationService = new EmailNotificationService();
|
||||
|
||||
@ -2,6 +2,12 @@ import webpush from 'web-push';
|
||||
import logger, { logNotificationEvent } from '@utils/logger';
|
||||
import { Subscription } from '@models/Subscription';
|
||||
import { Notification } from '@models/Notification';
|
||||
import {
|
||||
shouldSendEmail,
|
||||
shouldSendEmailWithOverride,
|
||||
shouldSendInAppNotification,
|
||||
EmailNotificationType
|
||||
} from '../emailtemplates/emailPreferences.helper';
|
||||
|
||||
type PushSubscription = any; // Web Push protocol JSON
|
||||
|
||||
@ -107,8 +113,9 @@ class NotificationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to users - saves to DB and sends via push/socket
|
||||
* Respects user notification preferences
|
||||
* Send notification to users - saves to DB, sends via push/socket, and emails
|
||||
* Respects user notification preferences for all channels
|
||||
* Automatically sends email for applicable notification types
|
||||
*/
|
||||
async sendToUsers(userIds: string[], payload: NotificationPayload) {
|
||||
const message = JSON.stringify(payload);
|
||||
@ -133,8 +140,10 @@ class NotificationService {
|
||||
|
||||
const sentVia: string[] = [];
|
||||
|
||||
// 1. Save notification to database for in-app display (if enabled)
|
||||
if (user.inAppNotificationsEnabled) {
|
||||
// 1. Check admin + user preferences for in-app notifications
|
||||
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
|
||||
|
||||
if (canSendInApp && user.inAppNotificationsEnabled) {
|
||||
const notification = await Notification.create({
|
||||
userId,
|
||||
requestId: payload.requestId,
|
||||
@ -173,7 +182,7 @@ class NotificationService {
|
||||
}
|
||||
|
||||
// 3. Send push notification (if enabled and user has subscriptions)
|
||||
if (user.pushNotificationsEnabled) {
|
||||
if (user.pushNotificationsEnabled && canSendInApp) {
|
||||
let subs = this.userIdToSubscriptions.get(userId) || [];
|
||||
// Load from DB if memory empty
|
||||
if (subs.length === 0) {
|
||||
@ -216,14 +225,20 @@ class NotificationService {
|
||||
logger.info(`[Notification] Push notifications disabled for user ${userId}, skipping push`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`[Notification] In-app notifications disabled for user ${userId}, skipping notification`);
|
||||
if (!canSendInApp) {
|
||||
logger.info(`[Notification] In-app notifications disabled by admin/user for user ${userId}, type: ${payload.type}`);
|
||||
} else {
|
||||
logger.info(`[Notification] In-app notifications disabled for user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Email notifications (when implemented)
|
||||
// if (user.emailNotificationsEnabled) {
|
||||
// // Send email notification
|
||||
// sentVia.push('EMAIL');
|
||||
// }
|
||||
// 4. Send email notification for applicable types (async, don't wait)
|
||||
console.log(`[DEBUG] Checking email for notification type: ${payload.type}`);
|
||||
this.sendEmailNotification(userId, user, payload).catch(emailError => {
|
||||
console.error(`[Notification] Email sending failed for user ${userId}:`, emailError);
|
||||
logger.error(`[Notification] Email sending failed for user ${userId}:`, emailError);
|
||||
// Don't throw - email failure shouldn't block notification
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`[Notification] Failed to create notification for user ${userId}:`, error);
|
||||
@ -231,10 +246,286 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification based on notification type
|
||||
* Only sends for notification types that warrant email
|
||||
*/
|
||||
private async sendEmailNotification(userId: string, user: any, payload: NotificationPayload): Promise<void> {
|
||||
console.log(`[DEBUG Email] Notification type: ${payload.type}, userId: ${userId}`);
|
||||
|
||||
// Import email service (lazy load to avoid circular dependencies)
|
||||
const { emailNotificationService } = await import('./emailNotification.service');
|
||||
const { EmailNotificationType } = await import('../emailtemplates/emailPreferences.helper');
|
||||
|
||||
// Map notification type to email type and check if email should be sent
|
||||
const emailTypeMap: Record<string, EmailNotificationType | null> = {
|
||||
'request_submitted': EmailNotificationType.REQUEST_CREATED,
|
||||
'assignment': EmailNotificationType.APPROVAL_REQUEST,
|
||||
'approval': EmailNotificationType.REQUEST_APPROVED,
|
||||
'rejection': EmailNotificationType.REQUEST_REJECTED,
|
||||
'tat_reminder': EmailNotificationType.TAT_REMINDER,
|
||||
'tat_breach': EmailNotificationType.TAT_BREACHED,
|
||||
'workflow_resumed': EmailNotificationType.WORKFLOW_RESUMED,
|
||||
'closed': EmailNotificationType.REQUEST_CLOSED,
|
||||
// These don't get emails (in-app only)
|
||||
'mention': null,
|
||||
'comment': null,
|
||||
'document_added': null,
|
||||
'status_change': null,
|
||||
'ai_conclusion_generated': null,
|
||||
'summary_generated': null,
|
||||
'workflow_paused': null, // Conditional - handled separately
|
||||
'pause_retriggered': null
|
||||
};
|
||||
|
||||
const emailType = emailTypeMap[payload.type || ''];
|
||||
|
||||
console.log(`[DEBUG Email] Email type mapped: ${emailType}`);
|
||||
|
||||
if (!emailType) {
|
||||
// This notification type doesn't warrant email
|
||||
console.log(`[DEBUG Email] No email for notification type: ${payload.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email should be sent (admin + user preferences)
|
||||
const shouldSend = payload.type === 'rejection' || payload.type === 'tat_breach'
|
||||
? await shouldSendEmailWithOverride(userId, emailType) // Critical emails
|
||||
: await shouldSendEmail(userId, emailType); // Regular emails
|
||||
|
||||
console.log(`[DEBUG Email] Should send email: ${shouldSend}`);
|
||||
|
||||
if (!shouldSend) {
|
||||
console.log(`[DEBUG Email] Email skipped for user ${userId}, type: ${payload.type} (preferences)`);
|
||||
logger.info(`[Email] Skipped for user ${userId}, type: ${payload.type} (preferences)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger email based on notification type
|
||||
// Email service will fetch additional data as needed
|
||||
console.log(`[DEBUG Email] Triggering email for type: ${payload.type}`);
|
||||
try {
|
||||
await this.triggerEmailByType(payload.type || '', userId, payload, user);
|
||||
} catch (error) {
|
||||
console.error(`[DEBUG Email] Error triggering email:`, error);
|
||||
logger.error(`[Email] Failed to trigger email for type ${payload.type}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger appropriate email based on notification type
|
||||
*/
|
||||
private async triggerEmailByType(
|
||||
notificationType: string,
|
||||
userId: string,
|
||||
payload: NotificationPayload,
|
||||
user: any
|
||||
): Promise<void> {
|
||||
const { emailNotificationService } = await import('./emailNotification.service');
|
||||
const { WorkflowRequest, User, ApprovalLevel } = await import('@models/index');
|
||||
|
||||
// Fetch request data if requestId is provided
|
||||
if (!payload.requestId) {
|
||||
logger.warn(`[Email] No requestId in payload for type ${notificationType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = await WorkflowRequest.findByPk(payload.requestId);
|
||||
|
||||
if (!request) {
|
||||
logger.warn(`[Email] Request ${payload.requestId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestData = request.toJSON();
|
||||
|
||||
// Fetch initiator user
|
||||
const initiator = await User.findByPk(requestData.initiatorId);
|
||||
if (!initiator) {
|
||||
logger.warn(`[Email] Initiator not found for request ${payload.requestId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const initiatorData = initiator.toJSON();
|
||||
|
||||
switch (notificationType) {
|
||||
case 'request_submitted':
|
||||
{
|
||||
const firstLevel = await ApprovalLevel.findOne({
|
||||
where: { requestId: payload.requestId, levelNumber: 1 }
|
||||
});
|
||||
|
||||
const firstApprover = firstLevel ? await User.findByPk((firstLevel as any).approverId) : null;
|
||||
|
||||
await emailNotificationService.sendRequestCreated(
|
||||
requestData,
|
||||
initiatorData,
|
||||
firstApprover ? firstApprover.toJSON() : null
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'assignment':
|
||||
{
|
||||
// Fetch the approver user (the one being assigned)
|
||||
const approverUser = await User.findByPk(userId);
|
||||
|
||||
if (!approverUser) {
|
||||
logger.warn(`[Email] Approver user ${userId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const allLevels = await ApprovalLevel.findAll({
|
||||
where: { requestId: payload.requestId },
|
||||
order: [['levelNumber', 'ASC']]
|
||||
});
|
||||
const isMultiLevel = allLevels.length > 1;
|
||||
|
||||
const approverData = approverUser.toJSON();
|
||||
|
||||
// Add level number if available
|
||||
const currentLevel = allLevels.find((l: any) => l.approverId === userId);
|
||||
if (currentLevel) {
|
||||
(approverData as any).levelNumber = (currentLevel as any).levelNumber;
|
||||
}
|
||||
|
||||
await emailNotificationService.sendApprovalRequest(
|
||||
requestData,
|
||||
approverData,
|
||||
initiatorData,
|
||||
isMultiLevel,
|
||||
isMultiLevel ? allLevels.map((l: any) => l.toJSON()) : undefined
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'approval':
|
||||
{
|
||||
const approvedLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: payload.requestId,
|
||||
status: 'APPROVED'
|
||||
},
|
||||
order: [['approvedAt', 'DESC']]
|
||||
});
|
||||
|
||||
const allLevels = await ApprovalLevel.findAll({
|
||||
where: { requestId: payload.requestId },
|
||||
order: [['levelNumber', 'ASC']]
|
||||
});
|
||||
|
||||
const approvedCount = allLevels.filter((l: any) => l.status === 'APPROVED').length;
|
||||
const isFinalApproval = approvedCount === allLevels.length;
|
||||
|
||||
const nextLevel = isFinalApproval ? null : allLevels.find((l: any) => l.status === 'PENDING');
|
||||
const nextApprover = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
|
||||
|
||||
await emailNotificationService.sendApprovalConfirmation(
|
||||
requestData,
|
||||
user, // Approver who just approved
|
||||
initiatorData,
|
||||
isFinalApproval,
|
||||
nextApprover ? nextApprover.toJSON() : undefined
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rejection':
|
||||
{
|
||||
const rejectedLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: payload.requestId,
|
||||
status: 'REJECTED'
|
||||
}
|
||||
});
|
||||
|
||||
await emailNotificationService.sendRejectionNotification(
|
||||
requestData,
|
||||
user, // Approver who rejected
|
||||
initiatorData,
|
||||
(rejectedLevel as any)?.comments || payload.metadata?.rejectionReason || 'No reason provided'
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tat_reminder':
|
||||
case 'tat_breach':
|
||||
{
|
||||
// Extract TAT info from metadata or payload
|
||||
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({
|
||||
where: {
|
||||
requestId: payload.requestId,
|
||||
status: 'PENDING'
|
||||
},
|
||||
order: [['levelNumber', 'ASC']]
|
||||
});
|
||||
|
||||
const currentApprover = currentLevel ? await User.findByPk((currentLevel as any).approverId) : null;
|
||||
const resumedBy = payload.metadata?.resumedBy;
|
||||
const pauseDuration = payload.metadata?.pauseDuration || 'Unknown';
|
||||
|
||||
await emailNotificationService.sendWorkflowResumed(
|
||||
requestData,
|
||||
currentApprover ? currentApprover.toJSON() : user,
|
||||
initiatorData,
|
||||
resumedBy,
|
||||
pauseDuration
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'closed':
|
||||
{
|
||||
const closureData = {
|
||||
conclusionRemark: payload.metadata?.conclusionRemark,
|
||||
workNotesCount: payload.metadata?.workNotesCount || 0,
|
||||
documentsCount: payload.metadata?.documentsCount || 0
|
||||
};
|
||||
|
||||
await emailNotificationService.sendRequestClosed(
|
||||
requestData,
|
||||
user,
|
||||
closureData
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
notificationService.configure();
|
||||
|
||||
|
||||
|
||||
|
||||
@ -231,6 +231,41 @@ export class PauseService {
|
||||
|
||||
logger.info(`[Pause] Workflow ${requestId} paused at level ${(level as any).levelNumber} by ${userId}`);
|
||||
|
||||
// Schedule dedicated auto-resume job for this workflow
|
||||
try {
|
||||
const { pauseResumeQueue } = require('../queues/pauseResumeQueue');
|
||||
if (pauseResumeQueue && resumeDate) {
|
||||
const delay = resumeDate.getTime() - now.getTime();
|
||||
|
||||
if (delay > 0) {
|
||||
const jobId = `resume-${requestId}-${(level as any).levelId}`;
|
||||
|
||||
await pauseResumeQueue.add(
|
||||
'auto-resume-workflow',
|
||||
{
|
||||
type: 'auto-resume-workflow',
|
||||
requestId,
|
||||
levelId: (level as any).levelId,
|
||||
scheduledResumeDate: resumeDate.toISOString()
|
||||
},
|
||||
{
|
||||
jobId,
|
||||
delay, // Exact delay in milliseconds until resume time
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`[Pause] Scheduled dedicated auto-resume job ${jobId} for ${resumeDate.toISOString()} (delay: ${Math.round(delay / 1000 / 60)} minutes)`);
|
||||
} else {
|
||||
logger.warn(`[Pause] Resume date ${resumeDate.toISOString()} is in the past, skipping job scheduling`);
|
||||
}
|
||||
}
|
||||
} catch (queueError) {
|
||||
logger.warn(`[Pause] Could not schedule dedicated auto-resume job:`, queueError);
|
||||
// Continue with pause even if job scheduling fails (hourly check will handle it as fallback)
|
||||
}
|
||||
|
||||
// Emit real-time update to all users viewing this request
|
||||
emitToRequestRoom(requestId, 'request:updated', {
|
||||
requestId,
|
||||
@ -319,6 +354,37 @@ export class PauseService {
|
||||
levelStartTime: now // This is the new start time from resume
|
||||
});
|
||||
|
||||
// Cancel any scheduled auto-resume job (if exists)
|
||||
try {
|
||||
const { pauseResumeQueue } = require('../queues/pauseResumeQueue');
|
||||
if (pauseResumeQueue) {
|
||||
// Try to remove job by specific ID pattern first (more efficient)
|
||||
const jobId = `resume-${requestId}-${(level as any).levelId}`;
|
||||
try {
|
||||
const specificJob = await pauseResumeQueue.getJob(jobId);
|
||||
if (specificJob) {
|
||||
await specificJob.remove();
|
||||
logger.info(`[Pause] Cancelled scheduled auto-resume job ${jobId} for workflow ${requestId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// Job might not exist, which is fine
|
||||
}
|
||||
|
||||
// Also check for any other jobs for this request (fallback for old jobs)
|
||||
const scheduledJobs = await pauseResumeQueue.getJobs(['delayed', 'waiting']);
|
||||
const otherJobs = scheduledJobs.filter((job: any) =>
|
||||
job.data.requestId === requestId && job.id !== jobId
|
||||
);
|
||||
for (const job of otherJobs) {
|
||||
await job.remove();
|
||||
logger.info(`[Pause] Cancelled legacy auto-resume job ${job.id} for workflow ${requestId}`);
|
||||
}
|
||||
}
|
||||
} catch (queueError) {
|
||||
logger.warn(`[Pause] Could not cancel scheduled auto-resume job:`, queueError);
|
||||
// Continue with resume even if job cancellation fails
|
||||
}
|
||||
|
||||
// Update workflow - restore previous status or default to PENDING
|
||||
const pauseSnapshot = (workflow as any).pauseTatSnapshot || {};
|
||||
const previousStatus = pauseSnapshot.previousStatus || WorkflowStatus.PENDING;
|
||||
|
||||
@ -10,7 +10,7 @@ import { CreateWorkflowRequest, UpdateWorkflowRequest } from '../types/workflow.
|
||||
import { generateRequestNumber, calculateTATDays } from '@utils/helpers';
|
||||
import logger, { logWorkflowEvent, logWithContext } from '@utils/logger';
|
||||
import { WorkflowStatus, ParticipantType, ApprovalStatus } from '../types/common.types';
|
||||
import { Op, QueryTypes } from 'sequelize';
|
||||
import { Op, QueryTypes, literal } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@ -816,6 +816,12 @@ export class WorkflowService {
|
||||
// Calculate approved levels count
|
||||
const approvedLevelsCount = approvals.filter((a: any) => a.status === 'APPROVED').length;
|
||||
|
||||
// Determine closure type for CLOSED requests
|
||||
// If ANY level was rejected, it's a "rejected" closure
|
||||
// If ALL completed levels were approved, it's an "approved" closure
|
||||
const hasRejectedLevel = approvals.some((a: any) => a.status === 'REJECTED');
|
||||
const closureType = hasRejectedLevel ? 'rejected' : 'approved';
|
||||
|
||||
const priority = ((wf as any).priority || 'standard').toString().toLowerCase();
|
||||
|
||||
// Calculate OVERALL request SLA based on cumulative elapsed hours from all levels
|
||||
@ -960,6 +966,7 @@ export class WorkflowService {
|
||||
createdAt: (wf as any).createdAt,
|
||||
closureDate: (wf as any).closureDate,
|
||||
conclusionRemark: (wf as any).conclusionRemark,
|
||||
closureType: closureType, // 'approved' or 'rejected' - indicates path to closure
|
||||
initiator: (wf as any).initiator,
|
||||
department: (wf as any).initiator?.department,
|
||||
totalLevels: (wf as any).totalLevels,
|
||||
@ -2069,26 +2076,45 @@ export class WorkflowService {
|
||||
// Build query conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// 1. Requests where user was approver/spectator (show APPROVED, REJECTED, CLOSED)
|
||||
const approverSpectatorStatuses = [
|
||||
WorkflowStatus.APPROVED as any,
|
||||
WorkflowStatus.REJECTED as any,
|
||||
// 1. Requests where user was approver/spectator (show ONLY CLOSED)
|
||||
// Closed requests are the final state after approval/rejection + conclusion
|
||||
const closedStatus = [
|
||||
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
||||
'APPROVED',
|
||||
'REJECTED',
|
||||
'CLOSED'
|
||||
] as any;
|
||||
|
||||
if (allRequestIds.length > 0) {
|
||||
const approverConditionParts: any[] = [
|
||||
{ requestId: { [Op.in]: allRequestIds } }
|
||||
{ requestId: { [Op.in]: allRequestIds } },
|
||||
{ status: { [Op.in]: closedStatus } } // Only CLOSED requests
|
||||
];
|
||||
|
||||
// Apply status filter
|
||||
if (filters?.status && filters.status !== 'all') {
|
||||
approverConditionParts.push({ status: filters.status.toUpperCase() });
|
||||
} else {
|
||||
approverConditionParts.push({ status: { [Op.in]: approverSpectatorStatuses } });
|
||||
// Apply closure type filter (approved/rejected before closure)
|
||||
if (filters?.status && filters?.status !== 'all') {
|
||||
const filterStatus = filters.status.toLowerCase();
|
||||
if (filterStatus === 'rejected') {
|
||||
// Closed after rejection: has at least one REJECTED approval level
|
||||
approverConditionParts.push({
|
||||
[Op.and]: [
|
||||
literal(`EXISTS (
|
||||
SELECT 1 FROM approval_levels al
|
||||
WHERE al.request_id = "WorkflowRequest"."request_id"
|
||||
AND al.status = 'REJECTED'
|
||||
)`)
|
||||
]
|
||||
});
|
||||
} else if (filterStatus === 'approved') {
|
||||
// Closed after approval: no REJECTED levels (all approved)
|
||||
approverConditionParts.push({
|
||||
[Op.and]: [
|
||||
literal(`NOT EXISTS (
|
||||
SELECT 1 FROM approval_levels al
|
||||
WHERE al.request_id = "WorkflowRequest"."request_id"
|
||||
AND al.status = 'REJECTED'
|
||||
)`)
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
@ -2114,31 +2140,44 @@ export class WorkflowService {
|
||||
whereConditions.push(approverCondition);
|
||||
}
|
||||
|
||||
// 2. Requests where user is initiator (show ONLY REJECTED or CLOSED, NOT APPROVED)
|
||||
// APPROVED means initiator still needs to finalize conclusion
|
||||
// 2. Requests where user is initiator (show ONLY CLOSED)
|
||||
// CLOSED means request has been finalized with conclusion
|
||||
const initiatorStatuses = [
|
||||
WorkflowStatus.REJECTED as any,
|
||||
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
||||
'REJECTED',
|
||||
'CLOSED'
|
||||
] as any;
|
||||
|
||||
const initiatorConditionParts: any[] = [
|
||||
{ initiatorId: userId }
|
||||
{ initiatorId: userId },
|
||||
{ status: { [Op.in]: initiatorStatuses } } // Only CLOSED requests
|
||||
];
|
||||
|
||||
// Apply status filter
|
||||
if (filters?.status && filters.status !== 'all') {
|
||||
const filterStatus = filters.status.toUpperCase();
|
||||
// Only apply if status is REJECTED or CLOSED (not APPROVED for initiator)
|
||||
if (filterStatus === 'REJECTED' || filterStatus === 'CLOSED') {
|
||||
initiatorConditionParts.push({ status: filterStatus });
|
||||
} else {
|
||||
// If filtering for APPROVED, don't include initiator requests
|
||||
initiatorConditionParts.push({ status: { [Op.in]: [] } }); // Empty set - no results
|
||||
// Apply closure type filter (approved/rejected before closure)
|
||||
if (filters?.status && filters?.status !== 'all') {
|
||||
const filterStatus = filters.status.toLowerCase();
|
||||
if (filterStatus === 'rejected') {
|
||||
// Closed after rejection: has at least one REJECTED approval level
|
||||
initiatorConditionParts.push({
|
||||
[Op.and]: [
|
||||
literal(`EXISTS (
|
||||
SELECT 1 FROM approval_levels al
|
||||
WHERE al.request_id = "WorkflowRequest"."request_id"
|
||||
AND al.status = 'REJECTED'
|
||||
)`)
|
||||
]
|
||||
});
|
||||
} else if (filterStatus === 'approved') {
|
||||
// Closed after approval: no REJECTED levels (all approved)
|
||||
initiatorConditionParts.push({
|
||||
[Op.and]: [
|
||||
literal(`NOT EXISTS (
|
||||
SELECT 1 FROM approval_levels al
|
||||
WHERE al.request_id = "WorkflowRequest"."request_id"
|
||||
AND al.status = 'REJECTED'
|
||||
)`)
|
||||
]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
initiatorConditionParts.push({ status: { [Op.in]: initiatorStatuses } });
|
||||
}
|
||||
|
||||
// Apply priority filter
|
||||
@ -2188,7 +2227,7 @@ export class WorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch closed/rejected/approved requests (including finalized ones)
|
||||
// Fetch only CLOSED requests (already finalized with conclusion)
|
||||
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||
where,
|
||||
offset,
|
||||
@ -2198,8 +2237,19 @@ export class WorkflowService {
|
||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
||||
],
|
||||
});
|
||||
const data = await this.enrichForCards(rows);
|
||||
return { data, pagination: { page, limit, total: count, totalPages: Math.ceil(count / limit) || 1 } };
|
||||
|
||||
// Enrich with SLA and closure type
|
||||
const enrichedData = await this.enrichForCards(rows);
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
totalPages: Math.ceil(count / limit) || 1
|
||||
}
|
||||
};
|
||||
}
|
||||
async createWorkflow(initiatorId: string, workflowData: CreateWorkflowRequest, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<WorkflowRequest> {
|
||||
try {
|
||||
|
||||
53
src/utils/queueMetrics.ts
Normal file
53
src/utils/queueMetrics.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Queue Metrics Collection Initializer
|
||||
* Collects BullMQ queue metrics and exposes them to Prometheus
|
||||
*/
|
||||
|
||||
import { tatQueue } from '../queues/tatQueue';
|
||||
import { pauseResumeQueue } from '../queues/pauseResumeQueue';
|
||||
import { startQueueMetricsCollection } from '../middlewares/metrics.middleware';
|
||||
import logger from './logger';
|
||||
|
||||
let metricsCollectionInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initialize queue metrics collection
|
||||
* Should be called after queues are initialized
|
||||
*/
|
||||
export function initializeQueueMetrics(): void {
|
||||
try {
|
||||
const queues: { name: string; queue: any }[] = [];
|
||||
|
||||
if (tatQueue) {
|
||||
queues.push({ name: 'tatQueue', queue: tatQueue });
|
||||
}
|
||||
|
||||
if (pauseResumeQueue) {
|
||||
queues.push({ name: 'pauseResumeQueue', queue: pauseResumeQueue });
|
||||
}
|
||||
|
||||
if (queues.length === 0) {
|
||||
logger.warn('[Queue Metrics] No queues available for metrics collection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start collecting metrics every 15 seconds
|
||||
metricsCollectionInterval = startQueueMetricsCollection(queues, 15000);
|
||||
|
||||
logger.info(`[Queue Metrics] ✅ Started metrics collection for ${queues.length} queue(s)`);
|
||||
} catch (error) {
|
||||
logger.error('[Queue Metrics] Failed to initialize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop queue metrics collection
|
||||
*/
|
||||
export function stopQueueMetrics(): void {
|
||||
if (metricsCollectionInterval) {
|
||||
clearInterval(metricsCollectionInterval);
|
||||
metricsCollectionInterval = null;
|
||||
logger.info('[Queue Metrics] Stopped metrics collection');
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ export const ssoCallbackSchema = z.object({
|
||||
});
|
||||
|
||||
export const refreshTokenSchema = z.object({
|
||||
refreshToken: z.string().min(1, 'Refresh token is required'),
|
||||
refreshToken: z.string().min(1, 'Refresh token is required').optional(),
|
||||
});
|
||||
|
||||
export const tokenExchangeSchema = z.object({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user