email template flow added with test account and templates for all cenerios

This commit is contained in:
laxmanhalaki 2025-12-04 20:58:32 +05:30
parent 134b7d547d
commit 9089e8c035
50 changed files with 9852 additions and 146 deletions

12
monitoring/.env.example Normal file
View 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
View 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

View 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

View File

@ -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

View 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
View File

@ -0,0 +1 @@
Redis

View File

@ -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:

View File

@ -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",

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
}; };

View 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)?** 🚀

File diff suppressed because it is too large Load Diff

View 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

View 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!** 🎯

View 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
View 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)

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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;
*/

View 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);
}

View 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>
`;
}

View 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';

View 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>
`;
}

View 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>
`;
}

View 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));

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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);
});

View 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
View 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;
}

View 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>
`;
}

View 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>
`;
}

View File

@ -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 };

View File

@ -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');
} }

View File

@ -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);

View File

@ -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);
}); });

View File

@ -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

View 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);
});

View 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();

View File

@ -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();

View File

@ -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;

View File

@ -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';
@ -815,6 +815,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();
@ -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
View 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');
}
}

View File

@ -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({