Files
bakery-ia/services/notification/app/services/notification_orchestrator.py
2025-08-23 10:19:58 +02:00

279 lines
11 KiB
Python

# services/notification/app/services/notification_orchestrator.py
"""
Notification orchestrator for managing delivery across all channels
Includes SSE integration for real-time dashboard updates
"""
from typing import List, Dict, Any
from datetime import datetime
import structlog
from .email_service import EmailService
from .whatsapp_service import WhatsAppService
from .sse_service import SSEService
logger = structlog.get_logger()
class NotificationOrchestrator:
"""
Orchestrates delivery across all notification channels
Now includes SSE for real-time dashboard updates, with support for recommendations
"""
def __init__(
self,
email_service: EmailService,
whatsapp_service: WhatsAppService,
sse_service: SSEService,
push_service=None # Optional push service
):
self.email_service = email_service
self.whatsapp_service = whatsapp_service
self.sse_service = sse_service
self.push_service = push_service
async def send_notification(
self,
tenant_id: str,
notification: Dict[str, Any],
channels: List[str]
) -> Dict[str, Any]:
"""
Send notification through specified channels
Channels can include: email, whatsapp, push, dashboard (SSE)
"""
results = {}
# Always send to dashboard for visibility (SSE)
if 'dashboard' in channels or notification.get('type') in ['alert', 'recommendation']:
try:
await self.sse_service.send_item_notification(
tenant_id,
notification
)
results['dashboard'] = {'status': 'sent', 'timestamp': datetime.utcnow().isoformat()}
logger.info("Item sent to dashboard via SSE",
tenant_id=tenant_id,
item_type=notification.get('type'),
item_id=notification.get('id'))
except Exception as e:
logger.error("Failed to send to dashboard",
tenant_id=tenant_id,
error=str(e))
results['dashboard'] = {'status': 'failed', 'error': str(e)}
# Send to email channel
if 'email' in channels:
try:
email_result = await self.email_service.send_notification_email(
to_email=notification.get('email'),
subject=notification.get('title'),
template_data={
'title': notification.get('title'),
'message': notification.get('message'),
'severity': notification.get('severity'),
'item_type': notification.get('type'),
'actions': notification.get('actions', []),
'metadata': notification.get('metadata', {}),
'timestamp': datetime.utcnow().isoformat()
},
notification_type=notification.get('type', 'alert')
)
results['email'] = email_result
except Exception as e:
logger.error("Failed to send email",
tenant_id=tenant_id,
error=str(e))
results['email'] = {'status': 'failed', 'error': str(e)}
# Send to WhatsApp channel
if 'whatsapp' in channels:
try:
whatsapp_result = await self.whatsapp_service.send_notification_message(
to_phone=notification.get('phone'),
message=self._format_whatsapp_message(notification),
notification_type=notification.get('type', 'alert')
)
results['whatsapp'] = whatsapp_result
except Exception as e:
logger.error("Failed to send WhatsApp",
tenant_id=tenant_id,
error=str(e))
results['whatsapp'] = {'status': 'failed', 'error': str(e)}
# Send to push notification channel
if 'push' in channels and self.push_service:
try:
push_result = await self.push_service.send_notification(
user_id=notification.get('user_id'),
title=notification.get('title'),
body=notification.get('message'),
data={
'item_type': notification.get('type'),
'severity': notification.get('severity'),
'item_id': notification.get('id'),
'metadata': notification.get('metadata', {})
}
)
results['push'] = push_result
except Exception as e:
logger.error("Failed to send push notification",
tenant_id=tenant_id,
error=str(e))
results['push'] = {'status': 'failed', 'error': str(e)}
# Log summary
successful_channels = [ch for ch, result in results.items() if result.get('status') == 'sent']
failed_channels = [ch for ch, result in results.items() if result.get('status') == 'failed']
logger.info("Notification delivery completed",
tenant_id=tenant_id,
item_type=notification.get('type'),
item_id=notification.get('id'),
successful_channels=successful_channels,
failed_channels=failed_channels,
total_channels=len(channels))
return {
'status': 'completed',
'successful_channels': successful_channels,
'failed_channels': failed_channels,
'results': results,
'timestamp': datetime.utcnow().isoformat()
}
def _format_whatsapp_message(self, notification: Dict[str, Any]) -> str:
"""Format message for WhatsApp with emojis and structure"""
item_type = notification.get('type', 'alert')
severity = notification.get('severity', 'medium')
# Get appropriate emoji
type_emoji = '🚨' if item_type == 'alert' else '💡'
severity_emoji = {
'urgent': '🔴',
'high': '🟡',
'medium': '🔵',
'low': '🟢'
}.get(severity, '🔵')
message = f"{type_emoji} {severity_emoji} *{notification.get('title', 'Notificación')}*\n\n"
message += f"{notification.get('message', '')}\n"
# Add actions if available
actions = notification.get('actions', [])
if actions and len(actions) > 0:
message += "\n*Acciones sugeridas:*\n"
for i, action in enumerate(actions[:3], 1): # Limit to 3 actions for WhatsApp
message += f"{i}. {action}\n"
# Add timestamp
message += f"\n_Enviado: {datetime.now().strftime('%H:%M, %d/%m/%Y')}_"
return message
def get_channels_by_severity(self, severity: str, item_type: str, hour: int = None) -> List[str]:
"""
Determine notification channels based on severity and item_type
Now includes 'dashboard' as a channel
"""
if hour is None:
hour = datetime.now().hour
# Dashboard always gets all items
channels = ['dashboard']
if item_type == 'alert':
if severity == 'urgent':
# Urgent alerts: All channels immediately
channels.extend(['email', 'whatsapp', 'push'])
elif severity == 'high':
# High alerts: Email and WhatsApp during extended hours
if 6 <= hour <= 22:
channels.extend(['email', 'whatsapp'])
else:
channels.append('email') # Email only during night
elif severity == 'medium':
# Medium alerts: Email during business hours
if 7 <= hour <= 20:
channels.append('email')
elif item_type == 'recommendation':
# Recommendations: Generally less urgent, respect business hours
if severity in ['medium', 'high']:
if 8 <= hour <= 19: # Stricter business hours for recommendations
channels.append('email')
# Low/urgent: Dashboard only (urgent rare for recommendations)
return channels
async def health_check(self) -> Dict[str, Any]:
"""Check health of all notification channels"""
health_status = {
'status': 'healthy',
'channels': {},
'timestamp': datetime.utcnow().isoformat()
}
# Check email service
try:
email_health = await self.email_service.health_check()
health_status['channels']['email'] = email_health
except Exception as e:
health_status['channels']['email'] = {'status': 'unhealthy', 'error': str(e)}
# Check WhatsApp service
try:
whatsapp_health = await self.whatsapp_service.health_check()
health_status['channels']['whatsapp'] = whatsapp_health
except Exception as e:
health_status['channels']['whatsapp'] = {'status': 'unhealthy', 'error': str(e)}
# Check SSE service
try:
sse_metrics = self.sse_service.get_metrics()
sse_status = 'healthy' if sse_metrics['redis_connected'] else 'unhealthy'
health_status['channels']['sse'] = {
'status': sse_status,
'metrics': sse_metrics
}
except Exception as e:
health_status['channels']['sse'] = {'status': 'unhealthy', 'error': str(e)}
# Check push service if available
if self.push_service:
try:
push_health = await self.push_service.health_check()
health_status['channels']['push'] = push_health
except Exception as e:
health_status['channels']['push'] = {'status': 'unhealthy', 'error': str(e)}
# Determine overall status
unhealthy_channels = [
ch for ch, status in health_status['channels'].items()
if status.get('status') != 'healthy'
]
if unhealthy_channels:
health_status['status'] = 'degraded' if len(unhealthy_channels) < len(health_status['channels']) else 'unhealthy'
health_status['unhealthy_channels'] = unhealthy_channels
return health_status
def get_metrics(self) -> Dict[str, Any]:
"""Get aggregated metrics from all services"""
metrics = {
'timestamp': datetime.utcnow().isoformat(),
'channels': {}
}
# Get SSE metrics
try:
metrics['channels']['sse'] = self.sse_service.get_metrics()
except Exception as e:
logger.error("Failed to get SSE metrics", error=str(e))
# Additional metrics could be added here for other services
return metrics