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
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@ -79,6 +91,37 @@ LOKI_HOST=http://localhost:3100
|
|||||||
|
|
||||||
## 📊 Available Dashboards
|
## 📊 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
|
### 1. RE Workflow Overview
|
||||||
Pre-configured dashboard with:
|
Pre-configured dashboard with:
|
||||||
- **API Metrics**: Request rate, error rate, latency percentiles
|
- **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'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
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
|
# PROMETHEUS - Metrics Collection
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@ -127,6 +147,27 @@ services:
|
|||||||
- monitoring_network
|
- monitoring_network
|
||||||
restart: unless-stopped
|
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)
|
# ALERTMANAGER - Alert Notifications (Optional)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
@ -157,6 +198,8 @@ networks:
|
|||||||
# VOLUMES
|
# VOLUMES
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
volumes:
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
name: re_redis_data
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
name: re_prometheus_data
|
name: re_prometheus_data
|
||||||
loki_data:
|
loki_data:
|
||||||
|
|||||||
@ -47,7 +47,7 @@
|
|||||||
"pluginVersion": "10.2.2",
|
"pluginVersion": "10.2.2",
|
||||||
"targets": [
|
"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"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -83,7 +83,7 @@
|
|||||||
},
|
},
|
||||||
"targets": [
|
"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"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -119,7 +119,7 @@
|
|||||||
},
|
},
|
||||||
"targets": [
|
"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"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -476,23 +476,28 @@
|
|||||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||||
},
|
},
|
||||||
"targets": [
|
"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\"}",
|
"expr": "nodejs_heap_size_used_bytes{job=\"re-workflow-backend\"}",
|
||||||
"legendFormat": "Heap Used",
|
"legendFormat": "Heap Used",
|
||||||
"refId": "B"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "nodejs_heap_size_total_bytes{job=\"re-workflow-backend\"}",
|
"expr": "nodejs_heap_size_total_bytes{job=\"re-workflow-backend\"}",
|
||||||
"legendFormat": "Heap Total",
|
"legendFormat": "Heap Total",
|
||||||
|
"refId": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "nodejs_external_memory_bytes{job=\"re-workflow-backend\"}",
|
||||||
|
"legendFormat": "External Memory",
|
||||||
"refId": "C"
|
"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"
|
"type": "timeseries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -535,9 +540,19 @@
|
|||||||
"expr": "nodejs_eventloop_lag_seconds{job=\"re-workflow-backend\"}",
|
"expr": "nodejs_eventloop_lag_seconds{job=\"re-workflow-backend\"}",
|
||||||
"legendFormat": "Event Loop Lag",
|
"legendFormat": "Event Loop Lag",
|
||||||
"refId": "A"
|
"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"
|
"type": "timeseries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -587,7 +602,7 @@
|
|||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Active Handles & Requests",
|
"title": "Node.js Active Handles & Requests",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -628,12 +643,436 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "rate(process_cpu_seconds_total{job=\"re-workflow-backend\"}[5m])",
|
"expr": "rate(process_cpu_seconds_total{job=\"re-workflow-backend\"}[5m])",
|
||||||
"legendFormat": "CPU Usage",
|
"legendFormat": "Process CPU Usage",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "CPU Usage",
|
"title": "Node.js Process CPU Usage",
|
||||||
"type": "timeseries"
|
"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",
|
"refresh": "30s",
|
||||||
|
|||||||
@ -72,13 +72,14 @@ scrape_configs:
|
|||||||
# service: 'postgresql'
|
# service: 'postgresql'
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Redis Metrics (if using redis_exporter)
|
# Redis Metrics
|
||||||
# ============================================
|
# ============================================
|
||||||
# - job_name: 'redis'
|
- job_name: 'redis'
|
||||||
# static_configs:
|
static_configs:
|
||||||
# - targets: ['redis-exporter:9121']
|
- targets: ['redis-exporter:9121']
|
||||||
# labels:
|
labels:
|
||||||
# service: 'redis'
|
service: 'redis'
|
||||||
|
environment: 'development'
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Loki Metrics
|
# 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",
|
"@anthropic-ai/sdk": "^0.68.0",
|
||||||
"@google-cloud/storage": "^7.14.0",
|
"@google-cloud/storage": "^7.14.0",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
"openai": "^6.8.1",
|
"openai": "^6.8.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@ -62,7 +64,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.19.1",
|
||||||
"@types/passport": "^1.0.16",
|
"@types/passport": "^1.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
|
|||||||
@ -107,7 +107,7 @@ export class AuthController {
|
|||||||
async refreshToken(req: Request, res: Response): Promise<void> {
|
async refreshToken(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Try to get refresh token from request body first, then from cookies
|
// Try to get refresh token from request body first, then from cookies
|
||||||
let refreshToken: string;
|
let refreshToken: string | undefined;
|
||||||
|
|
||||||
if (req.body?.refreshToken) {
|
if (req.body?.refreshToken) {
|
||||||
const validated = validateRefreshToken(req.body);
|
const validated = validateRefreshToken(req.body);
|
||||||
@ -115,8 +115,16 @@ export class AuthController {
|
|||||||
} else if ((req as any).cookies?.refreshToken) {
|
} else if ((req as any).cookies?.refreshToken) {
|
||||||
// Fallback to cookie if available (requires cookie-parser middleware)
|
// Fallback to cookie if available (requires cookie-parser middleware)
|
||||||
refreshToken = (req as any).cookies.refreshToken;
|
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);
|
const newAccessToken = await this.authService.refreshAccessToken(refreshToken);
|
||||||
@ -126,7 +134,7 @@ export class AuthController {
|
|||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isProduction,
|
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
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -340,7 +348,7 @@ export class AuthController {
|
|||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isProduction,
|
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
|
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();
|
const register = new client.Registry();
|
||||||
|
|
||||||
// Add default Node.js metrics (memory, CPU, event loop, GC, etc.)
|
// 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({
|
client.collectDefaultMetrics({
|
||||||
register,
|
register,
|
||||||
prefix: 'nodejs_',
|
|
||||||
labels: { app: 're-workflow', service: 'backend' },
|
labels: { app: 're-workflow', service: 'backend' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -132,6 +132,54 @@ export const aiServiceDuration = new client.Histogram({
|
|||||||
registers: [register],
|
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
|
// 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 the registry for advanced use cases
|
||||||
export { register };
|
export { register };
|
||||||
|
|
||||||
|
|||||||
@ -4,14 +4,26 @@ import logger from '../utils/logger';
|
|||||||
|
|
||||||
export async function handlePauseResumeJob(job: Job): Promise<void> {
|
export async function handlePauseResumeJob(job: Job): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { type } = job.data;
|
const { type, requestId, levelId, scheduledResumeDate } = job.data;
|
||||||
|
|
||||||
if (type === 'check_and_resume') {
|
if (type === 'auto-resume-workflow') {
|
||||||
logger.info(`[Pause Resume Processor] Processing auto-resume check job ${job.id}`);
|
// 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();
|
const resumedCount = await pauseService.checkAndResumePausedWorkflows();
|
||||||
|
|
||||||
if (resumedCount > 0) {
|
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 {
|
} else {
|
||||||
logger.debug('[Pause Resume Processor] No workflows to auto-resume');
|
logger.debug('[Pause Resume Processor] No workflows to auto-resume');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { tatQueue } from '../queues/tatQueue';
|
import { tatQueue } from '../queues/tatQueue';
|
||||||
import { tatWorker } from '../queues/tatWorker';
|
import { tatWorker } from '../queues/tatWorker';
|
||||||
|
import { pauseResumeQueue } from '../queues/pauseResumeQueue';
|
||||||
|
import { pauseResumeWorker } from '../queues/pauseResumeWorker';
|
||||||
import { TatAlert } from '@models/TatAlert';
|
import { TatAlert } from '@models/TatAlert';
|
||||||
import { ApprovalLevel } from '@models/ApprovalLevel';
|
import { ApprovalLevel } from '@models/ApprovalLevel';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@ -240,63 +242,123 @@ router.post('/tat-calculate', async (req: Request, res: Response): Promise<void>
|
|||||||
*/
|
*/
|
||||||
router.get('/queue-status', async (req: Request, res: Response): Promise<void> => {
|
router.get('/queue-status', async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
if (!tatQueue || !tatWorker) {
|
const response: any = {
|
||||||
res.json({
|
timestamp: new Date().toISOString(),
|
||||||
error: 'Queue or Worker not available',
|
tatQueue: null,
|
||||||
|
pauseResumeQueue: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// TAT Queue Status
|
||||||
|
if (tatQueue && tatWorker) {
|
||||||
|
const [tatWaiting, tatDelayed, tatActive, tatCompleted, tatFailed] = await Promise.all([
|
||||||
|
tatQueue.getJobCounts('waiting'),
|
||||||
|
tatQueue.getJobCounts('delayed'),
|
||||||
|
tatQueue.getJobCounts('active'),
|
||||||
|
tatQueue.getJobCounts('completed'),
|
||||||
|
tatQueue.getJobCounts('failed')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tatWaitingJobs = await tatQueue.getJobs(['waiting'], 0, 10);
|
||||||
|
const tatDelayedJobs = await tatQueue.getJobs(['delayed'], 0, 10);
|
||||||
|
const tatActiveJobs = await tatQueue.getJobs(['active'], 0, 10);
|
||||||
|
|
||||||
|
response.tatQueue = {
|
||||||
|
queue: {
|
||||||
|
name: tatQueue.name,
|
||||||
|
available: true
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
available: true,
|
||||||
|
running: tatWorker.isRunning(),
|
||||||
|
paused: tatWorker.isPaused(),
|
||||||
|
closing: tatWorker.closing,
|
||||||
|
concurrency: tatWorker.opts.concurrency,
|
||||||
|
autorun: tatWorker.opts.autorun
|
||||||
|
},
|
||||||
|
jobCounts: {
|
||||||
|
waiting: tatWaiting.waiting,
|
||||||
|
delayed: tatDelayed.delayed,
|
||||||
|
active: tatActive.active,
|
||||||
|
completed: tatCompleted.completed,
|
||||||
|
failed: tatFailed.failed
|
||||||
|
},
|
||||||
|
recentJobs: {
|
||||||
|
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,
|
||||||
|
delay: j.opts.delay,
|
||||||
|
timestamp: j.timestamp,
|
||||||
|
scheduledFor: new Date(j.timestamp + (j.opts.delay || 0)).toISOString()
|
||||||
|
})),
|
||||||
|
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,
|
queueAvailable: !!tatQueue,
|
||||||
workerAvailable: !!tatWorker
|
workerAvailable: !!tatWorker
|
||||||
});
|
};
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get job counts
|
// Pause/Resume Queue Status
|
||||||
const [waiting, delayed, active, completed, failed] = await Promise.all([
|
if (pauseResumeQueue && pauseResumeWorker) {
|
||||||
tatQueue.getJobCounts('waiting'),
|
const [prWaiting, prDelayed, prActive, prCompleted, prFailed] = await Promise.all([
|
||||||
tatQueue.getJobCounts('delayed'),
|
pauseResumeQueue.getJobCounts('waiting'),
|
||||||
tatQueue.getJobCounts('active'),
|
pauseResumeQueue.getJobCounts('delayed'),
|
||||||
tatQueue.getJobCounts('completed'),
|
pauseResumeQueue.getJobCounts('active'),
|
||||||
tatQueue.getJobCounts('failed')
|
pauseResumeQueue.getJobCounts('completed'),
|
||||||
]);
|
pauseResumeQueue.getJobCounts('failed')
|
||||||
|
]);
|
||||||
|
|
||||||
// Get all jobs in various states
|
const prWaitingJobs = await pauseResumeQueue.getJobs(['waiting'], 0, 10);
|
||||||
const waitingJobs = await tatQueue.getJobs(['waiting'], 0, 10);
|
const prDelayedJobs = await pauseResumeQueue.getJobs(['delayed'], 0, 10);
|
||||||
const delayedJobs = await tatQueue.getJobs(['delayed'], 0, 10);
|
const prActiveJobs = await pauseResumeQueue.getJobs(['active'], 0, 10);
|
||||||
const activeJobs = await tatQueue.getJobs(['active'], 0, 10);
|
|
||||||
|
|
||||||
res.json({
|
response.pauseResumeQueue = {
|
||||||
timestamp: new Date().toISOString(),
|
queue: {
|
||||||
queue: {
|
name: pauseResumeQueue.name,
|
||||||
name: tatQueue.name,
|
available: true
|
||||||
available: true
|
},
|
||||||
},
|
worker: {
|
||||||
worker: {
|
available: true,
|
||||||
available: true,
|
running: pauseResumeWorker.isRunning(),
|
||||||
running: tatWorker.isRunning(),
|
paused: pauseResumeWorker.isPaused(),
|
||||||
paused: tatWorker.isPaused(),
|
closing: pauseResumeWorker.closing,
|
||||||
closing: tatWorker.closing,
|
concurrency: pauseResumeWorker.opts.concurrency,
|
||||||
concurrency: tatWorker.opts.concurrency,
|
autorun: pauseResumeWorker.opts.autorun
|
||||||
autorun: tatWorker.opts.autorun
|
},
|
||||||
},
|
jobCounts: {
|
||||||
jobCounts: {
|
waiting: prWaiting.waiting,
|
||||||
waiting: waiting.waiting,
|
delayed: prDelayed.delayed,
|
||||||
delayed: delayed.delayed,
|
active: prActive.active,
|
||||||
active: active.active,
|
completed: prCompleted.completed,
|
||||||
completed: completed.completed,
|
failed: prFailed.failed
|
||||||
failed: failed.failed
|
},
|
||||||
},
|
recentJobs: {
|
||||||
recentJobs: {
|
waiting: prWaitingJobs.map(j => ({ id: j.id, name: j.name, data: j.data })),
|
||||||
waiting: waitingJobs.map(j => ({ id: j.id, name: j.name, data: j.data })),
|
delayed: prDelayedJobs.map(j => ({
|
||||||
delayed: delayedJobs.map(j => ({
|
id: j.id,
|
||||||
id: j.id,
|
name: j.name,
|
||||||
name: j.name,
|
data: j.data,
|
||||||
data: j.data,
|
delay: j.opts.delay,
|
||||||
delay: j.opts.delay,
|
timestamp: j.timestamp,
|
||||||
timestamp: j.timestamp,
|
scheduledFor: new Date(j.timestamp + (j.opts.delay || 0)).toISOString()
|
||||||
scheduledFor: new Date(j.timestamp + (j.opts.delay || 0)).toISOString()
|
})),
|
||||||
})),
|
active: prActiveJobs.map(j => ({ id: j.id, name: j.name, data: j.data }))
|
||||||
active: activeJobs.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) {
|
} catch (error: any) {
|
||||||
logger.error('[Debug] Error checking queue status:', error);
|
logger.error('[Debug] Error checking queue status:', error);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { initializeHolidaysCache } from './utils/tatTimeUtils';
|
|||||||
import { seedDefaultConfigurations } from './services/configSeed.service';
|
import { seedDefaultConfigurations } from './services/configSeed.service';
|
||||||
import { startPauseResumeJob } from './jobs/pauseResumeJob';
|
import { startPauseResumeJob } from './jobs/pauseResumeJob';
|
||||||
import './queues/pauseResumeWorker'; // Initialize pause resume worker
|
import './queues/pauseResumeWorker'; // Initialize pause resume worker
|
||||||
|
import { initializeQueueMetrics, stopQueueMetrics } from './utils/queueMetrics';
|
||||||
|
|
||||||
const PORT: number = parseInt(process.env.PORT || '5000', 10);
|
const PORT: number = parseInt(process.env.PORT || '5000', 10);
|
||||||
|
|
||||||
@ -34,6 +35,9 @@ const startServer = async (): Promise<void> => {
|
|||||||
// Start scheduled jobs
|
// Start scheduled jobs
|
||||||
startPauseResumeJob();
|
startPauseResumeJob();
|
||||||
|
|
||||||
|
// Initialize queue metrics collection for Prometheus
|
||||||
|
initializeQueueMetrics();
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
|
console.log(`🚀 Server running on port ${PORT} | ${process.env.NODE_ENV || 'development'}`);
|
||||||
});
|
});
|
||||||
@ -46,11 +50,13 @@ const startServer = async (): Promise<void> => {
|
|||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
console.log('🛑 SIGTERM signal received: closing HTTP server');
|
console.log('🛑 SIGTERM signal received: closing HTTP server');
|
||||||
|
stopQueueMetrics();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('🛑 SIGINT signal received: closing HTTP server');
|
console.log('🛑 SIGINT signal received: closing HTTP server');
|
||||||
|
stopQueueMetrics();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2331,14 +2331,6 @@ export class DashboardService {
|
|||||||
let dateFilter = '';
|
let dateFilter = '';
|
||||||
const replacements: any = { approverId };
|
const replacements: any = { approverId };
|
||||||
|
|
||||||
logger.info(`[Dashboard] Single approver stats - Received filters:`, {
|
|
||||||
dateRange,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
priority,
|
|
||||||
slaCompliance
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dateRange) {
|
if (dateRange) {
|
||||||
const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate);
|
const dateFilterObj = this.parseDateRange(dateRange, startDate, endDate);
|
||||||
dateFilter = `
|
dateFilter = `
|
||||||
@ -2349,12 +2341,6 @@ export class DashboardService {
|
|||||||
`;
|
`;
|
||||||
replacements.dateStart = dateFilterObj.start;
|
replacements.dateStart = dateFilterObj.start;
|
||||||
replacements.dateEnd = dateFilterObj.end;
|
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
|
// 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 logger, { logNotificationEvent } from '@utils/logger';
|
||||||
import { Subscription } from '@models/Subscription';
|
import { Subscription } from '@models/Subscription';
|
||||||
import { Notification } from '@models/Notification';
|
import { Notification } from '@models/Notification';
|
||||||
|
import {
|
||||||
|
shouldSendEmail,
|
||||||
|
shouldSendEmailWithOverride,
|
||||||
|
shouldSendInAppNotification,
|
||||||
|
EmailNotificationType
|
||||||
|
} from '../emailtemplates/emailPreferences.helper';
|
||||||
|
|
||||||
type PushSubscription = any; // Web Push protocol JSON
|
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
|
* Send notification to users - saves to DB, sends via push/socket, and emails
|
||||||
* Respects user notification preferences
|
* Respects user notification preferences for all channels
|
||||||
|
* Automatically sends email for applicable notification types
|
||||||
*/
|
*/
|
||||||
async sendToUsers(userIds: string[], payload: NotificationPayload) {
|
async sendToUsers(userIds: string[], payload: NotificationPayload) {
|
||||||
const message = JSON.stringify(payload);
|
const message = JSON.stringify(payload);
|
||||||
@ -133,8 +140,10 @@ class NotificationService {
|
|||||||
|
|
||||||
const sentVia: string[] = [];
|
const sentVia: string[] = [];
|
||||||
|
|
||||||
// 1. Save notification to database for in-app display (if enabled)
|
// 1. Check admin + user preferences for in-app notifications
|
||||||
if (user.inAppNotificationsEnabled) {
|
const canSendInApp = await shouldSendInAppNotification(userId, payload.type || 'general');
|
||||||
|
|
||||||
|
if (canSendInApp && user.inAppNotificationsEnabled) {
|
||||||
const notification = await Notification.create({
|
const notification = await Notification.create({
|
||||||
userId,
|
userId,
|
||||||
requestId: payload.requestId,
|
requestId: payload.requestId,
|
||||||
@ -173,7 +182,7 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Send push notification (if enabled and user has subscriptions)
|
// 3. Send push notification (if enabled and user has subscriptions)
|
||||||
if (user.pushNotificationsEnabled) {
|
if (user.pushNotificationsEnabled && canSendInApp) {
|
||||||
let subs = this.userIdToSubscriptions.get(userId) || [];
|
let subs = this.userIdToSubscriptions.get(userId) || [];
|
||||||
// Load from DB if memory empty
|
// Load from DB if memory empty
|
||||||
if (subs.length === 0) {
|
if (subs.length === 0) {
|
||||||
@ -216,14 +225,20 @@ class NotificationService {
|
|||||||
logger.info(`[Notification] Push notifications disabled for user ${userId}, skipping push`);
|
logger.info(`[Notification] Push notifications disabled for user ${userId}, skipping push`);
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
// 4. Send email notification for applicable types (async, don't wait)
|
||||||
// if (user.emailNotificationsEnabled) {
|
console.log(`[DEBUG] Checking email for notification type: ${payload.type}`);
|
||||||
// // Send email notification
|
this.sendEmailNotification(userId, user, payload).catch(emailError => {
|
||||||
// sentVia.push('EMAIL');
|
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) {
|
} catch (error) {
|
||||||
logger.error(`[Notification] Failed to create notification for user ${userId}:`, 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();
|
export const notificationService = new NotificationService();
|
||||||
notificationService.configure();
|
notificationService.configure();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -231,6 +231,41 @@ export class PauseService {
|
|||||||
|
|
||||||
logger.info(`[Pause] Workflow ${requestId} paused at level ${(level as any).levelNumber} by ${userId}`);
|
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
|
// Emit real-time update to all users viewing this request
|
||||||
emitToRequestRoom(requestId, 'request:updated', {
|
emitToRequestRoom(requestId, 'request:updated', {
|
||||||
requestId,
|
requestId,
|
||||||
@ -319,6 +354,37 @@ export class PauseService {
|
|||||||
levelStartTime: now // This is the new start time from resume
|
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
|
// Update workflow - restore previous status or default to PENDING
|
||||||
const pauseSnapshot = (workflow as any).pauseTatSnapshot || {};
|
const pauseSnapshot = (workflow as any).pauseTatSnapshot || {};
|
||||||
const previousStatus = pauseSnapshot.previousStatus || WorkflowStatus.PENDING;
|
const previousStatus = pauseSnapshot.previousStatus || WorkflowStatus.PENDING;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { CreateWorkflowRequest, UpdateWorkflowRequest } from '../types/workflow.
|
|||||||
import { generateRequestNumber, calculateTATDays } from '@utils/helpers';
|
import { generateRequestNumber, calculateTATDays } from '@utils/helpers';
|
||||||
import logger, { logWorkflowEvent, logWithContext } from '@utils/logger';
|
import logger, { logWorkflowEvent, logWithContext } from '@utils/logger';
|
||||||
import { WorkflowStatus, ParticipantType, ApprovalStatus } from '../types/common.types';
|
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 { sequelize } from '@config/database';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -816,6 +816,12 @@ export class WorkflowService {
|
|||||||
// Calculate approved levels count
|
// Calculate approved levels count
|
||||||
const approvedLevelsCount = approvals.filter((a: any) => a.status === 'APPROVED').length;
|
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();
|
const priority = ((wf as any).priority || 'standard').toString().toLowerCase();
|
||||||
|
|
||||||
// Calculate OVERALL request SLA based on cumulative elapsed hours from all levels
|
// Calculate OVERALL request SLA based on cumulative elapsed hours from all levels
|
||||||
@ -960,6 +966,7 @@ export class WorkflowService {
|
|||||||
createdAt: (wf as any).createdAt,
|
createdAt: (wf as any).createdAt,
|
||||||
closureDate: (wf as any).closureDate,
|
closureDate: (wf as any).closureDate,
|
||||||
conclusionRemark: (wf as any).conclusionRemark,
|
conclusionRemark: (wf as any).conclusionRemark,
|
||||||
|
closureType: closureType, // 'approved' or 'rejected' - indicates path to closure
|
||||||
initiator: (wf as any).initiator,
|
initiator: (wf as any).initiator,
|
||||||
department: (wf as any).initiator?.department,
|
department: (wf as any).initiator?.department,
|
||||||
totalLevels: (wf as any).totalLevels,
|
totalLevels: (wf as any).totalLevels,
|
||||||
@ -2069,26 +2076,45 @@ export class WorkflowService {
|
|||||||
// Build query conditions
|
// Build query conditions
|
||||||
const whereConditions: any[] = [];
|
const whereConditions: any[] = [];
|
||||||
|
|
||||||
// 1. Requests where user was approver/spectator (show APPROVED, REJECTED, CLOSED)
|
// 1. Requests where user was approver/spectator (show ONLY CLOSED)
|
||||||
const approverSpectatorStatuses = [
|
// Closed requests are the final state after approval/rejection + conclusion
|
||||||
WorkflowStatus.APPROVED as any,
|
const closedStatus = [
|
||||||
WorkflowStatus.REJECTED as any,
|
|
||||||
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
||||||
'APPROVED',
|
|
||||||
'REJECTED',
|
|
||||||
'CLOSED'
|
'CLOSED'
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
if (allRequestIds.length > 0) {
|
if (allRequestIds.length > 0) {
|
||||||
const approverConditionParts: any[] = [
|
const approverConditionParts: any[] = [
|
||||||
{ requestId: { [Op.in]: allRequestIds } }
|
{ requestId: { [Op.in]: allRequestIds } },
|
||||||
|
{ status: { [Op.in]: closedStatus } } // Only CLOSED requests
|
||||||
];
|
];
|
||||||
|
|
||||||
// Apply status filter
|
// Apply closure type filter (approved/rejected before closure)
|
||||||
if (filters?.status && filters.status !== 'all') {
|
if (filters?.status && filters?.status !== 'all') {
|
||||||
approverConditionParts.push({ status: filters.status.toUpperCase() });
|
const filterStatus = filters.status.toLowerCase();
|
||||||
} else {
|
if (filterStatus === 'rejected') {
|
||||||
approverConditionParts.push({ status: { [Op.in]: approverSpectatorStatuses } });
|
// 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
|
// Apply priority filter
|
||||||
@ -2114,31 +2140,44 @@ export class WorkflowService {
|
|||||||
whereConditions.push(approverCondition);
|
whereConditions.push(approverCondition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Requests where user is initiator (show ONLY REJECTED or CLOSED, NOT APPROVED)
|
// 2. Requests where user is initiator (show ONLY CLOSED)
|
||||||
// APPROVED means initiator still needs to finalize conclusion
|
// CLOSED means request has been finalized with conclusion
|
||||||
const initiatorStatuses = [
|
const initiatorStatuses = [
|
||||||
WorkflowStatus.REJECTED as any,
|
|
||||||
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
(WorkflowStatus as any).CLOSED ?? 'CLOSED',
|
||||||
'REJECTED',
|
|
||||||
'CLOSED'
|
'CLOSED'
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const initiatorConditionParts: any[] = [
|
const initiatorConditionParts: any[] = [
|
||||||
{ initiatorId: userId }
|
{ initiatorId: userId },
|
||||||
|
{ status: { [Op.in]: initiatorStatuses } } // Only CLOSED requests
|
||||||
];
|
];
|
||||||
|
|
||||||
// Apply status filter
|
// Apply closure type filter (approved/rejected before closure)
|
||||||
if (filters?.status && filters.status !== 'all') {
|
if (filters?.status && filters?.status !== 'all') {
|
||||||
const filterStatus = filters.status.toUpperCase();
|
const filterStatus = filters.status.toLowerCase();
|
||||||
// Only apply if status is REJECTED or CLOSED (not APPROVED for initiator)
|
if (filterStatus === 'rejected') {
|
||||||
if (filterStatus === 'REJECTED' || filterStatus === 'CLOSED') {
|
// Closed after rejection: has at least one REJECTED approval level
|
||||||
initiatorConditionParts.push({ status: filterStatus });
|
initiatorConditionParts.push({
|
||||||
} else {
|
[Op.and]: [
|
||||||
// If filtering for APPROVED, don't include initiator requests
|
literal(`EXISTS (
|
||||||
initiatorConditionParts.push({ status: { [Op.in]: [] } }); // Empty set - no results
|
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
|
// 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({
|
const { rows, count } = await WorkflowRequest.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
offset,
|
offset,
|
||||||
@ -2198,8 +2237,19 @@ export class WorkflowService {
|
|||||||
{ association: 'initiator', required: false, attributes: ['userId', 'email', 'displayName', 'department', 'designation'] },
|
{ 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> {
|
async createWorkflow(initiatorId: string, workflowData: CreateWorkflowRequest, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null }): Promise<WorkflowRequest> {
|
||||||
try {
|
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({
|
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({
|
export const tokenExchangeSchema = z.object({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user