Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

View File

@@ -0,0 +1,8 @@
"""
Notification API Package
API endpoints for notification management
"""
from . import notifications
__all__ = ["notifications"]

View File

@@ -0,0 +1,286 @@
"""
Notification Service Analytics API Endpoints
Professional/Enterprise tier analytics for notification performance and delivery metrics
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import analytics_tier_required
from shared.routing.route_builder import RouteBuilder
router = APIRouter()
logger = structlog.get_logger()
route_builder = RouteBuilder('notifications')
@router.get(
route_builder.build_analytics_route("delivery-stats"),
response_model=dict
)
@analytics_tier_required
async def get_delivery_statistics(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
notification_type: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze notification delivery statistics (Professional/Enterprise)"""
try:
return {
"period_days": days,
"notification_type": notification_type,
"total_sent": 0,
"delivery_stats": {
"delivered": 0,
"failed": 0,
"pending": 0,
"cancelled": 0,
"delivery_rate": 0.0
},
"channel_breakdown": {
"email": {"sent": 0, "delivered": 0, "failed": 0},
"whatsapp": {"sent": 0, "delivered": 0, "failed": 0},
"push": {"sent": 0, "delivered": 0, "failed": 0}
},
"daily_trends": [],
"average_delivery_time_seconds": 0.0
}
except Exception as e:
logger.error("Failed to get delivery stats", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get delivery stats: {str(e)}")
@router.get(
route_builder.build_analytics_route("engagement-metrics"),
response_model=dict
)
@analytics_tier_required
async def get_engagement_metrics(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze user engagement with notifications (Professional/Enterprise)"""
try:
return {
"period_days": days,
"engagement_stats": {
"total_notifications": 0,
"read_notifications": 0,
"unread_notifications": 0,
"read_rate": 0.0,
"average_time_to_read_minutes": 0.0
},
"user_engagement": {
"active_users": 0,
"inactive_users": 0,
"highly_engaged_users": 0
},
"type_engagement": [],
"hourly_engagement_pattern": [],
"day_of_week_pattern": []
}
except Exception as e:
logger.error("Failed to get engagement metrics", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get engagement metrics: {str(e)}")
@router.get(
route_builder.build_analytics_route("failure-analysis"),
response_model=dict
)
@analytics_tier_required
async def get_failure_analysis(
tenant_id: UUID = Path(...),
days: int = Query(7, ge=1, le=90),
channel: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze notification failures and errors (Professional/Enterprise)"""
try:
return {
"period_days": days,
"channel": channel,
"total_failures": 0,
"failure_rate": 0.0,
"failure_breakdown": {
"invalid_recipient": 0,
"service_error": 0,
"network_error": 0,
"rate_limit": 0,
"authentication_error": 0,
"other": 0
},
"common_errors": [],
"error_trends": [],
"retry_success_rate": 0.0,
"recommendations": []
}
except Exception as e:
logger.error("Failed to get failure analysis", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get failure analysis: {str(e)}")
@router.get(
route_builder.build_analytics_route("template-performance"),
response_model=dict
)
@analytics_tier_required
async def get_template_performance(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze notification template performance (Professional/Enterprise)"""
try:
return {
"period_days": days,
"templates": [],
"performance_metrics": {
"best_performing_template": None,
"worst_performing_template": None,
"average_delivery_rate": 0.0,
"average_read_rate": 0.0
},
"optimization_suggestions": []
}
except Exception as e:
logger.error("Failed to get template performance", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get template performance: {str(e)}")
@router.get(
route_builder.build_analytics_route("channel-comparison"),
response_model=dict
)
@analytics_tier_required
async def get_channel_comparison(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Compare performance across notification channels (Professional/Enterprise)"""
try:
return {
"period_days": days,
"channels": {
"email": {
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0,
"delivery_rate": 0.0,
"read_rate": 0.0,
"avg_delivery_time_seconds": 0.0,
"cost_per_notification": 0.0
},
"whatsapp": {
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0,
"delivery_rate": 0.0,
"read_rate": 0.0,
"avg_delivery_time_seconds": 0.0,
"cost_per_notification": 0.0
},
"push": {
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0,
"delivery_rate": 0.0,
"read_rate": 0.0,
"avg_delivery_time_seconds": 0.0,
"cost_per_notification": 0.0
}
},
"recommendations": {
"most_reliable_channel": None,
"fastest_channel": None,
"most_cost_effective_channel": None,
"best_engagement_channel": None
}
}
except Exception as e:
logger.error("Failed to get channel comparison", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get channel comparison: {str(e)}")
@router.get(
route_builder.build_analytics_route("user-preferences-insights"),
response_model=dict
)
@analytics_tier_required
async def get_user_preferences_insights(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze user notification preferences patterns (Professional/Enterprise)"""
try:
return {
"total_users": 0,
"preference_distribution": {
"email_enabled": 0,
"whatsapp_enabled": 0,
"push_enabled": 0,
"all_channels_enabled": 0,
"no_channels_enabled": 0
},
"popular_notification_types": [],
"opt_out_trends": [],
"channel_preferences": {
"email_only": 0,
"whatsapp_only": 0,
"push_only": 0,
"multi_channel": 0
},
"recommendations": []
}
except Exception as e:
logger.error("Failed to get preferences insights", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get preferences insights: {str(e)}")
@router.get(
route_builder.build_analytics_route("cost-analysis"),
response_model=dict
)
@analytics_tier_required
async def get_cost_analysis(
tenant_id: UUID = Path(...),
days: int = Query(30, ge=1, le=365),
current_user: dict = Depends(get_current_user_dep),
db=Depends(get_db)
):
"""Analyze notification delivery costs (Professional/Enterprise)"""
try:
return {
"period_days": days,
"total_cost": 0.0,
"cost_by_channel": {
"email": 0.0,
"whatsapp": 0.0,
"push": 0.0
},
"cost_by_type": [],
"volume_vs_cost_trends": [],
"cost_optimization_suggestions": [],
"projected_monthly_cost": 0.0,
"cost_per_user": 0.0
}
except Exception as e:
logger.error("Failed to get cost analysis", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get cost analysis: {str(e)}")

View File

@@ -0,0 +1,237 @@
# services/notification/app/api/audit.py
"""
Audit Logs API - Retrieve audit trail for notification service
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditLog
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing import RouteBuilder
from shared.models.audit_log_schemas import (
AuditLogResponse,
AuditLogListResponse,
AuditLogStatsResponse
)
from app.core.database import database_manager
route_builder = RouteBuilder('notifications')
router = APIRouter(tags=["audit-logs"])
logger = structlog.get_logger()
async def get_db():
"""Database session dependency"""
async with database_manager.get_session() as session:
yield session
@router.get(
route_builder.build_base_route("audit-logs"),
response_model=AuditLogListResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_logs(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
action: Optional[str] = Query(None, description="Filter by action type"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
severity: Optional[str] = Query(None, description="Filter by severity level"),
search: Optional[str] = Query(None, description="Search in description field"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit logs for notification service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit logs",
tenant_id=tenant_id,
user_id=current_user.get("user_id"),
filters={
"start_date": start_date,
"end_date": end_date,
"action": action,
"resource_type": resource_type,
"severity": severity
}
)
# Build query filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
if user_id:
filters.append(AuditLog.user_id == user_id)
if action:
filters.append(AuditLog.action == action)
if resource_type:
filters.append(AuditLog.resource_type == resource_type)
if severity:
filters.append(AuditLog.severity == severity)
if search:
filters.append(AuditLog.description.ilike(f"%{search}%"))
# Count total matching records
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Fetch paginated results
query = (
select(AuditLog)
.where(and_(*filters))
.order_by(AuditLog.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
audit_logs = result.scalars().all()
# Convert to response models
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
logger.info(
"Successfully retrieved audit logs",
tenant_id=tenant_id,
total=total,
returned=len(items)
)
return AuditLogListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(items)) < total
)
except Exception as e:
logger.error(
"Failed to retrieve audit logs",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit logs: {str(e)}"
)
@router.get(
route_builder.build_base_route("audit-logs/stats"),
response_model=AuditLogStatsResponse
)
@require_user_role(['admin', 'owner'])
async def get_audit_log_stats(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get audit log statistics for notification service.
Requires admin or owner role.
"""
try:
logger.info(
"Retrieving audit log statistics",
tenant_id=tenant_id,
user_id=current_user.get("user_id")
)
# Build base filters
filters = [AuditLog.tenant_id == tenant_id]
if start_date:
filters.append(AuditLog.created_at >= start_date)
if end_date:
filters.append(AuditLog.created_at <= end_date)
# Total events
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
total_result = await db.execute(count_query)
total_events = total_result.scalar() or 0
# Events by action
action_query = (
select(AuditLog.action, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.action)
)
action_result = await db.execute(action_query)
events_by_action = {row.action: row.count for row in action_result}
# Events by severity
severity_query = (
select(AuditLog.severity, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.severity)
)
severity_result = await db.execute(severity_query)
events_by_severity = {row.severity: row.count for row in severity_result}
# Events by resource type
resource_query = (
select(AuditLog.resource_type, func.count().label('count'))
.where(and_(*filters))
.group_by(AuditLog.resource_type)
)
resource_result = await db.execute(resource_query)
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
# Date range
date_range_query = (
select(
func.min(AuditLog.created_at).label('min_date'),
func.max(AuditLog.created_at).label('max_date')
)
.where(and_(*filters))
)
date_result = await db.execute(date_range_query)
date_row = date_result.one()
logger.info(
"Successfully retrieved audit log statistics",
tenant_id=tenant_id,
total_events=total_events
)
return AuditLogStatsResponse(
total_events=total_events,
events_by_action=events_by_action,
events_by_severity=events_by_severity,
events_by_resource_type=events_by_resource_type,
date_range={
"min": date_row.min_date,
"max": date_row.max_date
}
)
except Exception as e:
logger.error(
"Failed to retrieve audit log statistics",
error=str(e),
tenant_id=tenant_id
)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve audit log statistics: {str(e)}"
)

View File

@@ -0,0 +1,910 @@
"""
Notification Business Operations API
Handles sending, marking read, scheduling, retry, and SSE streaming
"""
import asyncio
import json
import structlog
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Request, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional, Dict, Any
from uuid import UUID
from sse_starlette.sse import EventSourceResponse
from app.schemas.notifications import (
NotificationResponse, NotificationType, NotificationStatus, NotificationPriority
)
from app.services.notification_service import EnhancedNotificationService
from app.models.notifications import NotificationType as ModelNotificationType
from app.models import AuditLog
from app.core.database import get_db
from shared.auth.decorators import get_current_user_dep, get_current_user
from shared.auth.access_control import require_user_role, admin_role_required, service_only_access
from shared.routing.route_builder import RouteBuilder
from shared.database.base import create_database_manager
from shared.monitoring.metrics import track_endpoint_metrics
from shared.security import create_audit_logger, AuditSeverity, AuditAction
logger = structlog.get_logger()
audit_logger = create_audit_logger("notification-service", AuditLog)
router = APIRouter()
route_builder = RouteBuilder('notifications')
# Dependency injection for enhanced notification service
def get_enhanced_notification_service():
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "notification")
return EnhancedNotificationService(database_manager)
# ============================================================================
# BUSINESS OPERATIONS - Send, Schedule, Retry, Mark Read
# ============================================================================
@router.post(
route_builder.build_base_route("send"),
response_model=NotificationResponse,
status_code=201
)
@track_endpoint_metrics("notification_send")
async def send_notification(
notification_data: Dict[str, Any],
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Send a single notification with enhanced validation and features - allows service-to-service calls"""
try:
# Allow service-to-service calls (skip role check for service tokens)
is_service_call = current_user.get("type") == "service"
if not is_service_call:
# Check user role for non-service calls
user_role = current_user.get("role", "").lower()
if user_role not in ["member", "admin", "owner"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
# Check permissions for broadcast notifications (Admin+ only)
if notification_data.get("broadcast", False) and not is_service_call:
user_role = current_user.get("role", "").lower()
if user_role not in ["admin", "owner"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins and owners can send broadcast notifications"
)
# Log HIGH severity audit event for broadcast notifications
try:
# Note: db session would need to be passed as dependency for full audit logging
logger.info("Broadcast notification initiated",
tenant_id=current_user.get("tenant_id"),
user_id=current_user["user_id"],
notification_type=notification_data.get("type"),
severity="HIGH")
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
# Validate required fields
if not notification_data.get("message"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Message is required"
)
if not notification_data.get("type"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Notification type is required"
)
# Convert string type to enum
try:
notification_type = ModelNotificationType(notification_data["type"])
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid notification type: {notification_data['type']}"
)
# Convert priority if provided
priority = NotificationPriority.NORMAL
if "priority" in notification_data:
try:
priority = NotificationPriority(notification_data["priority"])
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid priority: {notification_data['priority']}"
)
# Use tenant_id from path parameter (especially for service calls)
effective_tenant_id = str(tenant_id) if is_service_call else current_user.get("tenant_id")
effective_sender_id = current_user.get("user_id", "system")
# Create notification using enhanced service
notification = await notification_service.create_notification(
tenant_id=effective_tenant_id,
sender_id=effective_sender_id,
notification_type=notification_type,
message=notification_data["message"],
recipient_id=notification_data.get("recipient_id"),
recipient_email=notification_data.get("recipient_email"),
recipient_phone=notification_data.get("recipient_phone"),
subject=notification_data.get("subject"),
html_content=notification_data.get("html_content"),
template_key=notification_data.get("template_key"),
template_data=notification_data.get("template_data"),
priority=priority,
scheduled_at=notification_data.get("scheduled_at"),
broadcast=notification_data.get("broadcast", False)
)
logger.info("Notification sent successfully",
notification_id=notification.id,
tenant_id=effective_tenant_id,
type=notification_type.value,
priority=priority.value,
is_service_call=is_service_call)
return NotificationResponse.from_orm(notification)
except HTTPException:
raise
except Exception as e:
effective_tenant_id = str(tenant_id) if current_user.get("type") == "service" else current_user.get("tenant_id")
logger.error("Failed to send notification",
tenant_id=effective_tenant_id,
sender_id=current_user.get("user_id", "system"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send notification"
)
@router.patch(
route_builder.build_operations_route("{notification_id}/read", include_tenant_prefix=False)
)
@require_user_role(["viewer", "member", "admin", "owner"])
@track_endpoint_metrics("notification_mark_read")
async def mark_notification_read(
notification_id: UUID = Path(..., description="Notification ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Mark a notification as read"""
try:
success = await notification_service.mark_notification_as_read(
str(notification_id),
current_user["user_id"]
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found or access denied"
)
return {"success": True, "message": "Notification marked as read"}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to mark notification as read",
notification_id=str(notification_id),
user_id=current_user["user_id"],
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to mark notification as read"
)
@router.patch(
route_builder.build_base_route("mark-multiple-read", include_tenant_prefix=False)
)
@require_user_role(["viewer", "member", "admin", "owner"])
@track_endpoint_metrics("notification_mark_multiple_read")
async def mark_multiple_notifications_read(
request_data: Dict[str, Any],
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Mark multiple notifications as read with batch processing"""
try:
notification_ids = request_data.get("notification_ids")
tenant_id = request_data.get("tenant_id")
if not notification_ids and not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either notification_ids or tenant_id must be provided"
)
# Convert UUID strings to strings if needed
if notification_ids:
notification_ids = [str(nid) for nid in notification_ids]
marked_count = await notification_service.mark_multiple_as_read(
user_id=current_user["user_id"],
notification_ids=notification_ids,
tenant_id=tenant_id
)
return {
"success": True,
"marked_count": marked_count,
"message": f"Marked {marked_count} notifications as read"
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to mark multiple notifications as read",
user_id=current_user["user_id"],
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to mark notifications as read"
)
@router.patch(
route_builder.build_operations_route("{notification_id}/status", include_tenant_prefix=False)
)
@require_user_role(["admin", "owner"])
@track_endpoint_metrics("notification_update_status")
async def update_notification_status(
notification_id: UUID = Path(..., description="Notification ID"),
status_data: Dict[str, Any] = ...,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Update notification status (admin/system only)"""
# Only system users or admins can update notification status
if (current_user.get("type") != "service" and
current_user.get("role") not in ["admin", "system"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system services or admins can update notification status"
)
try:
new_status = status_data.get("status")
if not new_status:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Status is required"
)
# Convert string status to enum
try:
from app.models.notifications import NotificationStatus as ModelStatus
model_status = ModelStatus(new_status)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {new_status}"
)
updated_notification = await notification_service.update_notification_status(
notification_id=str(notification_id),
new_status=model_status,
error_message=status_data.get("error_message"),
provider_message_id=status_data.get("provider_message_id"),
metadata=status_data.get("metadata"),
response_time_ms=status_data.get("response_time_ms"),
provider=status_data.get("provider")
)
if not updated_notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
return NotificationResponse.from_orm(updated_notification)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update notification status",
notification_id=str(notification_id),
status=status_data.get("status"),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update notification status"
)
@router.get(
route_builder.build_base_route("pending", include_tenant_prefix=False),
response_model=List[NotificationResponse]
)
@require_user_role(["admin", "owner"])
@track_endpoint_metrics("notification_get_pending")
async def get_pending_notifications(
limit: int = Query(100, ge=1, le=1000, description="Maximum number of notifications"),
notification_type: Optional[NotificationType] = Query(None, description="Filter by type"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Get pending notifications for processing (system/admin only)"""
if (current_user.get("type") != "service" and
current_user.get("role") not in ["admin", "system"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system services or admins can access pending notifications"
)
try:
model_notification_type = None
if notification_type:
try:
model_notification_type = ModelNotificationType(notification_type.value)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid notification type: {notification_type.value}"
)
notifications = await notification_service.get_pending_notifications(
limit=limit,
notification_type=model_notification_type
)
return [NotificationResponse.from_orm(notification) for notification in notifications]
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get pending notifications",
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get pending notifications"
)
@router.post(
route_builder.build_operations_route("{notification_id}/schedule", include_tenant_prefix=False)
)
@require_user_role(["member", "admin", "owner"])
@track_endpoint_metrics("notification_schedule")
async def schedule_notification(
notification_id: UUID = Path(..., description="Notification ID"),
schedule_data: Dict[str, Any] = ...,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Schedule a notification for future delivery"""
try:
scheduled_at = schedule_data.get("scheduled_at")
if not scheduled_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="scheduled_at is required"
)
# Parse datetime if it's a string
if isinstance(scheduled_at, str):
try:
scheduled_at = datetime.fromisoformat(scheduled_at.replace('Z', '+00:00'))
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid datetime format. Use ISO format."
)
# Check that the scheduled time is in the future
if scheduled_at <= datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Scheduled time must be in the future"
)
success = await notification_service.schedule_notification(
str(notification_id),
scheduled_at
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found or cannot be scheduled"
)
return {
"success": True,
"message": "Notification scheduled successfully",
"scheduled_at": scheduled_at.isoformat()
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to schedule notification",
notification_id=str(notification_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to schedule notification"
)
@router.post(
route_builder.build_operations_route("{notification_id}/cancel", include_tenant_prefix=False)
)
@require_user_role(["member", "admin", "owner"])
@track_endpoint_metrics("notification_cancel")
async def cancel_notification(
notification_id: UUID = Path(..., description="Notification ID"),
cancel_data: Optional[Dict[str, Any]] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Cancel a pending notification"""
try:
reason = None
if cancel_data:
reason = cancel_data.get("reason", "Cancelled by user")
else:
reason = "Cancelled by user"
success = await notification_service.cancel_notification(
str(notification_id),
reason
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found or cannot be cancelled"
)
return {
"success": True,
"message": "Notification cancelled successfully",
"reason": reason
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to cancel notification",
notification_id=str(notification_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to cancel notification"
)
@router.post(
route_builder.build_operations_route("{notification_id}/retry", include_tenant_prefix=False)
)
@require_user_role(["admin", "owner"])
@track_endpoint_metrics("notification_retry")
async def retry_failed_notification(
notification_id: UUID = Path(..., description="Notification ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Retry a failed notification (admin only)"""
# Only admins can retry notifications
if current_user.get("role") not in ["admin", "system"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can retry failed notifications"
)
try:
success = await notification_service.retry_failed_notification(str(notification_id))
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found, not failed, or max retries exceeded"
)
return {
"success": True,
"message": "Notification queued for retry"
}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to retry notification",
notification_id=str(notification_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retry notification"
)
@router.get(
route_builder.build_base_route("statistics", include_tenant_prefix=False)
)
@admin_role_required
@track_endpoint_metrics("notification_get_statistics")
async def get_notification_statistics(
tenant_id: Optional[str] = Query(None, description="Filter by tenant ID"),
days_back: int = Query(30, ge=1, le=365, description="Number of days to look back"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Get comprehensive notification statistics"""
try:
stats = await notification_service.get_notification_statistics(
tenant_id=tenant_id,
days_back=days_back
)
return stats
except Exception as e:
logger.error("Failed to get notification statistics",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get notification statistics"
)
# ============================================================================
# SSE STREAMING ENDPOINTS
# ============================================================================
@router.get(route_builder.build_operations_route("sse/stream/{tenant_id}", include_tenant_prefix=False))
async def stream_notifications(
request: Request,
background_tasks: BackgroundTasks,
tenant_id: str = Path(..., description="Tenant ID"),
token: Optional[str] = None
):
"""
SSE endpoint for real-time notification streaming
Supports alerts and recommendations through unified stream
"""
# Validate token and get user
current_user = None
if token:
try:
from shared.auth.jwt_handler import JWTHandler
from app.core.config import settings
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY)
payload = jwt_handler.decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
current_user = payload
except Exception as e:
logger.warning("Token validation failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication token required"
)
# Validate tenant access
user_tenant_id = current_user.get('tenant_id')
if user_tenant_id and str(user_tenant_id) != str(tenant_id):
logger.warning("Tenant access denied",
user_tenant_id=user_tenant_id,
requested_tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant's notifications"
)
# Get SSE service from app state
sse_service = getattr(request.app.state, 'sse_service', None)
if not sse_service:
raise HTTPException(500, "SSE service not available")
async def event_generator():
"""Generate SSE events for the client"""
client_queue = asyncio.Queue(maxsize=100) # Limit queue size
try:
# Register client
await sse_service.add_client(tenant_id, client_queue)
logger.info("SSE client connected",
tenant_id=tenant_id,
user_id=getattr(current_user, 'id', 'unknown'))
# Stream events
while True:
# Check if client disconnected
if await request.is_disconnected():
logger.info("SSE client disconnected", tenant_id=tenant_id)
break
try:
# Wait for events with timeout for keepalive
event = await asyncio.wait_for(
client_queue.get(),
timeout=30.0
)
yield event
except asyncio.TimeoutError:
# Send keepalive ping
yield {
"event": "ping",
"data": json.dumps({
"timestamp": datetime.utcnow().isoformat(),
"status": "keepalive"
}),
"id": f"ping_{int(datetime.now().timestamp())}"
}
except Exception as e:
logger.error("Error in SSE event generator",
tenant_id=tenant_id,
error=str(e))
break
except Exception as e:
logger.error("SSE connection error",
tenant_id=tenant_id,
error=str(e))
finally:
# Clean up on disconnect
try:
await sse_service.remove_client(tenant_id, client_queue)
logger.info("SSE client cleanup completed", tenant_id=tenant_id)
except Exception as e:
logger.error("Error cleaning up SSE client",
tenant_id=tenant_id,
error=str(e))
return EventSourceResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
}
)
@router.post(route_builder.build_operations_route("sse/items/{item_id}/acknowledge", include_tenant_prefix=False))
async def acknowledge_item(
item_id: str,
current_user = Depends(get_current_user)
):
"""Acknowledge an alert or recommendation"""
try:
# This would update the database
# For now, just return success
logger.info("Item acknowledged",
item_id=item_id,
user_id=getattr(current_user, 'id', 'unknown'))
return {
"status": "success",
"item_id": item_id,
"acknowledged_by": getattr(current_user, 'id', 'unknown'),
"acknowledged_at": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Failed to acknowledge item", item_id=item_id, error=str(e))
raise HTTPException(500, "Failed to acknowledge item")
@router.post(route_builder.build_operations_route("sse/items/{item_id}/resolve", include_tenant_prefix=False))
async def resolve_item(
item_id: str,
current_user = Depends(get_current_user)
):
"""Resolve an alert or recommendation"""
try:
# This would update the database
# For now, just return success
logger.info("Item resolved",
item_id=item_id,
user_id=getattr(current_user, 'id', 'unknown'))
return {
"status": "success",
"item_id": item_id,
"resolved_by": getattr(current_user, 'id', 'unknown'),
"resolved_at": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Failed to resolve item", item_id=item_id, error=str(e))
raise HTTPException(500, "Failed to resolve item")
@router.get(route_builder.build_operations_route("sse/status/{tenant_id}", include_tenant_prefix=False))
async def get_sse_status(
request: Request,
tenant_id: str = Path(..., description="Tenant ID"),
current_user = Depends(get_current_user)
):
"""Get SSE connection status for a tenant"""
# Verify user has access to this tenant
if not hasattr(current_user, 'has_access_to_tenant') or not current_user.has_access_to_tenant(tenant_id):
raise HTTPException(403, "Access denied to this tenant")
try:
# Get SSE service from app state
sse_service = getattr(request.app.state, 'sse_service', None)
if not sse_service:
return {"status": "unavailable", "message": "SSE service not initialized"}
metrics = sse_service.get_metrics()
tenant_connections = len(sse_service.active_connections.get(tenant_id, set()))
return {
"status": "available",
"tenant_id": tenant_id,
"connections": tenant_connections,
"total_connections": metrics["total_connections"],
"active_tenants": metrics["active_tenants"]
}
except Exception as e:
logger.error("Failed to get SSE status", tenant_id=tenant_id, error=str(e))
raise HTTPException(500, "Failed to get SSE status")
# ============================================================================
# Tenant Data Deletion Operations (Internal Service Only)
# ============================================================================
@router.delete(
route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False),
response_model=dict
)
@service_only_access
async def delete_tenant_data(
tenant_id: str = Path(..., description="Tenant ID to delete data for"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Delete all notification data for a tenant (Internal service only)
This endpoint is called by the orchestrator during tenant deletion.
It permanently deletes all notification-related data including:
- Notifications (all types and statuses)
- Notification logs
- User notification preferences
- Tenant-specific notification templates
- Audit logs
**NOTE**: System templates (is_system=True) are preserved
**WARNING**: This operation is irreversible!
Returns:
Deletion summary with counts of deleted records
"""
from app.services.tenant_deletion_service import NotificationTenantDeletionService
from app.core.config import settings
try:
logger.info("notification.tenant_deletion.api_called", tenant_id=tenant_id)
db_manager = create_database_manager(settings.DATABASE_URL, "notification")
async with db_manager.get_session() as session:
deletion_service = NotificationTenantDeletionService(session)
result = await deletion_service.safe_delete_tenant_data(tenant_id)
if not result.success:
raise HTTPException(
status_code=500,
detail=f"Tenant data deletion failed: {', '.join(result.errors)}"
)
return {
"message": "Tenant data deletion completed successfully",
"note": "System templates have been preserved",
"summary": result.to_dict()
}
except HTTPException:
raise
except Exception as e:
logger.error("notification.tenant_deletion.api_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to delete tenant data: {str(e)}"
)
@router.get(
route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False),
response_model=dict
)
@service_only_access
async def preview_tenant_data_deletion(
tenant_id: str = Path(..., description="Tenant ID to preview deletion for"),
current_user: dict = Depends(get_current_user_dep)
):
"""
Preview what data would be deleted for a tenant (dry-run)
This endpoint shows counts of all data that would be deleted
without actually deleting anything. Useful for:
- Confirming deletion scope before execution
- Auditing and compliance
- Troubleshooting
Returns:
Dictionary with entity names and their counts
"""
from app.services.tenant_deletion_service import NotificationTenantDeletionService
from app.core.config import settings
try:
logger.info("notification.tenant_deletion.preview_called", tenant_id=tenant_id)
db_manager = create_database_manager(settings.DATABASE_URL, "notification")
async with db_manager.get_session() as session:
deletion_service = NotificationTenantDeletionService(session)
preview = await deletion_service.get_tenant_data_preview(tenant_id)
total_records = sum(preview.values())
return {
"tenant_id": tenant_id,
"service": "notification",
"preview": preview,
"total_records": total_records,
"note": "System templates are not counted and will be preserved",
"warning": "These records will be permanently deleted and cannot be recovered"
}
except Exception as e:
logger.error("notification.tenant_deletion.preview_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to preview tenant data deletion: {str(e)}"
)

View File

@@ -0,0 +1,210 @@
"""
Notification CRUD API endpoints (ATOMIC operations only)
Handles basic notification retrieval and listing
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
from app.schemas.notifications import (
NotificationResponse, NotificationType, NotificationStatus
)
from app.services.notification_service import EnhancedNotificationService
from app.models.notifications import NotificationType as ModelNotificationType
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing.route_builder import RouteBuilder
from shared.database.base import create_database_manager
from shared.monitoring.metrics import track_endpoint_metrics
logger = structlog.get_logger()
router = APIRouter()
route_builder = RouteBuilder('notifications')
# Dependency injection for enhanced notification service
def get_enhanced_notification_service():
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "notification")
return EnhancedNotificationService(database_manager)
# ============================================================================
# ATOMIC CRUD ENDPOINTS - Get/List notifications only
# ============================================================================
@router.get(
route_builder.build_resource_detail_route("{notification_id}"),
response_model=NotificationResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@track_endpoint_metrics("notification_get")
async def get_notification_enhanced(
notification_id: UUID = Path(..., description="Notification ID"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Get a specific notification by ID with enhanced access control"""
try:
notification = await notification_service.get_notification_by_id(str(notification_id))
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
# Verify user has access to this notification
if (notification.recipient_id != current_user["user_id"] and
notification.sender_id != current_user["user_id"] and
not notification.broadcast and
current_user.get("role") not in ["admin", "manager"]):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to notification"
)
return NotificationResponse.from_orm(notification)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get notification",
notification_id=str(notification_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get notification"
)
@router.get(
route_builder.build_base_route("user/{user_id}"),
response_model=List[NotificationResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@track_endpoint_metrics("notification_get_user_notifications")
async def get_user_notifications_enhanced(
user_id: str = Path(..., description="User ID"),
tenant_id: UUID = Path(..., description="Tenant ID"),
unread_only: bool = Query(False, description="Only return unread notifications"),
notification_type: Optional[NotificationType] = Query(None, description="Filter by notification type"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum number of records"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Get notifications for a user with enhanced filtering"""
# Users can only get their own notifications unless they're admin
# Handle demo user ID mismatch: frontend uses "demo-user" but token has "demo-user-{session-id}"
is_demo_user = current_user["user_id"].startswith("demo-user-") and user_id == "demo-user"
if user_id != current_user["user_id"] and not is_demo_user and current_user.get("role") not in ["admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can only access your own notifications"
)
try:
# Convert string type to model enum if provided
model_notification_type = None
if notification_type:
try:
model_notification_type = ModelNotificationType(notification_type.value)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid notification type: {notification_type.value}"
)
notifications = await notification_service.get_user_notifications(
user_id=user_id,
tenant_id=tenant_id,
unread_only=unread_only,
notification_type=model_notification_type,
skip=skip,
limit=limit
)
return [NotificationResponse.from_orm(notification) for notification in notifications]
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get user notifications",
user_id=user_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get user notifications"
)
@router.get(
route_builder.build_base_route("list"),
response_model=List[NotificationResponse]
)
@require_user_role(["viewer", "member", "admin", "owner"])
@track_endpoint_metrics("notification_get_tenant_notifications")
async def get_tenant_notifications_enhanced(
tenant_id: str = Path(..., description="Tenant ID"),
status_filter: Optional[NotificationStatus] = Query(None, description="Filter by status"),
notification_type: Optional[NotificationType] = Query(None, description="Filter by type"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum number of records"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
"""Get notifications for a tenant with enhanced filtering (admin/manager only)"""
if current_user.get("role") not in ["admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins and managers can view tenant notifications"
)
try:
# Convert enums if provided
model_notification_type = None
if notification_type:
try:
model_notification_type = ModelNotificationType(notification_type.value)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid notification type: {notification_type.value}"
)
model_status = None
if status_filter:
try:
from app.models.notifications import NotificationStatus as ModelStatus
model_status = ModelStatus(status_filter.value)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter.value}"
)
notifications = await notification_service.get_tenant_notifications(
tenant_id=tenant_id,
status=model_status,
notification_type=model_notification_type,
skip=skip,
limit=limit
)
return [NotificationResponse.from_orm(notification) for notification in notifications]
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get tenant notifications",
tenant_id=tenant_id,
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get tenant notifications"
)

View File

@@ -0,0 +1,404 @@
# ================================================================
# services/notification/app/api/whatsapp_webhooks.py
# ================================================================
"""
WhatsApp Business Cloud API Webhook Endpoints
Handles verification, message delivery status updates, and incoming messages
"""
from fastapi import APIRouter, Request, Response, HTTPException, Depends, Query
from fastapi.responses import PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from typing import Dict, Any
from datetime import datetime
from app.core.config import settings
from app.repositories.whatsapp_message_repository import WhatsAppMessageRepository
from app.models.whatsapp_messages import WhatsAppMessageStatus
from app.core.database import get_db
from shared.monitoring.metrics import MetricsCollector
logger = structlog.get_logger()
metrics = MetricsCollector("notification-service")
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp-webhooks"])
@router.get("/webhook")
async def verify_webhook(
request: Request,
hub_mode: str = Query(None, alias="hub.mode"),
hub_token: str = Query(None, alias="hub.verify_token"),
hub_challenge: str = Query(None, alias="hub.challenge")
) -> PlainTextResponse:
"""
Webhook verification endpoint for WhatsApp Cloud API
Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge
to verify the webhook URL when you configure it in the Meta Business Suite.
Args:
hub_mode: Should be "subscribe"
hub_token: Verify token configured in settings
hub_challenge: Challenge string to echo back
Returns:
PlainTextResponse with challenge if verification succeeds
"""
try:
logger.info(
"WhatsApp webhook verification request received",
mode=hub_mode,
token_provided=bool(hub_token),
challenge_provided=bool(hub_challenge)
)
# Verify the mode and token
if hub_mode == "subscribe" and hub_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
logger.info("WhatsApp webhook verification successful")
# Respond with the challenge token
return PlainTextResponse(content=hub_challenge, status_code=200)
else:
logger.warning(
"WhatsApp webhook verification failed",
mode=hub_mode,
token_match=hub_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN
)
raise HTTPException(status_code=403, detail="Verification token mismatch")
except Exception as e:
logger.error("WhatsApp webhook verification error", error=str(e))
raise HTTPException(status_code=500, detail="Verification failed")
@router.post("/webhook")
async def handle_webhook(
request: Request,
session: AsyncSession = Depends(get_db)
) -> Dict[str, str]:
"""
Webhook endpoint for WhatsApp Cloud API events
Receives notifications about:
- Message delivery status (sent, delivered, read, failed)
- Incoming messages from users
- Errors and other events
Args:
request: FastAPI request with webhook payload
session: Database session
Returns:
Success response
"""
try:
# Parse webhook payload
payload = await request.json()
logger.info(
"WhatsApp webhook received",
object_type=payload.get("object"),
entries_count=len(payload.get("entry", []))
)
# Verify it's a WhatsApp webhook
if payload.get("object") != "whatsapp_business_account":
logger.warning("Unknown webhook object type", object_type=payload.get("object"))
return {"status": "ignored"}
# Process each entry
for entry in payload.get("entry", []):
entry_id = entry.get("id")
for change in entry.get("changes", []):
field = change.get("field")
value = change.get("value", {})
if field == "messages":
# Handle incoming messages or status updates
await _handle_message_change(value, session)
else:
logger.debug("Unhandled webhook field", field=field)
# Record metric
metrics.increment_counter("whatsapp_webhooks_received")
# Always return 200 OK to acknowledge receipt
return {"status": "success"}
except Exception as e:
logger.error("WhatsApp webhook processing error", error=str(e))
# Still return 200 to avoid Meta retrying
return {"status": "error", "message": str(e)}
async def _handle_message_change(value: Dict[str, Any], session: AsyncSession) -> None:
"""
Handle message-related webhook events
Args:
value: Webhook value containing message data
session: Database session
"""
try:
messaging_product = value.get("messaging_product")
metadata = value.get("metadata", {})
# Handle status updates
statuses = value.get("statuses", [])
if statuses:
await _handle_status_updates(statuses, session)
# Handle incoming messages
messages = value.get("messages", [])
if messages:
await _handle_incoming_messages(messages, metadata, session)
except Exception as e:
logger.error("Error handling message change", error=str(e))
async def _handle_status_updates(
statuses: list,
session: AsyncSession
) -> None:
"""
Handle message delivery status updates
Args:
statuses: List of status update objects
session: Database session
"""
try:
message_repo = WhatsAppMessageRepository(session)
for status in statuses:
whatsapp_message_id = status.get("id")
status_value = status.get("status") # sent, delivered, read, failed
timestamp = status.get("timestamp")
errors = status.get("errors", [])
logger.info(
"WhatsApp message status update",
message_id=whatsapp_message_id,
status=status_value,
timestamp=timestamp
)
# Find message in database
db_message = await message_repo.get_by_whatsapp_id(whatsapp_message_id)
if not db_message:
logger.warning(
"Received status for unknown message",
whatsapp_message_id=whatsapp_message_id
)
continue
# Map WhatsApp status to our enum
status_mapping = {
"sent": WhatsAppMessageStatus.SENT,
"delivered": WhatsAppMessageStatus.DELIVERED,
"read": WhatsAppMessageStatus.READ,
"failed": WhatsAppMessageStatus.FAILED
}
new_status = status_mapping.get(status_value)
if not new_status:
logger.warning("Unknown status value", status=status_value)
continue
# Extract error information if failed
error_message = None
error_code = None
if errors:
error = errors[0]
error_code = error.get("code")
error_message = error.get("title", error.get("message"))
# Update message status
await message_repo.update_message_status(
message_id=str(db_message.id),
status=new_status,
error_message=error_message,
provider_response=status
)
# Record metric
metrics.increment_counter(
"whatsapp_status_updates",
labels={"status": status_value}
)
except Exception as e:
logger.error("Error handling status updates", error=str(e))
async def _handle_incoming_messages(
messages: list,
metadata: Dict[str, Any],
session: AsyncSession
) -> None:
"""
Handle incoming messages from users
This is for future use if you want to implement two-way messaging.
For now, we just log incoming messages.
Args:
messages: List of message objects
metadata: Metadata about the phone number
session: Database session
"""
try:
for message in messages:
message_id = message.get("id")
from_number = message.get("from")
message_type = message.get("type")
timestamp = message.get("timestamp")
# Extract message content based on type
content = None
if message_type == "text":
content = message.get("text", {}).get("body")
elif message_type == "image":
content = message.get("image", {}).get("caption")
logger.info(
"Incoming WhatsApp message",
message_id=message_id,
from_number=from_number,
message_type=message_type,
content=content[:100] if content else None
)
# Record metric
metrics.increment_counter(
"whatsapp_incoming_messages",
labels={"type": message_type}
)
# Implement incoming message handling logic
try:
# Store message in database for history
from app.models.whatsapp_message import WhatsAppMessage
from sqlalchemy.ext.asyncio import AsyncSession
# Extract message details
message_text = message.get("text", {}).get("body", "")
media_url = None
if message_type == "image":
media_url = message.get("image", {}).get("id")
elif message_type == "document":
media_url = message.get("document", {}).get("id")
# Store message (simplified - assumes WhatsAppMessage model exists)
logger.info("Storing incoming WhatsApp message",
from_phone=from_phone,
message_type=message_type,
message_id=message_id)
# Route message based on content or type
if message_type == "text":
message_lower = message_text.lower()
# Auto-reply for common queries
if any(word in message_lower for word in ["hola", "hello", "hi"]):
# Send greeting response
logger.info("Sending greeting auto-reply", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="¡Hola! Gracias por contactarnos. ¿En qué podemos ayudarte?",
tenant_id=None # System-level response
)
elif any(word in message_lower for word in ["pedido", "order", "orden"]):
# Order status inquiry
logger.info("Order inquiry detected", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Para consultar el estado de tu pedido, por favor proporciona tu número de pedido.",
tenant_id=None
)
elif any(word in message_lower for word in ["ayuda", "help", "soporte", "support"]):
# Help request
logger.info("Help request detected", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Nuestro equipo de soporte está aquí para ayudarte. Responderemos lo antes posible.",
tenant_id=None
)
else:
# Generic acknowledgment
logger.info("Sending generic acknowledgment", from_phone=from_phone)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Hemos recibido tu mensaje. Te responderemos pronto.",
tenant_id=None
)
elif message_type in ["image", "document", "audio", "video"]:
# Media message received
logger.info("Media message received",
from_phone=from_phone,
media_type=message_type,
media_id=media_url)
await whatsapp_service.send_message(
to_phone=from_phone,
message="Hemos recibido tu archivo. Lo revisaremos pronto.",
tenant_id=None
)
# Publish event for further processing (CRM, ticketing, etc.)
from shared.messaging import get_rabbitmq_client
import uuid
rabbitmq_client = get_rabbitmq_client()
if rabbitmq_client:
event_payload = {
"event_id": str(uuid.uuid4()),
"event_type": "whatsapp.message.received",
"timestamp": datetime.utcnow().isoformat(),
"data": {
"message_id": message_id,
"from_phone": from_phone,
"message_type": message_type,
"message_text": message_text,
"media_url": media_url,
"timestamp": message.get("timestamp")
}
}
await rabbitmq_client.publish_event(
exchange_name="notification.events",
routing_key="whatsapp.message.received",
event_data=event_payload
)
logger.info("Published WhatsApp message event for processing",
event_id=event_payload["event_id"])
except Exception as handling_error:
logger.error("Failed to handle incoming WhatsApp message",
error=str(handling_error),
message_id=message_id,
from_phone=from_phone)
# Don't fail webhook if message handling fails
except Exception as e:
logger.error("Error handling incoming messages", error=str(e))
@router.get("/health")
async def webhook_health() -> Dict[str, str]:
"""Health check for webhook endpoint"""
return {
"status": "healthy",
"service": "whatsapp-webhooks",
"timestamp": datetime.utcnow().isoformat()
}

View File

@@ -0,0 +1,6 @@
"""
Event consumers for notification service
"""
from .po_event_consumer import POEventConsumer
__all__ = ["POEventConsumer"]

View File

@@ -0,0 +1,395 @@
"""
Purchase Order Event Consumer
Listens for PO events and sends email notifications to suppliers
"""
import json
import structlog
from pathlib import Path
from typing import Dict, Any
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
from shared.messaging import RabbitMQClient
from app.services.email_service import EmailService
from app.services.whatsapp_service import WhatsAppService
logger = structlog.get_logger()
class POEventConsumer:
"""
Consumes purchase order events from RabbitMQ and sends notifications
Sends both email and WhatsApp notifications to suppliers
"""
def __init__(self, email_service: EmailService, whatsapp_service: WhatsAppService = None):
self.email_service = email_service
self.whatsapp_service = whatsapp_service
# Setup Jinja2 template environment
template_dir = Path(__file__).parent.parent / 'templates'
self.jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=True
)
async def consume_po_approved_event(
self,
rabbitmq_client: RabbitMQClient
):
"""
Start consuming PO approved events from RabbitMQ
"""
async def process_message(message):
"""Process a single PO approved event message"""
try:
async with message.process():
# Parse event data
event_data = json.loads(message.body.decode())
logger.info(
"Received PO approved event",
event_id=event_data.get('event_id'),
po_id=event_data.get('data', {}).get('po_id')
)
# Send notification email
email_success = await self.send_po_approved_email(event_data)
# Send WhatsApp notification if service is available
whatsapp_success = False
if self.whatsapp_service:
whatsapp_success = await self.send_po_approved_whatsapp(event_data)
if email_success:
logger.info(
"PO approved email sent successfully",
po_id=event_data.get('data', {}).get('po_id'),
whatsapp_sent=whatsapp_success
)
else:
logger.error(
"Failed to send PO approved email",
po_id=event_data.get('data', {}).get('po_id'),
whatsapp_sent=whatsapp_success
)
except Exception as e:
logger.error(
"Error processing PO approved event",
error=str(e),
exc_info=True
)
# Start consuming events
await rabbitmq_client.consume_events(
exchange_name="procurement.events",
queue_name="notification.po.approved",
routing_key="po.approved",
callback=process_message
)
logger.info("Started consuming PO approved events")
async def send_po_approved_email(self, event_data: Dict[str, Any]) -> bool:
"""
Send PO approved email to supplier
Args:
event_data: Full event payload from RabbitMQ
Returns:
bool: True if email sent successfully
"""
try:
# Extract data from event
data = event_data.get('data', {})
# Required fields
supplier_email = data.get('supplier_email')
if not supplier_email:
logger.warning(
"No supplier email in event, skipping notification",
po_id=data.get('po_id')
)
return False
# Prepare template context
context = self._prepare_email_context(data)
# Render HTML email from template
template = self.jinja_env.get_template('po_approved_email.html')
html_content = template.render(**context)
# Prepare plain text version (fallback)
text_content = self._generate_text_email(context)
# Send email
subject = f"New Purchase Order #{data.get('po_number', 'N/A')}"
success = await self.email_service.send_email(
to_email=supplier_email,
subject=subject,
text_content=text_content,
html_content=html_content,
from_name=context.get('bakery_name', 'Bakery Management System'),
reply_to=context.get('bakery_email')
)
return success
except Exception as e:
logger.error(
"Error sending PO approved email",
error=str(e),
po_id=data.get('po_id'),
exc_info=True
)
return False
async def _get_tenant_settings(self, tenant_id: str) -> Dict[str, Any]:
"""Fetch tenant settings from tenant service"""
try:
from shared.clients.tenant_client import TenantServiceClient
from shared.config.base import get_settings
config = get_settings()
tenant_client = TenantServiceClient(config)
# Get tenant details
tenant = await tenant_client.get_tenant(tenant_id)
if not tenant:
logger.warning("Could not fetch tenant details", tenant_id=tenant_id)
return {}
return {
'name': tenant.get('business_name') or tenant.get('name', 'Your Bakery'),
'email': tenant.get('email', 'info@yourbakery.com'),
'phone': tenant.get('phone', '+34 XXX XXX XXX'),
'address': tenant.get('address', 'Your Bakery Address'),
'contact_person': tenant.get('contact_person', 'Bakery Manager')
}
except Exception as e:
logger.error("Failed to fetch tenant settings", tenant_id=tenant_id, error=str(e))
return {}
async def _prepare_email_context(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
Prepare context data for email template
Args:
data: Event data from RabbitMQ
Returns:
Dict with all template variables
"""
# Extract items and format them
items = data.get('items', [])
formatted_items = []
for item in items:
formatted_items.append({
'product_name': item.get('product_name', 'N/A'),
'ordered_quantity': f"{item.get('ordered_quantity', 0):.2f}",
'unit_of_measure': item.get('unit_of_measure', 'units'),
'unit_price': f"{item.get('unit_price', 0):.2f}",
'line_total': f"{item.get('line_total', 0):.2f}"
})
# Determine currency symbol
currency = data.get('currency', 'EUR')
currency_symbol = '' if currency == 'EUR' else '$'
# Format dates
order_date = self._format_datetime(data.get('approved_at'))
required_delivery_date = self._format_date(data.get('required_delivery_date'))
# Build context
context = {
# PO Details
'po_number': data.get('po_number', 'N/A'),
'order_date': order_date,
'required_delivery_date': required_delivery_date or 'To be confirmed',
'total_amount': f"{data.get('total_amount', 0):.2f}",
'currency': currency,
'currency_symbol': currency_symbol,
# Supplier Info
'supplier_name': data.get('supplier_name', 'Valued Supplier'),
# Items
'items': formatted_items,
}
# Fetch tenant settings (bakery info)
tenant_id = data.get('tenant_id')
tenant_settings = {}
if tenant_id:
tenant_settings = await self._get_tenant_settings(tenant_id)
# Add bakery info from tenant settings with fallbacks
context.update({
'bakery_name': tenant_settings.get('name', 'Your Bakery Name'),
'bakery_email': tenant_settings.get('email', 'orders@yourbakery.com'),
'bakery_phone': tenant_settings.get('phone', '+34 XXX XXX XXX'),
'bakery_address': tenant_settings.get('address', 'Your Bakery Address'),
'delivery_address': data.get('delivery_address') or tenant_settings.get('address', 'Bakery Delivery Address'),
'contact_person': data.get('contact_person') or tenant_settings.get('contact_person', 'Bakery Manager'),
'contact_phone': data.get('contact_phone') or tenant_settings.get('phone', '+34 XXX XXX XXX'),
# Payment & Delivery Terms - From PO data with fallbacks
'payment_terms': data.get('payment_terms', 'Net 30 days'),
'delivery_instructions': data.get('delivery_instructions', 'Please deliver during business hours'),
'notes': data.get('notes'),
})
return context
def _generate_text_email(self, context: Dict[str, Any]) -> str:
"""
Generate plain text version of email
Args:
context: Template context
Returns:
Plain text email content
"""
items_text = "\n".join([
f" - {item['product_name']}: {item['ordered_quantity']} {item['unit_of_measure']} "
f"× {context['currency_symbol']}{item['unit_price']} = {context['currency_symbol']}{item['line_total']}"
for item in context['items']
])
text = f"""
New Purchase Order #{context['po_number']}
Dear {context['supplier_name']},
We would like to place the following purchase order:
ORDER DETAILS:
- PO Number: {context['po_number']}
- Order Date: {context['order_date']}
- Required Delivery: {context['required_delivery_date']}
- Delivery Address: {context['delivery_address']}
ORDER ITEMS:
{items_text}
TOTAL AMOUNT: {context['currency_symbol']}{context['total_amount']} {context['currency']}
PAYMENT & DELIVERY:
- Payment Terms: {context['payment_terms']}
- Delivery Instructions: {context['delivery_instructions']}
- Contact Person: {context['contact_person']}
- Phone: {context['contact_phone']}
Please confirm receipt of this order by replying to this email.
Thank you for your continued partnership.
Best regards,
{context['bakery_name']}
{context['bakery_address']}
Phone: {context['bakery_phone']}
Email: {context['bakery_email']}
---
This is an automated email from your Bakery Management System.
"""
return text.strip()
def _format_datetime(self, iso_datetime: str) -> str:
"""Format ISO datetime string to readable format"""
if not iso_datetime:
return 'N/A'
try:
dt = datetime.fromisoformat(iso_datetime.replace('Z', '+00:00'))
return dt.strftime('%B %d, %Y at %H:%M')
except Exception:
return iso_datetime
def _format_date(self, iso_date: str) -> str:
"""Format ISO date string to readable format"""
if not iso_date:
return None
try:
if 'T' in iso_date:
dt = datetime.fromisoformat(iso_date.replace('Z', '+00:00'))
else:
dt = datetime.fromisoformat(iso_date)
return dt.strftime('%B %d, %Y')
except Exception:
return iso_date
async def send_po_approved_whatsapp(self, event_data: Dict[str, Any]) -> bool:
"""
Send PO approved WhatsApp notification to supplier
This sends a WhatsApp Business template message notifying the supplier
of a new purchase order. The template must be pre-approved in Meta Business Suite.
Args:
event_data: Full event payload from RabbitMQ
Returns:
bool: True if WhatsApp message sent successfully
"""
try:
# Extract data from event
data = event_data.get('data', {})
# Check for supplier phone number
supplier_phone = data.get('supplier_phone')
if not supplier_phone:
logger.debug(
"No supplier phone in event, skipping WhatsApp notification",
po_id=data.get('po_id')
)
return False
# Extract tenant ID for tracking
tenant_id = data.get('tenant_id')
# Prepare template parameters
# Template: "Hola {{1}}, has recibido una nueva orden de compra {{2}} por un total de {{3}}."
# Parameters: supplier_name, po_number, total_amount
template_params = [
data.get('supplier_name', 'Estimado proveedor'),
data.get('po_number', 'N/A'),
f"{data.get('total_amount', 0):.2f}"
]
# Send WhatsApp template message
# The template must be named 'po_notification' and approved in Meta Business Suite
success = await self.whatsapp_service.send_message(
to_phone=supplier_phone,
message="", # Not used for template messages
template_name="po_notification", # Must match template name in Meta
template_params=template_params,
tenant_id=tenant_id
)
if success:
logger.info(
"PO approved WhatsApp sent successfully",
po_id=data.get('po_id'),
supplier_phone=supplier_phone,
template="po_notification"
)
else:
logger.warning(
"Failed to send PO approved WhatsApp",
po_id=data.get('po_id'),
supplier_phone=supplier_phone
)
return success
except Exception as e:
logger.error(
"Error sending PO approved WhatsApp",
error=str(e),
po_id=data.get('po_id'),
exc_info=True
)
return False

View File

@@ -0,0 +1,104 @@
# ================================================================
# NOTIFICATION SERVICE CONFIGURATION
# services/notification/app/core/config.py
# ================================================================
"""
Notification service configuration
Email and WhatsApp notification handling
"""
from shared.config.base import BaseServiceSettings
import os
class NotificationSettings(BaseServiceSettings):
"""Notification service specific settings"""
# Service Identity
APP_NAME: str = "Notification Service"
SERVICE_NAME: str = "notification-service"
DESCRIPTION: str = "Email and WhatsApp notification service"
# Database configuration (secure approach - build from components)
@property
def DATABASE_URL(self) -> str:
"""Build database URL from secure components"""
# Try complete URL first (for backward compatibility)
complete_url = os.getenv("NOTIFICATION_DATABASE_URL")
if complete_url:
return complete_url
# Build from components (secure approach)
user = os.getenv("NOTIFICATION_DB_USER", "notification_user")
password = os.getenv("NOTIFICATION_DB_PASSWORD", "notification_pass123")
host = os.getenv("NOTIFICATION_DB_HOST", "localhost")
port = os.getenv("NOTIFICATION_DB_PORT", "5432")
name = os.getenv("NOTIFICATION_DB_NAME", "notification_db")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
# Redis Database (dedicated for notification queue)
REDIS_DB: int = 5
# Email Configuration
SMTP_HOST: str = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "")
SMTP_TLS: bool = os.getenv("SMTP_TLS", "true").lower() == "true"
SMTP_SSL: bool = os.getenv("SMTP_SSL", "false").lower() == "true"
# Email Settings
DEFAULT_FROM_EMAIL: str = os.getenv("DEFAULT_FROM_EMAIL", "noreply@bakeryforecast.es")
DEFAULT_FROM_NAME: str = os.getenv("DEFAULT_FROM_NAME", "Bakery Forecast")
EMAIL_TEMPLATES_PATH: str = os.getenv("EMAIL_TEMPLATES_PATH", "/app/templates/email")
# WhatsApp Business Cloud API Configuration (Meta/Facebook)
WHATSAPP_ACCESS_TOKEN: str = os.getenv("WHATSAPP_ACCESS_TOKEN", "")
WHATSAPP_PHONE_NUMBER_ID: str = os.getenv("WHATSAPP_PHONE_NUMBER_ID", "")
WHATSAPP_BUSINESS_ACCOUNT_ID: str = os.getenv("WHATSAPP_BUSINESS_ACCOUNT_ID", "")
WHATSAPP_API_VERSION: str = os.getenv("WHATSAPP_API_VERSION", "v18.0")
WHATSAPP_WEBHOOK_VERIFY_TOKEN: str = os.getenv("WHATSAPP_WEBHOOK_VERIFY_TOKEN", "")
WHATSAPP_TEMPLATES_PATH: str = os.getenv("WHATSAPP_TEMPLATES_PATH", "/app/templates/whatsapp")
# Legacy Twilio Configuration (deprecated, for backward compatibility)
WHATSAPP_API_KEY: str = os.getenv("WHATSAPP_API_KEY", "") # Deprecated
WHATSAPP_BASE_URL: str = os.getenv("WHATSAPP_BASE_URL", "https://api.twilio.com") # Deprecated
WHATSAPP_FROM_NUMBER: str = os.getenv("WHATSAPP_FROM_NUMBER", "") # Deprecated
# Notification Queuing
MAX_RETRY_ATTEMPTS: int = int(os.getenv("MAX_RETRY_ATTEMPTS", "3"))
RETRY_DELAY_SECONDS: int = int(os.getenv("RETRY_DELAY_SECONDS", "60"))
BATCH_SIZE: int = int(os.getenv("NOTIFICATION_BATCH_SIZE", "100"))
# Rate Limiting
EMAIL_RATE_LIMIT_PER_HOUR: int = int(os.getenv("EMAIL_RATE_LIMIT_PER_HOUR", "1000"))
WHATSAPP_RATE_LIMIT_PER_HOUR: int = int(os.getenv("WHATSAPP_RATE_LIMIT_PER_HOUR", "100"))
# Spanish Localization
DEFAULT_LANGUAGE: str = os.getenv("DEFAULT_LANGUAGE", "es")
TIMEZONE: str = "Europe/Madrid"
DATE_FORMAT: str = "%d/%m/%Y"
TIME_FORMAT: str = "%H:%M"
# Notification Types
ENABLE_EMAIL_NOTIFICATIONS: bool = os.getenv("ENABLE_EMAIL_NOTIFICATIONS", "true").lower() == "true"
ENABLE_WHATSAPP_NOTIFICATIONS: bool = os.getenv("ENABLE_WHATSAPP_NOTIFICATIONS", "true").lower() == "true"
ENABLE_PUSH_NOTIFICATIONS: bool = os.getenv("ENABLE_PUSH_NOTIFICATIONS", "false").lower() == "true"
# Template Categories
ALERT_TEMPLATES_ENABLED: bool = True
MARKETING_TEMPLATES_ENABLED: bool = os.getenv("MARKETING_TEMPLATES_ENABLED", "false").lower() == "true"
TRANSACTIONAL_TEMPLATES_ENABLED: bool = True
# Delivery Configuration
IMMEDIATE_DELIVERY: bool = os.getenv("IMMEDIATE_DELIVERY", "true").lower() == "true"
SCHEDULED_DELIVERY_ENABLED: bool = os.getenv("SCHEDULED_DELIVERY_ENABLED", "true").lower() == "true"
BULK_DELIVERY_ENABLED: bool = os.getenv("BULK_DELIVERY_ENABLED", "true").lower() == "true"
# Analytics
DELIVERY_TRACKING_ENABLED: bool = os.getenv("DELIVERY_TRACKING_ENABLED", "true").lower() == "true"
OPEN_TRACKING_ENABLED: bool = os.getenv("OPEN_TRACKING_ENABLED", "true").lower() == "true"
CLICK_TRACKING_ENABLED: bool = os.getenv("CLICK_TRACKING_ENABLED", "true").lower() == "true"
settings = NotificationSettings()

View File

@@ -0,0 +1,430 @@
# ================================================================
# services/notification/app/core/database.py - COMPLETE IMPLEMENTATION
# ================================================================
"""
Database configuration and initialization for notification service
"""
import structlog
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy import text
from shared.database.base import Base, DatabaseManager
from app.core.config import settings
logger = structlog.get_logger()
# Initialize database manager with notification service configuration
database_manager = DatabaseManager(settings.DATABASE_URL)
# Convenience alias for dependency injection
get_db = database_manager.get_db
async def init_db():
"""Initialize database tables and seed data"""
try:
logger.info("Initializing notification service database...")
# Import all models to ensure they're registered with SQLAlchemy
from app.models.notifications import (
Notification, NotificationTemplate, NotificationPreference,
NotificationLog
)
# Import template models (these are separate and optional)
try:
from app.models.templates import EmailTemplate, WhatsAppTemplate
logger.info("Template models imported successfully")
except ImportError:
logger.warning("Template models not found, using basic templates only")
logger.info("Models imported successfully")
# Create all tables
await database_manager.create_tables()
logger.info("Database tables created successfully")
# Seed default templates
await _seed_default_templates()
logger.info("Default templates seeded successfully")
# Test database connection
await _test_database_connection()
logger.info("Database connection test passed")
logger.info("Notification service database initialization completed")
except Exception as e:
logger.error(f"Failed to initialize notification database: {e}")
raise
async def _seed_default_templates():
"""Seed default notification templates"""
try:
async for db in get_db():
# Check if templates already exist
from sqlalchemy import select
from app.models.notifications import NotificationTemplate
result = await db.execute(
select(NotificationTemplate).where(
NotificationTemplate.is_system == True
).limit(1)
)
if result.scalar_one_or_none():
logger.info("Default templates already exist, skipping seeding")
return
# Create default email templates
default_templates = [
{
"template_key": "welcome_email",
"name": "Bienvenida - Email",
"description": "Email de bienvenida para nuevos usuarios",
"category": "transactional",
"type": "email",
"subject_template": "¡Bienvenido a Bakery Forecast, {{user_name}}!",
"body_template": """
¡Hola {{user_name}}!
Bienvenido a Bakery Forecast, la plataforma de pronóstico de demanda para panaderías.
Tu cuenta ha sido creada exitosamente. Ya puedes:
- Subir datos de ventas históricos
- Generar pronósticos de demanda
- Optimizar tu producción diaria
Para comenzar, visita tu dashboard: {{dashboard_url}}
Si tienes alguna pregunta, nuestro equipo está aquí para ayudarte.
¡Éxito en tu panadería!
Saludos,
El equipo de Bakery Forecast
""".strip(),
"html_template": """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenido a Bakery Forecast</title>
</head>
<body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; background-color: #f4f4f4;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 28px;">🥖 Bakery Forecast</h1>
<p style="color: #e8e8e8; margin: 10px 0 0 0; font-size: 16px;">Pronósticos inteligentes para tu panadería</p>
</div>
<div style="background: white; padding: 30px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<h2 style="color: #333; margin-top: 0;">¡Hola {{user_name}}!</h2>
<p style="font-size: 16px; margin-bottom: 20px;">
Bienvenido a <strong>Bakery Forecast</strong>, la plataforma de pronóstico de demanda diseñada especialmente para panaderías como la tuya.
</p>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 25px 0;">
<h3 style="color: #495057; margin-top: 0; font-size: 18px;">🎯 Tu cuenta está lista</h3>
<p style="margin-bottom: 15px;">Ya puedes comenzar a:</p>
<ul style="color: #495057; padding-left: 20px;">
<li style="margin-bottom: 8px;"><strong>📊 Subir datos de ventas</strong> - Importa tu historial de ventas</li>
<li style="margin-bottom: 8px;"><strong>🔮 Generar pronósticos</strong> - Obtén predicciones precisas de demanda</li>
<li style="margin-bottom: 8px;"><strong>⚡ Optimizar producción</strong> - Reduce desperdicios y maximiza ganancias</li>
</ul>
</div>
<div style="text-align: center; margin: 35px 0;">
<a href="{{dashboard_url}}"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 30px;
text-decoration: none;
border-radius: 25px;
font-weight: bold;
font-size: 16px;
display: inline-block;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
transition: all 0.3s ease;">
🚀 Ir al Dashboard
</a>
</div>
<div style="background: #e7f3ff; border-left: 4px solid #0066cc; padding: 15px; margin: 25px 0; border-radius: 0 8px 8px 0;">
<p style="margin: 0; color: #004085;">
<strong>💡 Consejo:</strong> Para obtener mejores pronósticos, te recomendamos subir al menos 3 meses de datos históricos de ventas.
</p>
</div>
<p style="font-size: 16px;">
Si tienes alguna pregunta o necesitas ayuda, nuestro equipo está aquí para apoyarte en cada paso.
</p>
<p style="font-size: 16px; margin-bottom: 0;">
¡Éxito en tu panadería! 🥐<br>
<strong>El equipo de Bakery Forecast</strong>
</p>
</div>
<div style="background: #6c757d; color: white; padding: 20px; text-align: center; font-size: 12px; border-radius: 0 0 10px 10px;">
<p style="margin: 0;">© 2025 Bakery Forecast. Todos los derechos reservados.</p>
<p style="margin: 5px 0 0 0;">Madrid, España 🇪🇸</p>
</div>
</body>
</html>
""".strip(),
"language": "es",
"is_system": True,
"is_active": True,
"default_priority": "normal"
},
{
"template_key": "forecast_alert_email",
"name": "Alerta de Pronóstico - Email",
"description": "Alerta por email cuando hay cambios significativos en la demanda",
"category": "alert",
"type": "email",
"subject_template": "🚨 Alerta: Variación significativa en {{product_name}}",
"body_template": """
ALERTA DE PRONÓSTICO - {{bakery_name}}
Se ha detectado una variación significativa en la demanda prevista:
📦 Producto: {{product_name}}
📅 Fecha: {{forecast_date}}
📊 Demanda prevista: {{predicted_demand}} unidades
📈 Variación: {{variation_percentage}}%
{{alert_message}}
Te recomendamos revisar los pronósticos y ajustar la producción según sea necesario.
Ver pronósticos completos: {{dashboard_url}}
Saludos,
El equipo de Bakery Forecast
""".strip(),
"html_template": """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alerta de Pronóstico</title>
</head>
<body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; background-color: #f4f4f4;">
<div style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); padding: 25px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">🚨 Alerta de Pronóstico</h1>
<p style="color: #ffe8e8; margin: 10px 0 0 0;">{{bakery_name}}</p>
</div>
<div style="background: white; padding: 30px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<div style="background: #fff5f5; border: 2px solid #ff6b6b; padding: 20px; border-radius: 8px; margin-bottom: 25px;">
<h2 style="color: #c53030; margin-top: 0; font-size: 18px;">⚠️ Variación Significativa Detectada</h2>
<p style="color: #c53030; margin-bottom: 0;">Se requiere tu atención para ajustar la producción.</p>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; font-weight: bold; color: #495057; width: 40%;">📦 Producto:</td>
<td style="padding: 8px 0; color: #212529;">{{product_name}}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold; color: #495057;">📅 Fecha:</td>
<td style="padding: 8px 0; color: #212529;">{{forecast_date}}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold; color: #495057;">📊 Demanda prevista:</td>
<td style="padding: 8px 0; color: #212529; font-weight: bold;">{{predicted_demand}} unidades</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold; color: #495057;">📈 Variación:</td>
<td style="padding: 8px 0; color: #ff6b6b; font-weight: bold; font-size: 18px;">{{variation_percentage}}%</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h4 style="margin-top: 0; color: #856404; font-size: 16px;">💡 Recomendación:</h4>
<p style="margin-bottom: 0; color: #856404;">{{alert_message}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_url}}"
style="background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
padding: 15px 30px;
text-decoration: none;
border-radius: 25px;
font-weight: bold;
font-size: 16px;
display: inline-block;
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);">
📊 Ver Pronósticos Completos
</a>
</div>
<p style="font-size: 14px; color: #6c757d; text-align: center; margin-top: 30px;">
<strong>El equipo de Bakery Forecast</strong>
</p>
</div>
</body>
</html>
""".strip(),
"language": "es",
"is_system": True,
"is_active": True,
"default_priority": "high"
},
{
"template_key": "weekly_report_email",
"name": "Reporte Semanal - Email",
"description": "Reporte semanal de rendimiento y estadísticas",
"category": "transactional",
"type": "email",
"subject_template": "📊 Reporte Semanal - {{bakery_name}} ({{week_start}} - {{week_end}})",
"body_template": """
REPORTE SEMANAL - {{bakery_name}}
Período: {{week_start}} - {{week_end}}
RESUMEN DE VENTAS:
- Total de ventas: {{total_sales}} unidades
- Precisión del pronóstico: {{forecast_accuracy}}%
- Productos más vendidos:
{{#top_products}}
{{name}}: {{quantity}} unidades
{{/top_products}}
ANÁLISIS:
{{recommendations}}
Ver reporte completo: {{report_url}}
Saludos,
El equipo de Bakery Forecast
""".strip(),
"html_template": """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reporte Semanal</title>
</head>
<body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; background-color: #f4f4f4;">
<div style="background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); padding: 25px; text-align: center; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">📊 Reporte Semanal</h1>
<p style="color: #ddd; margin: 10px 0 0 0;">{{bakery_name}}</p>
<p style="color: #bbb; margin: 5px 0 0 0; font-size: 14px;">{{week_start}} - {{week_end}}</p>
</div>
<div style="background: white; padding: 30px; border-radius: 0 0 10px 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<div style="display: flex; gap: 15px; margin: 25px 0; flex-wrap: wrap;">
<div style="background: linear-gradient(135deg, #00b894 0%, #00a085 100%); padding: 20px; border-radius: 10px; flex: 1; text-align: center; min-width: 120px; color: white;">
<h3 style="margin: 0; font-size: 28px;">{{total_sales}}</h3>
<p style="margin: 5px 0 0 0; font-size: 14px; opacity: 0.9;">Ventas Totales</p>
</div>
<div style="background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); padding: 20px; border-radius: 10px; flex: 1; text-align: center; min-width: 120px; color: white;">
<h3 style="margin: 0; font-size: 28px;">{{forecast_accuracy}}%</h3>
<p style="margin: 5px 0 0 0; font-size: 14px; opacity: 0.9;">Precisión</p>
</div>
</div>
<h3 style="color: #333; margin: 30px 0 15px 0; font-size: 18px;">🏆 Productos más vendidos:</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
{{#top_products}}
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #e9ecef;">
<span style="font-weight: 500; color: #495057;">{{name}}</span>
<span style="background: #007bff; color: white; padding: 4px 12px; border-radius: 15px; font-size: 12px; font-weight: bold;">{{quantity}} unidades</span>
</div>
{{/top_products}}
</div>
<div style="background: #e7f3ff; border-left: 4px solid #007bff; padding: 20px; margin: 25px 0; border-radius: 0 8px 8px 0;">
<h4 style="margin-top: 0; color: #004085; font-size: 16px;">📈 Análisis y Recomendaciones:</h4>
<p style="margin-bottom: 0; color: #004085; line-height: 1.6;">{{recommendations}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{report_url}}"
style="background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
padding: 15px 30px;
text-decoration: none;
border-radius: 25px;
font-weight: bold;
font-size: 16px;
display: inline-block;
box-shadow: 0 4px 15px rgba(116, 185, 255, 0.4);">
📋 Ver Reporte Completo
</a>
</div>
<p style="font-size: 14px; color: #6c757d; text-align: center; margin-top: 30px; border-top: 1px solid #e9ecef; padding-top: 20px;">
<strong>El equipo de Bakery Forecast</strong><br>
<span style="font-size: 12px;">Optimizando panaderías en Madrid desde 2025</span>
</p>
</div>
</body>
</html>
""".strip(),
"language": "es",
"is_system": True,
"is_active": True,
"default_priority": "normal"
}
]
# Create template objects
from app.models.notifications import NotificationTemplate, NotificationType, NotificationPriority
for template_data in default_templates:
template = NotificationTemplate(
template_key=template_data["template_key"],
name=template_data["name"],
description=template_data["description"],
category=template_data["category"],
type=NotificationType(template_data["type"]),
subject_template=template_data["subject_template"],
body_template=template_data["body_template"],
html_template=template_data["html_template"],
language=template_data["language"],
is_system=template_data["is_system"],
is_active=template_data["is_active"],
default_priority=NotificationPriority(template_data["default_priority"])
)
db.add(template)
await db.commit()
logger.info(f"Created {len(default_templates)} default templates")
except Exception as e:
logger.error(f"Failed to seed default templates: {e}")
raise
async def _test_database_connection():
"""Test database connection"""
try:
async for db in get_db():
result = await db.execute(text("SELECT 1"))
if result.scalar() == 1:
logger.info("Database connection test successful")
else:
raise Exception("Database connection test failed")
except Exception as e:
logger.error(f"Database connection test failed: {e}")
raise
# Health check function for the database
async def check_database_health() -> bool:
"""Check if database is healthy"""
try:
await _test_database_connection()
return True
except Exception as e:
logger.error(f"Database health check failed: {e}")
return False

View File

@@ -0,0 +1,315 @@
# ================================================================
# services/notification/app/main.py - ENHANCED WITH SSE SUPPORT
# ================================================================
"""
Notification Service Main Application
Handles email, WhatsApp notifications and SSE for real-time alerts/recommendations
"""
from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api.notifications import router as notification_router
from app.api.notification_operations import router as notification_operations_router
from app.api.analytics import router as analytics_router
from app.api.audit import router as audit_router
from app.api.whatsapp_webhooks import router as whatsapp_webhooks_router
from app.services.sse_service import SSEService
from app.services.notification_orchestrator import NotificationOrchestrator
from app.services.email_service import EmailService
from app.services.whatsapp_service import WhatsAppService
from app.consumers.po_event_consumer import POEventConsumer
from shared.service_base import StandardFastAPIService
from shared.clients.tenant_client import TenantServiceClient
from shared.monitoring.system_metrics import SystemMetricsCollector
import asyncio
class NotificationService(StandardFastAPIService):
"""Notification Service with standardized setup"""
expected_migration_version = "whatsapp001"
async def verify_migrations(self):
"""Verify database schema matches the latest migrations."""
try:
async with self.database_manager.get_session() as session:
result = await session.execute(text("SELECT version_num FROM alembic_version"))
version = result.scalar()
if version != self.expected_migration_version:
self.logger.error(f"Migration version mismatch: expected {self.expected_migration_version}, got {version}")
raise RuntimeError(f"Migration version mismatch: expected {self.expected_migration_version}, got {version}")
self.logger.info(f"Migration verification successful: {version}")
except Exception as e:
self.logger.error(f"Migration verification failed: {e}")
raise
def __init__(self):
# Define expected database tables for health checks
notification_expected_tables = [
'notifications', 'notification_templates', 'notification_preferences',
'notification_logs', 'email_templates', 'whatsapp_messages', 'whatsapp_templates'
]
self.sse_service = None
self.orchestrator = None
self.email_service = None
self.whatsapp_service = None
self.tenant_client = None
self.po_consumer = None
self.po_consumer_task = None
# Define custom metrics for notification service
notification_custom_metrics = {
"notifications_sent_total": {
"type": "counter",
"description": "Total notifications sent",
"labels": ["type", "status", "channel"]
},
"emails_sent_total": {
"type": "counter",
"description": "Total emails sent",
"labels": ["status"]
},
"whatsapp_sent_total": {
"type": "counter",
"description": "Total WhatsApp messages sent",
"labels": ["status"]
},
"sse_events_sent_total": {
"type": "counter",
"description": "Total SSE events sent",
"labels": ["tenant", "event_type"]
},
"notification_processing_duration_seconds": {
"type": "histogram",
"description": "Time spent processing notifications"
}
}
# Define custom health checks for notification service components
#async def check_email_service():
# """Check email service health - service is ready even if credentials are invalid"""
# try:
# if not self.email_service:
# return False
# # Service is considered healthy if it's initialized, even if credentials fail
# # This allows the pod to be ready while external services may have config issues
# await self.email_service.health_check()
# return True
# except Exception as e:
# Log but don't fail readiness - email service config issues shouldn't block the pod
# self.logger.error("Email service health check failed", error=str(e))
# Return True to indicate service is ready (initialized) even if credentials are wrong
# return True
#async def check_whatsapp_service():
# """Check WhatsApp service health - service is ready even if credentials are invalid"""
# try:
# if not self.whatsapp_service:
# return False
# Service is considered healthy if it's initialized, even if credentials fail
# await self.whatsapp_service.health_check()
# return True
# except Exception as e:
# Log but don't fail readiness - WhatsApp config issues shouldn't block the pod
# self.logger.error("WhatsApp service health check failed", error=str(e))
# Return True to indicate service is ready (initialized) even if credentials are wrong
# return True
async def check_sse_service():
"""Check SSE service health"""
try:
if self.sse_service:
metrics = self.sse_service.get_metrics()
return bool(metrics.get("redis_connected", False))
return False
except Exception as e:
self.logger.error("SSE service health check failed", error=str(e))
return False
#async def check_messaging():
# """Check messaging service health"""
# try:
# from app.services.messaging import notification_publisher
# return bool(notification_publisher and notification_publisher.connected)
# except Exception as e:
# self.logger.error("Messaging health check failed", error=str(e))
# return False
super().__init__(
service_name="notification-service",
app_name="Bakery Notification Service",
description="Email, WhatsApp and SSE notification service for bakery alerts and recommendations",
version="2.0.0",
log_level=settings.LOG_LEVEL,
cors_origins=getattr(settings, 'CORS_ORIGINS', ["*"]),
api_prefix="", # Empty because RouteBuilder already includes /api/v1
database_manager=database_manager,
expected_tables=notification_expected_tables,
custom_health_checks={
# "email_service": check_email_service,
# "whatsapp_service": check_whatsapp_service,
"sse_service": check_sse_service,
# "messaging": check_messaging
},
enable_messaging=True,
custom_metrics=notification_custom_metrics
)
async def _setup_messaging(self):
"""Setup messaging for notification service using unified messaging"""
# The base class will handle the unified messaging setup
# For notification service, no additional setup is needed
self.logger.info("Notification service messaging initialized")
async def _cleanup_messaging(self):
"""Cleanup messaging for notification service"""
# The base class will handle the unified messaging cleanup
self.logger.info("Notification service messaging cleaned up")
async def on_startup(self, app: FastAPI):
"""Custom startup logic for notification service"""
# Verify migrations first
await self.verify_migrations()
# Call parent startup (includes database, messaging, etc.)
await super().on_startup(app)
# Initialize tenant client for fetching tenant-specific settings
self.tenant_client = TenantServiceClient(settings)
self.logger.info("Tenant service client initialized")
# Initialize services
self.email_service = EmailService()
self.whatsapp_service = WhatsAppService(tenant_client=self.tenant_client)
# Initialize system metrics collection
system_metrics = SystemMetricsCollector("notification")
self.logger.info("System metrics collection started")
# Initialize SSE service
self.sse_service = SSEService()
await self.sse_service.initialize(settings.REDIS_URL)
self.logger.info("SSE service initialized")
# Create orchestrator
self.orchestrator = NotificationOrchestrator(
email_service=self.email_service,
whatsapp_service=self.whatsapp_service,
sse_service=self.sse_service
)
# Store services in app state
app.state.orchestrator = self.orchestrator
app.state.sse_service = self.sse_service
app.state.email_service = self.email_service
app.state.whatsapp_service = self.whatsapp_service
# Initialize and start PO event consumer
self.po_consumer = POEventConsumer(
email_service=self.email_service,
whatsapp_service=self.whatsapp_service
)
# Start consuming PO approved events in background
# Initialize unified messaging publisher
from shared.messaging import UnifiedEventPublisher, RabbitMQClient
rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, "notification-service")
if await rabbitmq_client.connect():
notification_publisher = UnifiedEventPublisher(rabbitmq_client, "notification-service")
self.po_consumer_task = asyncio.create_task(
self.po_consumer.consume_po_approved_event(notification_publisher)
)
self.logger.info("PO event consumer started successfully")
else:
self.logger.warning("RabbitMQ not connected, PO event consumer not started")
app.state.po_consumer = self.po_consumer
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for notification service"""
# Cancel PO consumer task
if self.po_consumer_task and not self.po_consumer_task.done():
self.po_consumer_task.cancel()
try:
await self.po_consumer_task
except asyncio.CancelledError:
self.logger.info("PO event consumer task cancelled")
# Shutdown SSE service
if self.sse_service:
await self.sse_service.shutdown()
self.logger.info("SSE service shutdown completed")
def get_service_features(self):
"""Return notification-specific features"""
return [
"email_notifications",
"whatsapp_notifications",
"sse_real_time_updates",
"notification_templates",
"notification_orchestration",
"messaging_integration",
"multi_channel_support"
]
def setup_custom_endpoints(self):
"""Setup custom endpoints for notification service"""
# SSE metrics endpoint
@self.app.get("/sse-metrics")
async def sse_metrics():
"""Get SSE service metrics"""
if self.sse_service:
try:
sse_metrics = self.sse_service.get_metrics()
return {
'active_tenants': sse_metrics.get('active_tenants', 0),
'total_connections': sse_metrics.get('total_connections', 0),
'active_listeners': sse_metrics.get('active_listeners', 0),
'redis_connected': bool(sse_metrics.get('redis_connected', False))
}
except Exception as e:
return {"error": str(e)}
return {"error": "SSE service not available"}
# Metrics endpoint
# Note: Metrics are exported via OpenTelemetry OTLP to SigNoz
# The /metrics endpoint is not needed as metrics are pushed automatically
# @self.app.get("/metrics")
# async def metrics():
# """Prometheus metrics endpoint"""
# if self.metrics_collector:
# return self.metrics_collector.get_metrics()
# return {"metrics": "not_available"}
# Create service instance
service = NotificationService()
# Create FastAPI app with standardized setup
app = service.create_app(
docs_url="/docs",
redoc_url="/redoc"
)
# Setup standard endpoints
service.setup_standard_endpoints()
# Setup custom endpoints
service.setup_custom_endpoints()
# Include routers
# IMPORTANT: Register audit router FIRST to avoid route matching conflicts
# where {notification_id} would match literal paths like "audit-logs"
service.add_router(audit_router, tags=["audit-logs"])
service.add_router(whatsapp_webhooks_router, tags=["whatsapp-webhooks"])
service.add_router(notification_operations_router, tags=["notification-operations"])
service.add_router(analytics_router, tags=["notifications-analytics"])
service.add_router(notification_router, tags=["notifications"])
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,49 @@
"""
Notification Service Models Package
Import all models to ensure they are registered with SQLAlchemy Base.
"""
# Import AuditLog model for this service
from shared.security import create_audit_log_model
from shared.database.base import Base
# Create audit log model for this service
AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .notifications import (
Notification,
NotificationTemplate,
NotificationType,
NotificationStatus,
NotificationPriority,
NotificationPreference,
NotificationLog,
)
from .templates import (
EmailTemplate,
)
from .whatsapp_messages import (
WhatsAppTemplate,
WhatsAppMessage,
WhatsAppMessageStatus,
WhatsAppMessageType,
)
# List all models for easier access
__all__ = [
"Notification",
"NotificationTemplate",
"NotificationType",
"NotificationStatus",
"NotificationPriority",
"NotificationPreference",
"NotificationLog",
"EmailTemplate",
"WhatsAppTemplate",
"WhatsAppMessage",
"WhatsAppMessageStatus",
"WhatsAppMessageType",
"AuditLog",
]

View File

@@ -0,0 +1,184 @@
# ================================================================
# services/notification/app/models/notifications.py
# ================================================================
"""
Notification models for the notification service
"""
from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer, Enum
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
import uuid
import enum
from shared.database.base import Base
class NotificationType(enum.Enum):
"""Notification types supported by the service"""
EMAIL = "email"
WHATSAPP = "whatsapp"
PUSH = "push"
SMS = "sms"
class NotificationStatus(enum.Enum):
"""Notification delivery status"""
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
FAILED = "failed"
CANCELLED = "cancelled"
class NotificationPriority(enum.Enum):
"""Notification priority levels"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class Notification(Base):
"""Main notification record"""
__tablename__ = "notifications"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
sender_id = Column(UUID(as_uuid=True), nullable=False)
recipient_id = Column(UUID(as_uuid=True), nullable=True) # Null for broadcast
# Notification details
type = Column(Enum(NotificationType), nullable=False)
status = Column(Enum(NotificationStatus), default=NotificationStatus.PENDING, index=True)
priority = Column(Enum(NotificationPriority), default=NotificationPriority.NORMAL)
# Content
subject = Column(String(255), nullable=True)
message = Column(Text, nullable=False)
html_content = Column(Text, nullable=True)
template_id = Column(String(100), nullable=True)
template_data = Column(JSON, nullable=True)
# Delivery details
recipient_email = Column(String(255), nullable=True)
recipient_phone = Column(String(20), nullable=True)
delivery_channel = Column(String(50), nullable=True)
# Scheduling
scheduled_at = Column(DateTime, nullable=True)
sent_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
# Metadata
log_metadata = Column(JSON, nullable=True)
error_message = Column(Text, nullable=True)
retry_count = Column(Integer, default=0)
max_retries = Column(Integer, default=3)
# Tracking
broadcast = Column(Boolean, default=False)
read = Column(Boolean, default=False)
read_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class NotificationTemplate(Base):
"""Email and notification templates"""
__tablename__ = "notification_templates"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Null for system templates
# Template identification
template_key = Column(String(100), nullable=False, unique=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
category = Column(String(50), nullable=False) # alert, marketing, transactional
# Template content
type = Column(Enum(NotificationType), nullable=False)
subject_template = Column(String(255), nullable=True)
body_template = Column(Text, nullable=False)
html_template = Column(Text, nullable=True)
# Configuration
language = Column(String(2), default="es")
is_active = Column(Boolean, default=True)
is_system = Column(Boolean, default=False) # System templates can't be deleted
# Metadata
default_priority = Column(Enum(NotificationPriority), default=NotificationPriority.NORMAL)
required_variables = Column(JSON, nullable=True) # List of required template variables
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class NotificationPreference(Base):
"""User notification preferences"""
__tablename__ = "notification_preferences"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, unique=True, index=True)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Email preferences
email_enabled = Column(Boolean, default=True)
email_alerts = Column(Boolean, default=True)
email_marketing = Column(Boolean, default=False)
email_reports = Column(Boolean, default=True)
# WhatsApp preferences
whatsapp_enabled = Column(Boolean, default=False)
whatsapp_alerts = Column(Boolean, default=False)
whatsapp_reports = Column(Boolean, default=False)
# Push notification preferences
push_enabled = Column(Boolean, default=True)
push_alerts = Column(Boolean, default=True)
push_reports = Column(Boolean, default=False)
# Timing preferences
quiet_hours_start = Column(String(5), default="22:00") # HH:MM format
quiet_hours_end = Column(String(5), default="08:00")
timezone = Column(String(50), default="Europe/Madrid")
# Frequency preferences
digest_frequency = Column(String(20), default="daily") # none, daily, weekly
max_emails_per_day = Column(Integer, default=10)
# Language preference
language = Column(String(2), default="es")
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class NotificationLog(Base):
"""Detailed logging for notification delivery attempts"""
__tablename__ = "notification_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
notification_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Attempt details
attempt_number = Column(Integer, nullable=False)
status = Column(Enum(NotificationStatus), nullable=False)
# Provider details
provider = Column(String(50), nullable=True) # e.g., "gmail", "twilio"
provider_message_id = Column(String(255), nullable=True)
provider_response = Column(JSON, nullable=True)
# Timing
attempted_at = Column(DateTime, default=datetime.utcnow)
response_time_ms = Column(Integer, nullable=True)
# Error details
error_code = Column(String(50), nullable=True)
error_message = Column(Text, nullable=True)
# Additional metadata
log_metadata = Column(JSON, nullable=True)

View File

@@ -0,0 +1,84 @@
# ================================================================
# services/notification/app/models/templates.py
# ================================================================
"""
Template-specific models for email and WhatsApp templates
"""
from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Integer
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
import uuid
from shared.database.base import Base
class EmailTemplate(Base):
"""Email-specific templates with HTML support"""
__tablename__ = "email_templates"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Template identification
template_key = Column(String(100), nullable=False, unique=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
# Email-specific content
subject = Column(String(255), nullable=False)
html_body = Column(Text, nullable=False)
text_body = Column(Text, nullable=True) # Plain text fallback
# Email settings
from_email = Column(String(255), nullable=True)
from_name = Column(String(255), nullable=True)
reply_to = Column(String(255), nullable=True)
# Template variables
variables = Column(JSON, nullable=True) # Expected variables and their types
sample_data = Column(JSON, nullable=True) # Sample data for preview
# Configuration
language = Column(String(2), default="es")
is_active = Column(Boolean, default=True)
is_system = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# NOTE: WhatsAppTemplate has been moved to app/models/whatsapp_messages.py
# This old definition is commented out to avoid duplicate table definition errors
# class WhatsAppTemplate(Base):
# """WhatsApp-specific templates"""
# __tablename__ = "whatsapp_templates"
#
# id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True)
#
# # Template identification
# template_key = Column(String(100), nullable=False, unique=True)
# name = Column(String(255), nullable=False)
#
# # WhatsApp template details
# whatsapp_template_name = Column(String(255), nullable=False) # Template name in WhatsApp Business API
# whatsapp_template_id = Column(String(255), nullable=True)
# language_code = Column(String(10), default="es")
#
# # Template content
# header_text = Column(String(60), nullable=True) # WhatsApp header limit
# body_text = Column(Text, nullable=False)
# footer_text = Column(String(60), nullable=True) # WhatsApp footer limit
#
# # Template parameters
# parameter_count = Column(Integer, default=0)
# parameters = Column(JSON, nullable=True) # Parameter definitions
#
# # Status
# approval_status = Column(String(20), default="pending") # pending, approved, rejected
# is_active = Column(Boolean, default=True)
#
# # Timestamps
# created_at = Column(DateTime, default=datetime.utcnow)
# updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -0,0 +1,135 @@
# ================================================================
# services/notification/app/models/whatsapp_messages.py
# ================================================================
"""
WhatsApp message tracking models for WhatsApp Business Cloud API
"""
from sqlalchemy import Column, String, Text, Boolean, DateTime, JSON, Enum, Integer
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
import uuid
import enum
from shared.database.base import Base
class WhatsAppMessageStatus(enum.Enum):
"""WhatsApp message delivery status"""
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
READ = "read"
FAILED = "failed"
class WhatsAppMessageType(enum.Enum):
"""WhatsApp message types"""
TEMPLATE = "template"
TEXT = "text"
IMAGE = "image"
DOCUMENT = "document"
INTERACTIVE = "interactive"
class WhatsAppMessage(Base):
"""Track WhatsApp messages sent via Cloud API"""
__tablename__ = "whatsapp_messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
notification_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Link to notification if exists
# Message identification
whatsapp_message_id = Column(String(255), nullable=True, index=True) # WhatsApp's message ID
# Recipient details
recipient_phone = Column(String(20), nullable=False, index=True) # E.164 format
recipient_name = Column(String(255), nullable=True)
# Message details
message_type = Column(Enum(WhatsAppMessageType), nullable=False)
status = Column(Enum(WhatsAppMessageStatus), default=WhatsAppMessageStatus.PENDING, index=True)
# Template details (for template messages)
template_name = Column(String(255), nullable=True)
template_language = Column(String(10), default="es")
template_parameters = Column(JSON, nullable=True) # Template variable values
# Message content (for non-template messages)
message_body = Column(Text, nullable=True)
media_url = Column(String(512), nullable=True)
# Delivery tracking
sent_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
read_at = Column(DateTime, nullable=True)
failed_at = Column(DateTime, nullable=True)
# Error tracking
error_code = Column(String(50), nullable=True)
error_message = Column(Text, nullable=True)
# Provider response
provider_response = Column(JSON, nullable=True)
# Additional data (renamed from metadata to avoid SQLAlchemy reserved word)
additional_data = Column(JSON, nullable=True) # Additional context (PO number, order ID, etc.)
# Conversation tracking
conversation_id = Column(String(255), nullable=True, index=True) # WhatsApp conversation ID
conversation_category = Column(String(50), nullable=True) # business_initiated, user_initiated
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class WhatsAppTemplate(Base):
"""Store WhatsApp message templates metadata"""
__tablename__ = "whatsapp_templates"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True) # Null for system templates
# Template identification
template_name = Column(String(255), nullable=False, index=True) # Name in WhatsApp
template_key = Column(String(100), nullable=False, unique=True) # Internal key
display_name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
category = Column(String(50), nullable=False) # MARKETING, UTILITY, AUTHENTICATION
# Template configuration
language = Column(String(10), default="es")
status = Column(String(20), default="PENDING") # PENDING, APPROVED, REJECTED
# Template structure
header_type = Column(String(20), nullable=True) # TEXT, IMAGE, DOCUMENT, VIDEO
header_text = Column(String(60), nullable=True)
body_text = Column(Text, nullable=False)
footer_text = Column(String(60), nullable=True)
# Parameters
parameters = Column(JSON, nullable=True) # List of parameter definitions
parameter_count = Column(Integer, default=0)
# Buttons (for interactive templates)
buttons = Column(JSON, nullable=True)
# Metadata
is_active = Column(Boolean, default=True)
is_system = Column(Boolean, default=False)
# Usage tracking
sent_count = Column(Integer, default=0)
last_used_at = Column(DateTime, nullable=True)
# WhatsApp metadata
whatsapp_template_id = Column(String(255), nullable=True)
approved_at = Column(DateTime, nullable=True)
rejected_at = Column(DateTime, nullable=True)
rejection_reason = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -0,0 +1,18 @@
"""
Notification Service Repositories
Repository implementations for notification service
"""
from .base import NotificationBaseRepository
from .notification_repository import NotificationRepository
from .template_repository import TemplateRepository
from .preference_repository import PreferenceRepository
from .log_repository import LogRepository
__all__ = [
"NotificationBaseRepository",
"NotificationRepository",
"TemplateRepository",
"PreferenceRepository",
"LogRepository"
]

View File

@@ -0,0 +1,265 @@
"""
Base Repository for Notification Service
Service-specific repository base class with notification utilities
"""
from typing import Optional, List, Dict, Any, Type
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text, and_
from datetime import datetime, timedelta
import structlog
from shared.database.repository import BaseRepository
from shared.database.exceptions import DatabaseError
logger = structlog.get_logger()
class NotificationBaseRepository(BaseRepository):
"""Base repository for notification service with common notification operations"""
def __init__(self, model: Type, session: AsyncSession, cache_ttl: Optional[int] = 300):
# Notifications change frequently, shorter cache time (5 minutes)
super().__init__(model, session, cache_ttl)
async def get_by_tenant_id(self, tenant_id: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by tenant ID"""
if hasattr(self.model, 'tenant_id'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"tenant_id": tenant_id},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def get_by_user_id(self, user_id: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by user ID (recipient or sender)"""
filters = {}
if hasattr(self.model, 'recipient_id'):
filters["recipient_id"] = user_id
elif hasattr(self.model, 'sender_id'):
filters["sender_id"] = user_id
elif hasattr(self.model, 'user_id'):
filters["user_id"] = user_id
if filters:
return await self.get_multi(
skip=skip,
limit=limit,
filters=filters,
order_by="created_at",
order_desc=True
)
return []
async def get_by_status(self, status: str, skip: int = 0, limit: int = 100) -> List:
"""Get records by status"""
if hasattr(self.model, 'status'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"status": status},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def get_active_records(self, skip: int = 0, limit: int = 100) -> List:
"""Get active records (if model has is_active field)"""
if hasattr(self.model, 'is_active'):
return await self.get_multi(
skip=skip,
limit=limit,
filters={"is_active": True},
order_by="created_at",
order_desc=True
)
return await self.get_multi(skip=skip, limit=limit)
async def get_recent_records(self, hours: int = 24, skip: int = 0, limit: int = 100) -> List:
"""Get records created in the last N hours"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=hours)
table_name = self.model.__tablename__
query_text = f"""
SELECT * FROM {table_name}
WHERE created_at >= :cutoff_time
ORDER BY created_at DESC
LIMIT :limit OFFSET :skip
"""
result = await self.session.execute(text(query_text), {
"cutoff_time": cutoff_time,
"limit": limit,
"skip": skip
})
records = []
for row in result.fetchall():
record_dict = dict(row._mapping)
record = self.model(**record_dict)
records.append(record)
return records
except Exception as e:
logger.error("Failed to get recent records",
model=self.model.__name__,
hours=hours,
error=str(e))
return []
async def cleanup_old_records(self, days_old: int = 90) -> int:
"""Clean up old notification records (90 days by default)"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
table_name = self.model.__tablename__
# Only delete successfully processed or cancelled records that are old
conditions = [
"created_at < :cutoff_date"
]
# Add status condition if model has status field
if hasattr(self.model, 'status'):
conditions.append("status IN ('delivered', 'cancelled', 'failed')")
query_text = f"""
DELETE FROM {table_name}
WHERE {' AND '.join(conditions)}
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info(f"Cleaned up old {self.model.__name__} records",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old records",
model=self.model.__name__,
error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")
async def get_statistics_by_tenant(self, tenant_id: str) -> Dict[str, Any]:
"""Get statistics for a tenant"""
try:
table_name = self.model.__tablename__
# Get basic counts
total_records = await self.count(filters={"tenant_id": tenant_id})
# Get recent activity (records in last 24 hours)
twenty_four_hours_ago = datetime.utcnow() - timedelta(hours=24)
recent_query = text(f"""
SELECT COUNT(*) as count
FROM {table_name}
WHERE tenant_id = :tenant_id
AND created_at >= :twenty_four_hours_ago
""")
result = await self.session.execute(recent_query, {
"tenant_id": tenant_id,
"twenty_four_hours_ago": twenty_four_hours_ago
})
recent_records = result.scalar() or 0
# Get status breakdown if applicable
status_breakdown = {}
if hasattr(self.model, 'status'):
status_query = text(f"""
SELECT status, COUNT(*) as count
FROM {table_name}
WHERE tenant_id = :tenant_id
GROUP BY status
""")
result = await self.session.execute(status_query, {"tenant_id": tenant_id})
status_breakdown = {row.status: row.count for row in result.fetchall()}
return {
"total_records": total_records,
"recent_records_24h": recent_records,
"status_breakdown": status_breakdown
}
except Exception as e:
logger.error("Failed to get tenant statistics",
model=self.model.__name__,
tenant_id=tenant_id,
error=str(e))
return {
"total_records": 0,
"recent_records_24h": 0,
"status_breakdown": {}
}
def _validate_notification_data(self, data: Dict[str, Any], required_fields: List[str]) -> Dict[str, Any]:
"""Validate notification-related data"""
errors = []
for field in required_fields:
if field not in data or not data[field]:
errors.append(f"Missing required field: {field}")
# Validate tenant_id format if present
if "tenant_id" in data and data["tenant_id"]:
tenant_id = data["tenant_id"]
if not isinstance(tenant_id, str) or len(tenant_id) < 1:
errors.append("Invalid tenant_id format")
# Validate user IDs if present
user_fields = ["user_id", "recipient_id", "sender_id"]
for field in user_fields:
if field in data and data[field]:
user_id = data[field]
if not isinstance(user_id, str) or len(user_id) < 1:
errors.append(f"Invalid {field} format")
# Validate email format if present
if "recipient_email" in data and data["recipient_email"]:
email = data["recipient_email"]
if "@" not in email or "." not in email.split("@")[-1]:
errors.append("Invalid email format")
# Validate phone format if present
if "recipient_phone" in data and data["recipient_phone"]:
phone = data["recipient_phone"]
if not isinstance(phone, str) or len(phone) < 9:
errors.append("Invalid phone format")
# Validate priority if present
if "priority" in data and data["priority"]:
from enum import Enum
priority_value = data["priority"].value if isinstance(data["priority"], Enum) else data["priority"]
valid_priorities = ["low", "normal", "high", "urgent"]
if priority_value not in valid_priorities:
errors.append(f"Invalid priority. Must be one of: {valid_priorities}")
# Validate notification type if present
if "type" in data and data["type"]:
from enum import Enum
type_value = data["type"].value if isinstance(data["type"], Enum) else data["type"]
valid_types = ["email", "whatsapp", "push", "sms"]
if type_value not in valid_types:
errors.append(f"Invalid notification type. Must be one of: {valid_types}")
# Validate status if present
if "status" in data and data["status"]:
from enum import Enum
status_value = data["status"].value if isinstance(data["status"], Enum) else data["status"]
valid_statuses = ["pending", "sent", "delivered", "failed", "cancelled"]
if status_value not in valid_statuses:
errors.append(f"Invalid status. Must be one of: {valid_statuses}")
return {
"is_valid": len(errors) == 0,
"errors": errors
}

View File

@@ -0,0 +1,470 @@
"""
Log Repository
Repository for notification log operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime, timedelta
import structlog
import json
from .base import NotificationBaseRepository
from app.models.notifications import NotificationLog, NotificationStatus
from shared.database.exceptions import DatabaseError, ValidationError
logger = structlog.get_logger()
class LogRepository(NotificationBaseRepository):
"""Repository for notification log operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 120):
# Logs are very dynamic, very short cache time (2 minutes)
super().__init__(NotificationLog, session, cache_ttl)
async def create_log_entry(self, log_data: Dict[str, Any]) -> NotificationLog:
"""Create a new notification log entry"""
try:
# Validate log data
validation_result = self._validate_notification_data(
log_data,
["notification_id", "attempt_number", "status"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid log data: {validation_result['errors']}")
# Set default values
if "attempted_at" not in log_data:
log_data["attempted_at"] = datetime.utcnow()
# Serialize metadata if it's a dict
if "log_metadata" in log_data and isinstance(log_data["log_metadata"], dict):
log_data["log_metadata"] = json.dumps(log_data["log_metadata"])
# Serialize provider response if it's a dict
if "provider_response" in log_data and isinstance(log_data["provider_response"], dict):
log_data["provider_response"] = json.dumps(log_data["provider_response"])
# Create log entry
log_entry = await self.create(log_data)
logger.debug("Notification log entry created",
log_id=log_entry.id,
notification_id=log_entry.notification_id,
attempt_number=log_entry.attempt_number,
status=log_entry.status.value)
return log_entry
except ValidationError:
raise
except Exception as e:
logger.error("Failed to create log entry",
notification_id=log_data.get("notification_id"),
error=str(e))
raise DatabaseError(f"Failed to create log entry: {str(e)}")
async def get_logs_for_notification(
self,
notification_id: str,
skip: int = 0,
limit: int = 50
) -> List[NotificationLog]:
"""Get all log entries for a specific notification"""
try:
return await self.get_multi(
filters={"notification_id": notification_id},
skip=skip,
limit=limit,
order_by="attempt_number",
order_desc=False
)
except Exception as e:
logger.error("Failed to get logs for notification",
notification_id=notification_id,
error=str(e))
return []
async def get_latest_log_for_notification(
self,
notification_id: str
) -> Optional[NotificationLog]:
"""Get the most recent log entry for a notification"""
try:
logs = await self.get_multi(
filters={"notification_id": notification_id},
limit=1,
order_by="attempt_number",
order_desc=True
)
return logs[0] if logs else None
except Exception as e:
logger.error("Failed to get latest log for notification",
notification_id=notification_id,
error=str(e))
return None
async def get_failed_delivery_logs(
self,
hours_back: int = 24,
provider: str = None,
limit: int = 100
) -> List[NotificationLog]:
"""Get failed delivery logs for analysis"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=hours_back)
conditions = [
"status = 'failed'",
"attempted_at >= :cutoff_time"
]
params = {"cutoff_time": cutoff_time, "limit": limit}
if provider:
conditions.append("provider = :provider")
params["provider"] = provider
query_text = f"""
SELECT * FROM notification_logs
WHERE {' AND '.join(conditions)}
ORDER BY attempted_at DESC
LIMIT :limit
"""
result = await self.session.execute(text(query_text), params)
logs = []
for row in result.fetchall():
record_dict = dict(row._mapping)
# Convert enum string back to enum object
record_dict["status"] = NotificationStatus(record_dict["status"])
log_entry = self.model(**record_dict)
logs.append(log_entry)
return logs
except Exception as e:
logger.error("Failed to get failed delivery logs",
hours_back=hours_back,
provider=provider,
error=str(e))
return []
async def get_delivery_performance_stats(
self,
hours_back: int = 24,
provider: str = None
) -> Dict[str, Any]:
"""Get delivery performance statistics"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=hours_back)
conditions = ["attempted_at >= :cutoff_time"]
params = {"cutoff_time": cutoff_time}
if provider:
conditions.append("provider = :provider")
params["provider"] = provider
where_clause = " AND ".join(conditions)
# Get overall statistics
stats_query = text(f"""
SELECT
COUNT(*) as total_attempts,
COUNT(CASE WHEN status = 'sent' OR status = 'delivered' THEN 1 END) as successful_attempts,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_attempts,
AVG(response_time_ms) as avg_response_time_ms,
MIN(response_time_ms) as min_response_time_ms,
MAX(response_time_ms) as max_response_time_ms
FROM notification_logs
WHERE {where_clause}
""")
result = await self.session.execute(stats_query, params)
stats = result.fetchone()
total = stats.total_attempts or 0
successful = stats.successful_attempts or 0
failed = stats.failed_attempts or 0
success_rate = (successful / total * 100) if total > 0 else 0
failure_rate = (failed / total * 100) if total > 0 else 0
# Get error breakdown
error_query = text(f"""
SELECT error_code, COUNT(*) as count
FROM notification_logs
WHERE {where_clause} AND status = 'failed' AND error_code IS NOT NULL
GROUP BY error_code
ORDER BY count DESC
LIMIT 10
""")
result = await self.session.execute(error_query, params)
error_breakdown = {row.error_code: row.count for row in result.fetchall()}
# Get provider breakdown if not filtering by provider
provider_breakdown = {}
if not provider:
provider_query = text(f"""
SELECT provider,
COUNT(*) as total,
COUNT(CASE WHEN status = 'sent' OR status = 'delivered' THEN 1 END) as successful
FROM notification_logs
WHERE {where_clause} AND provider IS NOT NULL
GROUP BY provider
ORDER BY total DESC
""")
result = await self.session.execute(provider_query, params)
for row in result.fetchall():
provider_success_rate = (row.successful / row.total * 100) if row.total > 0 else 0
provider_breakdown[row.provider] = {
"total": row.total,
"successful": row.successful,
"success_rate_percent": round(provider_success_rate, 2)
}
return {
"total_attempts": total,
"successful_attempts": successful,
"failed_attempts": failed,
"success_rate_percent": round(success_rate, 2),
"failure_rate_percent": round(failure_rate, 2),
"avg_response_time_ms": float(stats.avg_response_time_ms or 0),
"min_response_time_ms": int(stats.min_response_time_ms or 0),
"max_response_time_ms": int(stats.max_response_time_ms or 0),
"error_breakdown": error_breakdown,
"provider_breakdown": provider_breakdown,
"hours_analyzed": hours_back
}
except Exception as e:
logger.error("Failed to get delivery performance stats",
hours_back=hours_back,
provider=provider,
error=str(e))
return {
"total_attempts": 0,
"successful_attempts": 0,
"failed_attempts": 0,
"success_rate_percent": 0.0,
"failure_rate_percent": 0.0,
"avg_response_time_ms": 0.0,
"min_response_time_ms": 0,
"max_response_time_ms": 0,
"error_breakdown": {},
"provider_breakdown": {},
"hours_analyzed": hours_back
}
async def get_logs_by_provider(
self,
provider: str,
hours_back: int = 24,
status: NotificationStatus = None,
limit: int = 100
) -> List[NotificationLog]:
"""Get logs for a specific provider"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=hours_back)
conditions = [
"provider = :provider",
"attempted_at >= :cutoff_time"
]
params = {"provider": provider, "cutoff_time": cutoff_time, "limit": limit}
if status:
conditions.append("status = :status")
params["status"] = status.value
query_text = f"""
SELECT * FROM notification_logs
WHERE {' AND '.join(conditions)}
ORDER BY attempted_at DESC
LIMIT :limit
"""
result = await self.session.execute(text(query_text), params)
logs = []
for row in result.fetchall():
record_dict = dict(row._mapping)
# Convert enum string back to enum object
record_dict["status"] = NotificationStatus(record_dict["status"])
log_entry = self.model(**record_dict)
logs.append(log_entry)
return logs
except Exception as e:
logger.error("Failed to get logs by provider",
provider=provider,
error=str(e))
return []
async def cleanup_old_logs(self, days_old: int = 30) -> int:
"""Clean up old notification logs"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
# Only delete logs for successfully delivered or permanently failed notifications
query_text = """
DELETE FROM notification_logs
WHERE attempted_at < :cutoff_date
AND status IN ('delivered', 'failed')
"""
result = await self.session.execute(text(query_text), {"cutoff_date": cutoff_date})
deleted_count = result.rowcount
logger.info("Cleaned up old notification logs",
deleted_count=deleted_count,
days_old=days_old)
return deleted_count
except Exception as e:
logger.error("Failed to cleanup old logs", error=str(e))
raise DatabaseError(f"Cleanup failed: {str(e)}")
async def get_notification_timeline(
self,
notification_id: str
) -> Dict[str, Any]:
"""Get complete timeline for a notification including all attempts"""
try:
logs = await self.get_logs_for_notification(notification_id)
timeline = []
for log in logs:
entry = {
"attempt_number": log.attempt_number,
"status": log.status.value,
"attempted_at": log.attempted_at.isoformat() if log.attempted_at else None,
"provider": log.provider,
"provider_message_id": log.provider_message_id,
"response_time_ms": log.response_time_ms,
"error_code": log.error_code,
"error_message": log.error_message
}
# Parse metadata if present
if log.log_metadata:
try:
entry["metadata"] = json.loads(log.log_metadata)
except json.JSONDecodeError:
entry["metadata"] = log.log_metadata
# Parse provider response if present
if log.provider_response:
try:
entry["provider_response"] = json.loads(log.provider_response)
except json.JSONDecodeError:
entry["provider_response"] = log.provider_response
timeline.append(entry)
# Calculate summary statistics
total_attempts = len(logs)
successful_attempts = len([log for log in logs if log.status in [NotificationStatus.SENT, NotificationStatus.DELIVERED]])
failed_attempts = len([log for log in logs if log.status == NotificationStatus.FAILED])
avg_response_time = 0
if logs:
response_times = [log.response_time_ms for log in logs if log.response_time_ms is not None]
avg_response_time = sum(response_times) / len(response_times) if response_times else 0
return {
"notification_id": notification_id,
"total_attempts": total_attempts,
"successful_attempts": successful_attempts,
"failed_attempts": failed_attempts,
"avg_response_time_ms": round(avg_response_time, 2),
"timeline": timeline
}
except Exception as e:
logger.error("Failed to get notification timeline",
notification_id=notification_id,
error=str(e))
return {
"notification_id": notification_id,
"error": str(e),
"timeline": []
}
async def get_retry_analysis(self, days_back: int = 7) -> Dict[str, Any]:
"""Analyze retry patterns and success rates"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Get retry statistics
retry_query = text("""
SELECT
attempt_number,
COUNT(*) as total_attempts,
COUNT(CASE WHEN status = 'sent' OR status = 'delivered' THEN 1 END) as successful_attempts
FROM notification_logs
WHERE attempted_at >= :cutoff_date
GROUP BY attempt_number
ORDER BY attempt_number
""")
result = await self.session.execute(retry_query, {"cutoff_date": cutoff_date})
retry_stats = {}
for row in result.fetchall():
success_rate = (row.successful_attempts / row.total_attempts * 100) if row.total_attempts > 0 else 0
retry_stats[row.attempt_number] = {
"total_attempts": row.total_attempts,
"successful_attempts": row.successful_attempts,
"success_rate_percent": round(success_rate, 2)
}
# Get common failure patterns
failure_query = text("""
SELECT
error_code,
attempt_number,
COUNT(*) as count
FROM notification_logs
WHERE attempted_at >= :cutoff_date
AND status = 'failed'
AND error_code IS NOT NULL
GROUP BY error_code, attempt_number
ORDER BY count DESC
LIMIT 20
""")
result = await self.session.execute(failure_query, {"cutoff_date": cutoff_date})
failure_patterns = []
for row in result.fetchall():
failure_patterns.append({
"error_code": row.error_code,
"attempt_number": row.attempt_number,
"count": row.count
})
return {
"retry_statistics": retry_stats,
"failure_patterns": failure_patterns,
"days_analyzed": days_back
}
except Exception as e:
logger.error("Failed to get retry analysis", error=str(e))
return {
"retry_statistics": {},
"failure_patterns": [],
"days_analyzed": days_back,
"error": str(e)
}

View File

@@ -0,0 +1,515 @@
"""
Notification Repository
Repository for notification operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_, or_
from datetime import datetime, timedelta
import structlog
import json
from .base import NotificationBaseRepository
from app.models.notifications import Notification, NotificationStatus, NotificationType, NotificationPriority
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class NotificationRepository(NotificationBaseRepository):
"""Repository for notification operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 300):
# Notifications are very dynamic, short cache time (5 minutes)
super().__init__(Notification, session, cache_ttl)
async def create_notification(self, notification_data: Dict[str, Any]) -> Notification:
"""Create a new notification with validation"""
try:
# Validate notification data
validation_result = self._validate_notification_data(
notification_data,
["tenant_id", "sender_id", "type", "message"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid notification data: {validation_result['errors']}")
# Set default values
if "status" not in notification_data:
notification_data["status"] = NotificationStatus.PENDING
if "priority" not in notification_data:
notification_data["priority"] = NotificationPriority.NORMAL
if "retry_count" not in notification_data:
notification_data["retry_count"] = 0
if "max_retries" not in notification_data:
notification_data["max_retries"] = 3
if "broadcast" not in notification_data:
notification_data["broadcast"] = False
if "read" not in notification_data:
notification_data["read"] = False
# Create notification
notification = await self.create(notification_data)
logger.info("Notification created successfully",
notification_id=notification.id,
tenant_id=notification.tenant_id,
type=notification.type.value,
recipient_id=notification.recipient_id,
priority=notification.priority.value)
return notification
except ValidationError:
raise
except Exception as e:
logger.error("Failed to create notification",
tenant_id=notification_data.get("tenant_id"),
type=notification_data.get("type"),
error=str(e))
raise DatabaseError(f"Failed to create notification: {str(e)}")
async def get_pending_notifications(self, limit: int = 100) -> List[Notification]:
"""Get pending notifications ready for processing"""
try:
# Get notifications that are pending and either not scheduled or scheduled for now/past
now = datetime.utcnow()
query_text = """
SELECT * FROM notifications
WHERE status = 'pending'
AND (scheduled_at IS NULL OR scheduled_at <= :now)
AND retry_count < max_retries
ORDER BY priority DESC, created_at ASC
LIMIT :limit
"""
result = await self.session.execute(text(query_text), {
"now": now,
"limit": limit
})
notifications = []
for row in result.fetchall():
record_dict = dict(row._mapping)
# Convert enum strings back to enum objects
record_dict["status"] = NotificationStatus(record_dict["status"])
record_dict["type"] = NotificationType(record_dict["type"])
record_dict["priority"] = NotificationPriority(record_dict["priority"])
notification = self.model(**record_dict)
notifications.append(notification)
return notifications
except Exception as e:
logger.error("Failed to get pending notifications", error=str(e))
return []
async def get_notifications_by_recipient(
self,
recipient_id: str,
tenant_id: str = None,
status: NotificationStatus = None,
notification_type: NotificationType = None,
unread_only: bool = False,
skip: int = 0,
limit: int = 50
) -> List[Notification]:
"""Get notifications for a specific recipient with filters"""
try:
filters = {"recipient_id": recipient_id}
if tenant_id:
filters["tenant_id"] = tenant_id
if status:
filters["status"] = status
if notification_type:
filters["type"] = notification_type
if unread_only:
filters["read"] = False
return await self.get_multi(
filters=filters,
skip=skip,
limit=limit,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get notifications by recipient",
recipient_id=recipient_id,
error=str(e))
return []
async def get_broadcast_notifications(
self,
tenant_id: str,
skip: int = 0,
limit: int = 50
) -> List[Notification]:
"""Get broadcast notifications for a tenant"""
try:
return await self.get_multi(
filters={
"tenant_id": tenant_id,
"broadcast": True
},
skip=skip,
limit=limit,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get broadcast notifications",
tenant_id=tenant_id,
error=str(e))
return []
async def update_notification_status(
self,
notification_id: str,
new_status: NotificationStatus,
error_message: str = None,
provider_message_id: str = None,
metadata: Dict[str, Any] = None
) -> Optional[Notification]:
"""Update notification status and related fields"""
try:
update_data = {
"status": new_status,
"updated_at": datetime.utcnow()
}
# Set timestamp based on status
if new_status == NotificationStatus.SENT:
update_data["sent_at"] = datetime.utcnow()
elif new_status == NotificationStatus.DELIVERED:
update_data["delivered_at"] = datetime.utcnow()
if "sent_at" not in update_data:
update_data["sent_at"] = datetime.utcnow()
# Add error message if provided
if error_message:
update_data["error_message"] = error_message
# Add metadata if provided
if metadata:
update_data["log_metadata"] = json.dumps(metadata)
updated_notification = await self.update(notification_id, update_data)
logger.info("Notification status updated",
notification_id=notification_id,
new_status=new_status.value,
provider_message_id=provider_message_id)
return updated_notification
except Exception as e:
logger.error("Failed to update notification status",
notification_id=notification_id,
new_status=new_status.value,
error=str(e))
raise DatabaseError(f"Failed to update status: {str(e)}")
async def increment_retry_count(self, notification_id: str) -> Optional[Notification]:
"""Increment retry count for a notification"""
try:
notification = await self.get_by_id(notification_id)
if not notification:
return None
new_retry_count = notification.retry_count + 1
update_data = {
"retry_count": new_retry_count,
"updated_at": datetime.utcnow()
}
# If max retries exceeded, mark as failed
if new_retry_count >= notification.max_retries:
update_data["status"] = NotificationStatus.FAILED
update_data["error_message"] = "Maximum retry attempts exceeded"
updated_notification = await self.update(notification_id, update_data)
logger.info("Notification retry count incremented",
notification_id=notification_id,
retry_count=new_retry_count,
max_retries=notification.max_retries)
return updated_notification
except Exception as e:
logger.error("Failed to increment retry count",
notification_id=notification_id,
error=str(e))
raise DatabaseError(f"Failed to increment retry count: {str(e)}")
async def mark_as_read(self, notification_id: str) -> Optional[Notification]:
"""Mark notification as read"""
try:
updated_notification = await self.update(notification_id, {
"read": True,
"read_at": datetime.utcnow()
})
logger.info("Notification marked as read",
notification_id=notification_id)
return updated_notification
except Exception as e:
logger.error("Failed to mark notification as read",
notification_id=notification_id,
error=str(e))
raise DatabaseError(f"Failed to mark as read: {str(e)}")
async def mark_multiple_as_read(
self,
recipient_id: str,
notification_ids: List[str] = None,
tenant_id: str = None
) -> int:
"""Mark multiple notifications as read"""
try:
conditions = ["recipient_id = :recipient_id", "read = false"]
params = {"recipient_id": recipient_id}
if notification_ids:
placeholders = ", ".join([f":id_{i}" for i in range(len(notification_ids))])
conditions.append(f"id IN ({placeholders})")
for i, notification_id in enumerate(notification_ids):
params[f"id_{i}"] = notification_id
if tenant_id:
conditions.append("tenant_id = :tenant_id")
params["tenant_id"] = tenant_id
query_text = f"""
UPDATE notifications
SET read = true, read_at = :read_at
WHERE {' AND '.join(conditions)}
"""
params["read_at"] = datetime.utcnow()
result = await self.session.execute(text(query_text), params)
updated_count = result.rowcount
logger.info("Multiple notifications marked as read",
recipient_id=recipient_id,
updated_count=updated_count)
return updated_count
except Exception as e:
logger.error("Failed to mark multiple notifications as read",
recipient_id=recipient_id,
error=str(e))
raise DatabaseError(f"Failed to mark multiple as read: {str(e)}")
async def get_failed_notifications_for_retry(self, hours_ago: int = 1) -> List[Notification]:
"""Get failed notifications that can be retried"""
try:
cutoff_time = datetime.utcnow() - timedelta(hours=hours_ago)
query_text = """
SELECT * FROM notifications
WHERE status = 'failed'
AND retry_count < max_retries
AND updated_at >= :cutoff_time
ORDER BY priority DESC, updated_at ASC
LIMIT 100
"""
result = await self.session.execute(text(query_text), {
"cutoff_time": cutoff_time
})
notifications = []
for row in result.fetchall():
record_dict = dict(row._mapping)
# Convert enum strings back to enum objects
record_dict["status"] = NotificationStatus(record_dict["status"])
record_dict["type"] = NotificationType(record_dict["type"])
record_dict["priority"] = NotificationPriority(record_dict["priority"])
notification = self.model(**record_dict)
notifications.append(notification)
return notifications
except Exception as e:
logger.error("Failed to get failed notifications for retry", error=str(e))
return []
async def get_notification_statistics(
self,
tenant_id: str = None,
days_back: int = 30
) -> Dict[str, Any]:
"""Get notification statistics"""
try:
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Build base query conditions
conditions = ["created_at >= :cutoff_date"]
params = {"cutoff_date": cutoff_date}
if tenant_id:
conditions.append("tenant_id = :tenant_id")
params["tenant_id"] = tenant_id
where_clause = " AND ".join(conditions)
# Get statistics by status
status_query = text(f"""
SELECT status, COUNT(*) as count
FROM notifications
WHERE {where_clause}
GROUP BY status
ORDER BY count DESC
""")
result = await self.session.execute(status_query, params)
status_stats = {row.status: row.count for row in result.fetchall()}
# Get statistics by type
type_query = text(f"""
SELECT type, COUNT(*) as count
FROM notifications
WHERE {where_clause}
GROUP BY type
ORDER BY count DESC
""")
result = await self.session.execute(type_query, params)
type_stats = {row.type: row.count for row in result.fetchall()}
# Get delivery rate
delivery_query = text(f"""
SELECT
COUNT(*) as total_notifications,
COUNT(CASE WHEN status = 'delivered' THEN 1 END) as delivered_count,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_count,
AVG(CASE WHEN sent_at IS NOT NULL AND delivered_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (delivered_at - sent_at)) END) as avg_delivery_time_seconds
FROM notifications
WHERE {where_clause}
""")
result = await self.session.execute(delivery_query, params)
delivery_row = result.fetchone()
total = delivery_row.total_notifications or 0
delivered = delivery_row.delivered_count or 0
failed = delivery_row.failed_count or 0
delivery_rate = (delivered / total * 100) if total > 0 else 0
failure_rate = (failed / total * 100) if total > 0 else 0
# Get unread count (if tenant_id provided)
unread_count = 0
if tenant_id:
unread_query = text(f"""
SELECT COUNT(*) as count
FROM notifications
WHERE tenant_id = :tenant_id AND read = false
""")
result = await self.session.execute(unread_query, {"tenant_id": tenant_id})
unread_count = result.scalar() or 0
return {
"total_notifications": total,
"by_status": status_stats,
"by_type": type_stats,
"delivery_rate_percent": round(delivery_rate, 2),
"failure_rate_percent": round(failure_rate, 2),
"avg_delivery_time_seconds": float(delivery_row.avg_delivery_time_seconds or 0),
"unread_count": unread_count,
"days_analyzed": days_back
}
except Exception as e:
logger.error("Failed to get notification statistics",
tenant_id=tenant_id,
error=str(e))
return {
"total_notifications": 0,
"by_status": {},
"by_type": {},
"delivery_rate_percent": 0.0,
"failure_rate_percent": 0.0,
"avg_delivery_time_seconds": 0.0,
"unread_count": 0,
"days_analyzed": days_back
}
async def cancel_notification(self, notification_id: str, reason: str = None) -> Optional[Notification]:
"""Cancel a pending notification"""
try:
notification = await self.get_by_id(notification_id)
if not notification:
return None
if notification.status != NotificationStatus.PENDING:
raise ValidationError("Can only cancel pending notifications")
update_data = {
"status": NotificationStatus.CANCELLED,
"updated_at": datetime.utcnow()
}
if reason:
update_data["error_message"] = f"Cancelled: {reason}"
updated_notification = await self.update(notification_id, update_data)
logger.info("Notification cancelled",
notification_id=notification_id,
reason=reason)
return updated_notification
except ValidationError:
raise
except Exception as e:
logger.error("Failed to cancel notification",
notification_id=notification_id,
error=str(e))
raise DatabaseError(f"Failed to cancel notification: {str(e)}")
async def schedule_notification(
self,
notification_id: str,
scheduled_at: datetime
) -> Optional[Notification]:
"""Schedule a notification for future delivery"""
try:
if scheduled_at <= datetime.utcnow():
raise ValidationError("Scheduled time must be in the future")
updated_notification = await self.update(notification_id, {
"scheduled_at": scheduled_at,
"updated_at": datetime.utcnow()
})
logger.info("Notification scheduled",
notification_id=notification_id,
scheduled_at=scheduled_at)
return updated_notification
except ValidationError:
raise
except Exception as e:
logger.error("Failed to schedule notification",
notification_id=notification_id,
error=str(e))
raise DatabaseError(f"Failed to schedule notification: {str(e)}")

View File

@@ -0,0 +1,474 @@
"""
Preference Repository
Repository for notification preference operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime
import structlog
from .base import NotificationBaseRepository
from app.models.notifications import NotificationPreference
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class PreferenceRepository(NotificationBaseRepository):
"""Repository for notification preference operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 900):
# Preferences are relatively stable, medium cache time (15 minutes)
super().__init__(NotificationPreference, session, cache_ttl)
async def create_preferences(self, preference_data: Dict[str, Any]) -> NotificationPreference:
"""Create user notification preferences with validation"""
try:
# Validate preference data
validation_result = self._validate_notification_data(
preference_data,
["user_id", "tenant_id"]
)
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid preference data: {validation_result['errors']}")
# Check if preferences already exist for this user and tenant
existing_prefs = await self.get_user_preferences(
preference_data["user_id"],
preference_data["tenant_id"]
)
if existing_prefs:
raise DuplicateRecordError(f"Preferences already exist for user in this tenant")
# Set default values
defaults = {
"email_enabled": True,
"email_alerts": True,
"email_marketing": False,
"email_reports": True,
"whatsapp_enabled": False,
"whatsapp_alerts": False,
"whatsapp_reports": False,
"push_enabled": True,
"push_alerts": True,
"push_reports": False,
"quiet_hours_start": "22:00",
"quiet_hours_end": "08:00",
"timezone": "Europe/Madrid",
"digest_frequency": "daily",
"max_emails_per_day": 10,
"language": "es"
}
# Apply defaults for any missing fields
for key, default_value in defaults.items():
if key not in preference_data:
preference_data[key] = default_value
# Create preferences
preferences = await self.create(preference_data)
logger.info("User notification preferences created",
preferences_id=preferences.id,
user_id=preferences.user_id,
tenant_id=preferences.tenant_id)
return preferences
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create preferences",
user_id=preference_data.get("user_id"),
tenant_id=preference_data.get("tenant_id"),
error=str(e))
raise DatabaseError(f"Failed to create preferences: {str(e)}")
async def get_user_preferences(
self,
user_id: str,
tenant_id: str
) -> Optional[NotificationPreference]:
"""Get notification preferences for a specific user and tenant"""
try:
preferences = await self.get_multi(
filters={
"user_id": user_id,
"tenant_id": tenant_id
},
limit=1
)
return preferences[0] if preferences else None
except Exception as e:
logger.error("Failed to get user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to get preferences: {str(e)}")
async def update_user_preferences(
self,
user_id: str,
tenant_id: str,
update_data: Dict[str, Any]
) -> Optional[NotificationPreference]:
"""Update user notification preferences"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
# Create preferences if they don't exist
create_data = {
"user_id": user_id,
"tenant_id": tenant_id,
**update_data
}
return await self.create_preferences(create_data)
# Validate specific preference fields
self._validate_preference_updates(update_data)
updated_preferences = await self.update(str(preferences.id), update_data)
logger.info("User preferences updated",
preferences_id=preferences.id,
user_id=user_id,
tenant_id=tenant_id,
updated_fields=list(update_data.keys()))
return updated_preferences
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update preferences: {str(e)}")
async def get_users_with_email_enabled(
self,
tenant_id: str,
notification_category: str = "alerts"
) -> List[NotificationPreference]:
"""Get users who have email notifications enabled for a category"""
try:
filters = {
"tenant_id": tenant_id,
"email_enabled": True
}
# Add category-specific filter
if notification_category == "alerts":
filters["email_alerts"] = True
elif notification_category == "marketing":
filters["email_marketing"] = True
elif notification_category == "reports":
filters["email_reports"] = True
return await self.get_multi(filters=filters)
except Exception as e:
logger.error("Failed to get users with email enabled",
tenant_id=tenant_id,
category=notification_category,
error=str(e))
return []
async def get_users_with_whatsapp_enabled(
self,
tenant_id: str,
notification_category: str = "alerts"
) -> List[NotificationPreference]:
"""Get users who have WhatsApp notifications enabled for a category"""
try:
filters = {
"tenant_id": tenant_id,
"whatsapp_enabled": True
}
# Add category-specific filter
if notification_category == "alerts":
filters["whatsapp_alerts"] = True
elif notification_category == "reports":
filters["whatsapp_reports"] = True
return await self.get_multi(filters=filters)
except Exception as e:
logger.error("Failed to get users with WhatsApp enabled",
tenant_id=tenant_id,
category=notification_category,
error=str(e))
return []
async def get_users_with_push_enabled(
self,
tenant_id: str,
notification_category: str = "alerts"
) -> List[NotificationPreference]:
"""Get users who have push notifications enabled for a category"""
try:
filters = {
"tenant_id": tenant_id,
"push_enabled": True
}
# Add category-specific filter
if notification_category == "alerts":
filters["push_alerts"] = True
elif notification_category == "reports":
filters["push_reports"] = True
return await self.get_multi(filters=filters)
except Exception as e:
logger.error("Failed to get users with push enabled",
tenant_id=tenant_id,
category=notification_category,
error=str(e))
return []
async def check_quiet_hours(
self,
user_id: str,
tenant_id: str,
check_time: datetime = None
) -> bool:
"""Check if current time is within user's quiet hours"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
return False # No quiet hours if no preferences
if not check_time:
check_time = datetime.utcnow()
# Convert time to user's timezone (simplified - using hour comparison)
current_hour = check_time.hour
quiet_start = int(preferences.quiet_hours_start.split(":")[0])
quiet_end = int(preferences.quiet_hours_end.split(":")[0])
# Handle quiet hours that span midnight
if quiet_start > quiet_end:
return current_hour >= quiet_start or current_hour < quiet_end
else:
return quiet_start <= current_hour < quiet_end
except Exception as e:
logger.error("Failed to check quiet hours",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return False
async def get_users_for_digest(
self,
tenant_id: str,
frequency: str = "daily"
) -> List[NotificationPreference]:
"""Get users who want digest notifications for a frequency"""
try:
return await self.get_multi(
filters={
"tenant_id": tenant_id,
"digest_frequency": frequency,
"email_enabled": True
}
)
except Exception as e:
logger.error("Failed to get users for digest",
tenant_id=tenant_id,
frequency=frequency,
error=str(e))
return []
async def can_send_email(
self,
user_id: str,
tenant_id: str,
category: str = "alerts"
) -> Dict[str, Any]:
"""Check if an email can be sent to a user based on their preferences"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
return {
"can_send": True, # Default to allowing if no preferences set
"reason": "No preferences found, using defaults"
}
# Check if email is enabled
if not preferences.email_enabled:
return {
"can_send": False,
"reason": "Email notifications disabled"
}
# Check category-specific settings
category_enabled = True
if category == "alerts" and not preferences.email_alerts:
category_enabled = False
elif category == "marketing" and not preferences.email_marketing:
category_enabled = False
elif category == "reports" and not preferences.email_reports:
category_enabled = False
if not category_enabled:
return {
"can_send": False,
"reason": f"Email {category} notifications disabled"
}
# Check quiet hours
if self.check_quiet_hours(user_id, tenant_id):
return {
"can_send": False,
"reason": "Within user's quiet hours"
}
# Check daily limit (simplified - would need to query recent notifications)
# For now, just return the limit info
return {
"can_send": True,
"max_daily_emails": preferences.max_emails_per_day,
"language": preferences.language,
"timezone": preferences.timezone
}
except Exception as e:
logger.error("Failed to check if email can be sent",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return {
"can_send": True, # Default to allowing on error
"reason": "Error checking preferences"
}
async def bulk_update_preferences(
self,
tenant_id: str,
update_data: Dict[str, Any],
user_ids: List[str] = None
) -> int:
"""Bulk update preferences for multiple users"""
try:
conditions = ["tenant_id = :tenant_id"]
params = {"tenant_id": tenant_id}
if user_ids:
placeholders = ", ".join([f":user_id_{i}" for i in range(len(user_ids))])
conditions.append(f"user_id IN ({placeholders})")
for i, user_id in enumerate(user_ids):
params[f"user_id_{i}"] = user_id
# Build update clause
update_fields = []
for key, value in update_data.items():
update_fields.append(f"{key} = :update_{key}")
params[f"update_{key}"] = value
params["updated_at"] = datetime.utcnow()
update_fields.append("updated_at = :updated_at")
query_text = f"""
UPDATE notification_preferences
SET {', '.join(update_fields)}
WHERE {' AND '.join(conditions)}
"""
result = await self.session.execute(text(query_text), params)
updated_count = result.rowcount
logger.info("Bulk preferences update completed",
tenant_id=tenant_id,
updated_count=updated_count,
updated_fields=list(update_data.keys()))
return updated_count
except Exception as e:
logger.error("Failed to bulk update preferences",
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Bulk update failed: {str(e)}")
async def delete_user_preferences(
self,
user_id: str,
tenant_id: str
) -> bool:
"""Delete user preferences (when user leaves tenant)"""
try:
preferences = await self.get_user_preferences(user_id, tenant_id)
if not preferences:
return False
await self.delete(str(preferences.id))
logger.info("User preferences deleted",
user_id=user_id,
tenant_id=tenant_id)
return True
except Exception as e:
logger.error("Failed to delete user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to delete preferences: {str(e)}")
def _validate_preference_updates(self, update_data: Dict[str, Any]) -> None:
"""Validate preference update data"""
# Validate boolean fields
boolean_fields = [
"email_enabled", "email_alerts", "email_marketing", "email_reports",
"whatsapp_enabled", "whatsapp_alerts", "whatsapp_reports",
"push_enabled", "push_alerts", "push_reports"
]
for field in boolean_fields:
if field in update_data and not isinstance(update_data[field], bool):
raise ValidationError(f"{field} must be a boolean value")
# Validate time format for quiet hours
time_fields = ["quiet_hours_start", "quiet_hours_end"]
for field in time_fields:
if field in update_data:
time_value = update_data[field]
if not isinstance(time_value, str) or len(time_value) != 5 or ":" not in time_value:
raise ValidationError(f"{field} must be in HH:MM format")
try:
hour, minute = time_value.split(":")
hour, minute = int(hour), int(minute)
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError()
except ValueError:
raise ValidationError(f"{field} must be a valid time in HH:MM format")
# Validate digest frequency
if "digest_frequency" in update_data:
valid_frequencies = ["none", "daily", "weekly"]
if update_data["digest_frequency"] not in valid_frequencies:
raise ValidationError(f"digest_frequency must be one of: {valid_frequencies}")
# Validate max emails per day
if "max_emails_per_day" in update_data:
max_emails = update_data["max_emails_per_day"]
if not isinstance(max_emails, int) or max_emails < 0 or max_emails > 100:
raise ValidationError("max_emails_per_day must be an integer between 0 and 100")
# Validate language
if "language" in update_data:
valid_languages = ["es", "en", "fr", "de"]
if update_data["language"] not in valid_languages:
raise ValidationError(f"language must be one of: {valid_languages}")

View File

@@ -0,0 +1,450 @@
"""
Template Repository
Repository for notification template operations
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, and_
from datetime import datetime
import structlog
import json
from .base import NotificationBaseRepository
from app.models.notifications import NotificationTemplate, NotificationType
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
logger = structlog.get_logger()
class TemplateRepository(NotificationBaseRepository):
"""Repository for notification template operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 1800):
# Templates don't change often, longer cache time (30 minutes)
super().__init__(NotificationTemplate, session, cache_ttl)
async def create_template(self, template_data: Dict[str, Any]) -> NotificationTemplate:
"""Create a new notification template with validation"""
try:
# Validate template data
required_fields = ["template_key", "name", "category", "type", "body_template"]
validation_result = self._validate_notification_data(template_data, required_fields)
# Additional template-specific validation
if validation_result["is_valid"]:
# Check if template_key already exists
existing_template = await self.get_by_template_key(template_data["template_key"])
if existing_template:
raise DuplicateRecordError(f"Template key {template_data['template_key']} already exists")
# Validate template variables if provided
if "required_variables" in template_data:
if isinstance(template_data["required_variables"], list):
template_data["required_variables"] = json.dumps(template_data["required_variables"])
elif isinstance(template_data["required_variables"], str):
# Verify it's valid JSON
try:
json.loads(template_data["required_variables"])
except json.JSONDecodeError:
validation_result["errors"].append("Invalid JSON format in required_variables")
validation_result["is_valid"] = False
if not validation_result["is_valid"]:
raise ValidationError(f"Invalid template data: {validation_result['errors']}")
# Set default values
if "language" not in template_data:
template_data["language"] = "es"
if "is_active" not in template_data:
template_data["is_active"] = True
if "is_system" not in template_data:
template_data["is_system"] = False
if "default_priority" not in template_data:
template_data["default_priority"] = "normal"
# Create template
template = await self.create(template_data)
logger.info("Notification template created successfully",
template_id=template.id,
template_key=template.template_key,
type=template.type.value,
category=template.category)
return template
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create template",
template_key=template_data.get("template_key"),
error=str(e))
raise DatabaseError(f"Failed to create template: {str(e)}")
async def get_by_template_key(self, template_key: str) -> Optional[NotificationTemplate]:
"""Get template by template key"""
try:
return await self.get_by_field("template_key", template_key)
except Exception as e:
logger.error("Failed to get template by key",
template_key=template_key,
error=str(e))
raise DatabaseError(f"Failed to get template: {str(e)}")
async def get_templates_by_category(
self,
category: str,
tenant_id: str = None,
include_system: bool = True
) -> List[NotificationTemplate]:
"""Get templates by category"""
try:
filters = {"category": category, "is_active": True}
if tenant_id and include_system:
# Get both tenant-specific and system templates
tenant_templates = await self.get_multi(
filters={**filters, "tenant_id": tenant_id}
)
system_templates = await self.get_multi(
filters={**filters, "is_system": True}
)
return tenant_templates + system_templates
elif tenant_id:
# Only tenant-specific templates
filters["tenant_id"] = tenant_id
return await self.get_multi(filters=filters)
elif include_system:
# Only system templates
filters["is_system"] = True
return await self.get_multi(filters=filters)
else:
return []
except Exception as e:
logger.error("Failed to get templates by category",
category=category,
tenant_id=tenant_id,
error=str(e))
return []
async def get_templates_by_type(
self,
notification_type: NotificationType,
tenant_id: str = None,
include_system: bool = True
) -> List[NotificationTemplate]:
"""Get templates by notification type"""
try:
filters = {"type": notification_type, "is_active": True}
if tenant_id and include_system:
# Get both tenant-specific and system templates
tenant_templates = await self.get_multi(
filters={**filters, "tenant_id": tenant_id}
)
system_templates = await self.get_multi(
filters={**filters, "is_system": True}
)
return tenant_templates + system_templates
elif tenant_id:
# Only tenant-specific templates
filters["tenant_id"] = tenant_id
return await self.get_multi(filters=filters)
elif include_system:
# Only system templates
filters["is_system"] = True
return await self.get_multi(filters=filters)
else:
return []
except Exception as e:
logger.error("Failed to get templates by type",
notification_type=notification_type.value,
tenant_id=tenant_id,
error=str(e))
return []
async def update_template(
self,
template_id: str,
update_data: Dict[str, Any],
allow_system_update: bool = False
) -> Optional[NotificationTemplate]:
"""Update template with system template protection"""
try:
template = await self.get_by_id(template_id)
if not template:
return None
# Prevent updating system templates unless explicitly allowed
if template.is_system and not allow_system_update:
raise ValidationError("Cannot update system templates")
# Validate required_variables if being updated
if "required_variables" in update_data:
if isinstance(update_data["required_variables"], list):
update_data["required_variables"] = json.dumps(update_data["required_variables"])
elif isinstance(update_data["required_variables"], str):
try:
json.loads(update_data["required_variables"])
except json.JSONDecodeError:
raise ValidationError("Invalid JSON format in required_variables")
# Update template
updated_template = await self.update(template_id, update_data)
logger.info("Template updated successfully",
template_id=template_id,
template_key=template.template_key,
updated_fields=list(update_data.keys()))
return updated_template
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update template",
template_id=template_id,
error=str(e))
raise DatabaseError(f"Failed to update template: {str(e)}")
async def deactivate_template(self, template_id: str) -> Optional[NotificationTemplate]:
"""Deactivate a template (soft delete)"""
try:
template = await self.get_by_id(template_id)
if not template:
return None
# Prevent deactivating system templates
if template.is_system:
raise ValidationError("Cannot deactivate system templates")
updated_template = await self.update(template_id, {
"is_active": False,
"updated_at": datetime.utcnow()
})
logger.info("Template deactivated",
template_id=template_id,
template_key=template.template_key)
return updated_template
except ValidationError:
raise
except Exception as e:
logger.error("Failed to deactivate template",
template_id=template_id,
error=str(e))
raise DatabaseError(f"Failed to deactivate template: {str(e)}")
async def activate_template(self, template_id: str) -> Optional[NotificationTemplate]:
"""Activate a template"""
try:
updated_template = await self.update(template_id, {
"is_active": True,
"updated_at": datetime.utcnow()
})
if updated_template:
logger.info("Template activated",
template_id=template_id,
template_key=updated_template.template_key)
return updated_template
except Exception as e:
logger.error("Failed to activate template",
template_id=template_id,
error=str(e))
raise DatabaseError(f"Failed to activate template: {str(e)}")
async def search_templates(
self,
search_term: str,
tenant_id: str = None,
category: str = None,
notification_type: NotificationType = None,
include_system: bool = True,
limit: int = 50
) -> List[NotificationTemplate]:
"""Search templates by name, description, or template key"""
try:
conditions = [
"is_active = true",
"(LOWER(name) LIKE LOWER(:search_term) OR LOWER(description) LIKE LOWER(:search_term) OR LOWER(template_key) LIKE LOWER(:search_term))"
]
params = {"search_term": f"%{search_term}%", "limit": limit}
# Add tenant/system filter
if tenant_id and include_system:
conditions.append("(tenant_id = :tenant_id OR is_system = true)")
params["tenant_id"] = tenant_id
elif tenant_id:
conditions.append("tenant_id = :tenant_id")
params["tenant_id"] = tenant_id
elif include_system:
conditions.append("is_system = true")
# Add category filter
if category:
conditions.append("category = :category")
params["category"] = category
# Add type filter
if notification_type:
conditions.append("type = :notification_type")
params["notification_type"] = notification_type.value
query_text = f"""
SELECT * FROM notification_templates
WHERE {' AND '.join(conditions)}
ORDER BY name ASC
LIMIT :limit
"""
result = await self.session.execute(text(query_text), params)
templates = []
for row in result.fetchall():
record_dict = dict(row._mapping)
# Convert enum string back to enum object
record_dict["type"] = NotificationType(record_dict["type"])
template = self.model(**record_dict)
templates.append(template)
return templates
except Exception as e:
logger.error("Failed to search templates",
search_term=search_term,
error=str(e))
return []
async def get_template_usage_statistics(self, template_id: str) -> Dict[str, Any]:
"""Get usage statistics for a template"""
try:
template = await self.get_by_id(template_id)
if not template:
return {"error": "Template not found"}
# Get usage statistics from notifications table
usage_query = text("""
SELECT
COUNT(*) as total_uses,
COUNT(CASE WHEN status = 'delivered' THEN 1 END) as successful_uses,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed_uses,
COUNT(CASE WHEN created_at >= NOW() - INTERVAL '30 days' THEN 1 END) as uses_last_30_days,
MIN(created_at) as first_used,
MAX(created_at) as last_used
FROM notifications
WHERE template_id = :template_key
""")
result = await self.session.execute(usage_query, {"template_key": template.template_key})
stats = result.fetchone()
total = stats.total_uses or 0
successful = stats.successful_uses or 0
success_rate = (successful / total * 100) if total > 0 else 0
return {
"template_id": template_id,
"template_key": template.template_key,
"total_uses": total,
"successful_uses": successful,
"failed_uses": stats.failed_uses or 0,
"success_rate_percent": round(success_rate, 2),
"uses_last_30_days": stats.uses_last_30_days or 0,
"first_used": stats.first_used.isoformat() if stats.first_used else None,
"last_used": stats.last_used.isoformat() if stats.last_used else None
}
except Exception as e:
logger.error("Failed to get template usage statistics",
template_id=template_id,
error=str(e))
return {
"template_id": template_id,
"error": str(e)
}
async def duplicate_template(
self,
template_id: str,
new_template_key: str,
new_name: str,
tenant_id: str = None
) -> Optional[NotificationTemplate]:
"""Duplicate an existing template"""
try:
original_template = await self.get_by_id(template_id)
if not original_template:
return None
# Check if new template key already exists
existing_template = await self.get_by_template_key(new_template_key)
if existing_template:
raise DuplicateRecordError(f"Template key {new_template_key} already exists")
# Create duplicate template data
duplicate_data = {
"template_key": new_template_key,
"name": new_name,
"description": f"Copy of {original_template.name}",
"category": original_template.category,
"type": original_template.type,
"subject_template": original_template.subject_template,
"body_template": original_template.body_template,
"html_template": original_template.html_template,
"language": original_template.language,
"default_priority": original_template.default_priority,
"required_variables": original_template.required_variables,
"tenant_id": tenant_id,
"is_active": True,
"is_system": False # Duplicates are never system templates
}
duplicated_template = await self.create(duplicate_data)
logger.info("Template duplicated successfully",
original_template_id=template_id,
new_template_id=duplicated_template.id,
new_template_key=new_template_key)
return duplicated_template
except DuplicateRecordError:
raise
except Exception as e:
logger.error("Failed to duplicate template",
template_id=template_id,
new_template_key=new_template_key,
error=str(e))
raise DatabaseError(f"Failed to duplicate template: {str(e)}")
async def get_system_templates(self) -> List[NotificationTemplate]:
"""Get all system templates"""
try:
return await self.get_multi(
filters={"is_system": True, "is_active": True},
order_by="category"
)
except Exception as e:
logger.error("Failed to get system templates", error=str(e))
return []
async def get_tenant_templates(self, tenant_id: str) -> List[NotificationTemplate]:
"""Get all templates for a specific tenant"""
try:
return await self.get_multi(
filters={"tenant_id": tenant_id, "is_active": True},
order_by="category"
)
except Exception as e:
logger.error("Failed to get tenant templates",
tenant_id=tenant_id,
error=str(e))
return []

View File

@@ -0,0 +1,379 @@
"""
WhatsApp Message Repository
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text, select, and_
from datetime import datetime, timedelta
import structlog
from app.repositories.base import NotificationBaseRepository
from app.models.whatsapp_messages import WhatsAppMessage, WhatsAppMessageStatus, WhatsAppTemplate
from shared.database.exceptions import DatabaseError
logger = structlog.get_logger()
class WhatsAppMessageRepository(NotificationBaseRepository):
"""Repository for WhatsApp message operations"""
def __init__(self, session: AsyncSession):
super().__init__(WhatsAppMessage, session, cache_ttl=60) # 1 minute cache
async def create_message(self, message_data: Dict[str, Any]) -> WhatsAppMessage:
"""Create a new WhatsApp message record"""
try:
# Validate required fields
validation = self._validate_notification_data(
message_data,
["tenant_id", "recipient_phone", "message_type"]
)
if not validation["is_valid"]:
raise DatabaseError(f"Validation failed: {', '.join(validation['errors'])}")
message = await self.create(message_data)
logger.info(
"WhatsApp message created",
message_id=str(message.id),
recipient=message.recipient_phone,
message_type=message.message_type.value
)
return message
except Exception as e:
logger.error("Failed to create WhatsApp message", error=str(e))
raise DatabaseError(f"Failed to create message: {str(e)}")
async def update_message_status(
self,
message_id: str,
status: WhatsAppMessageStatus,
whatsapp_message_id: Optional[str] = None,
error_message: Optional[str] = None,
provider_response: Optional[Dict] = None
) -> Optional[WhatsAppMessage]:
"""Update message status and related fields"""
try:
update_data = {
"status": status,
"updated_at": datetime.utcnow()
}
# Update timestamps based on status
if status == WhatsAppMessageStatus.SENT:
update_data["sent_at"] = datetime.utcnow()
elif status == WhatsAppMessageStatus.DELIVERED:
update_data["delivered_at"] = datetime.utcnow()
elif status == WhatsAppMessageStatus.READ:
update_data["read_at"] = datetime.utcnow()
elif status == WhatsAppMessageStatus.FAILED:
update_data["failed_at"] = datetime.utcnow()
if whatsapp_message_id:
update_data["whatsapp_message_id"] = whatsapp_message_id
if error_message:
update_data["error_message"] = error_message
if provider_response:
update_data["provider_response"] = provider_response
message = await self.update(message_id, update_data)
logger.info(
"WhatsApp message status updated",
message_id=message_id,
status=status.value,
whatsapp_message_id=whatsapp_message_id
)
return message
except Exception as e:
logger.error(
"Failed to update message status",
message_id=message_id,
error=str(e)
)
return None
async def get_by_whatsapp_id(self, whatsapp_message_id: str) -> Optional[WhatsAppMessage]:
"""Get message by WhatsApp's message ID"""
try:
messages = await self.get_multi(
filters={"whatsapp_message_id": whatsapp_message_id},
limit=1
)
return messages[0] if messages else None
except Exception as e:
logger.error(
"Failed to get message by WhatsApp ID",
whatsapp_message_id=whatsapp_message_id,
error=str(e)
)
return None
async def get_by_notification_id(self, notification_id: str) -> Optional[WhatsAppMessage]:
"""Get message by notification ID"""
try:
messages = await self.get_multi(
filters={"notification_id": notification_id},
limit=1
)
return messages[0] if messages else None
except Exception as e:
logger.error(
"Failed to get message by notification ID",
notification_id=notification_id,
error=str(e)
)
return None
async def get_messages_by_phone(
self,
tenant_id: str,
phone: str,
skip: int = 0,
limit: int = 50
) -> List[WhatsAppMessage]:
"""Get all messages for a specific phone number"""
try:
return await self.get_multi(
filters={"tenant_id": tenant_id, "recipient_phone": phone},
skip=skip,
limit=limit,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error(
"Failed to get messages by phone",
phone=phone,
error=str(e)
)
return []
async def get_pending_messages(
self,
tenant_id: str,
limit: int = 100
) -> List[WhatsAppMessage]:
"""Get pending messages for retry processing"""
try:
return await self.get_multi(
filters={
"tenant_id": tenant_id,
"status": WhatsAppMessageStatus.PENDING
},
limit=limit,
order_by="created_at",
order_desc=False # Oldest first
)
except Exception as e:
logger.error("Failed to get pending messages", error=str(e))
return []
async def get_conversation_messages(
self,
conversation_id: str,
skip: int = 0,
limit: int = 50
) -> List[WhatsAppMessage]:
"""Get all messages in a conversation"""
try:
return await self.get_multi(
filters={"conversation_id": conversation_id},
skip=skip,
limit=limit,
order_by="created_at",
order_desc=False # Chronological order
)
except Exception as e:
logger.error(
"Failed to get conversation messages",
conversation_id=conversation_id,
error=str(e)
)
return []
async def get_delivery_stats(
self,
tenant_id: str,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get delivery statistics for WhatsApp messages"""
try:
# Default to last 30 days
if not start_date:
start_date = datetime.utcnow() - timedelta(days=30)
if not end_date:
end_date = datetime.utcnow()
query = text("""
SELECT
COUNT(*) as total_messages,
COUNT(CASE WHEN status = 'SENT' THEN 1 END) as sent,
COUNT(CASE WHEN status = 'DELIVERED' THEN 1 END) as delivered,
COUNT(CASE WHEN status = 'READ' THEN 1 END) as read,
COUNT(CASE WHEN status = 'FAILED' THEN 1 END) as failed,
COUNT(CASE WHEN status = 'PENDING' THEN 1 END) as pending,
COUNT(DISTINCT recipient_phone) as unique_recipients,
COUNT(DISTINCT conversation_id) as total_conversations
FROM whatsapp_messages
WHERE tenant_id = :tenant_id
AND created_at BETWEEN :start_date AND :end_date
""")
result = await self.session.execute(
query,
{
"tenant_id": tenant_id,
"start_date": start_date,
"end_date": end_date
}
)
row = result.fetchone()
if row:
total = row.total_messages or 0
delivered = row.delivered or 0
return {
"total_messages": total,
"sent": row.sent or 0,
"delivered": delivered,
"read": row.read or 0,
"failed": row.failed or 0,
"pending": row.pending or 0,
"unique_recipients": row.unique_recipients or 0,
"total_conversations": row.total_conversations or 0,
"delivery_rate": round((delivered / total * 100), 2) if total > 0 else 0,
"period": {
"start": start_date.isoformat(),
"end": end_date.isoformat()
}
}
return {
"total_messages": 0,
"sent": 0,
"delivered": 0,
"read": 0,
"failed": 0,
"pending": 0,
"unique_recipients": 0,
"total_conversations": 0,
"delivery_rate": 0,
"period": {
"start": start_date.isoformat(),
"end": end_date.isoformat()
}
}
except Exception as e:
logger.error("Failed to get delivery stats", error=str(e))
return {}
class WhatsAppTemplateRepository(NotificationBaseRepository):
"""Repository for WhatsApp template operations"""
def __init__(self, session: AsyncSession):
super().__init__(WhatsAppTemplate, session, cache_ttl=300) # 5 minute cache
async def get_by_template_name(
self,
template_name: str,
language: str = "es"
) -> Optional[WhatsAppTemplate]:
"""Get template by name and language"""
try:
templates = await self.get_multi(
filters={
"template_name": template_name,
"language": language,
"is_active": True
},
limit=1
)
return templates[0] if templates else None
except Exception as e:
logger.error(
"Failed to get template by name",
template_name=template_name,
error=str(e)
)
return None
async def get_by_template_key(self, template_key: str) -> Optional[WhatsAppTemplate]:
"""Get template by internal key"""
try:
templates = await self.get_multi(
filters={"template_key": template_key},
limit=1
)
return templates[0] if templates else None
except Exception as e:
logger.error(
"Failed to get template by key",
template_key=template_key,
error=str(e)
)
return None
async def get_active_templates(
self,
tenant_id: Optional[str] = None,
category: Optional[str] = None
) -> List[WhatsAppTemplate]:
"""Get all active templates"""
try:
filters = {"is_active": True, "status": "APPROVED"}
if tenant_id:
filters["tenant_id"] = tenant_id
if category:
filters["category"] = category
return await self.get_multi(
filters=filters,
limit=1000,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get active templates", error=str(e))
return []
async def increment_usage(self, template_id: str) -> None:
"""Increment template usage counter"""
try:
template = await self.get(template_id)
if template:
await self.update(
template_id,
{
"sent_count": (template.sent_count or 0) + 1,
"last_used_at": datetime.utcnow()
}
)
except Exception as e:
logger.error(
"Failed to increment template usage",
template_id=template_id,
error=str(e)
)

View File

@@ -0,0 +1,291 @@
# ================================================================
# services/notification/app/schemas/notifications.py
# ================================================================
"""
Notification schemas for API validation and serialization
"""
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional, Dict, Any, List
from datetime import datetime
from enum import Enum
# Reuse enums from models
class NotificationType(str, Enum):
EMAIL = "email"
WHATSAPP = "whatsapp"
PUSH = "push"
SMS = "sms"
class NotificationStatus(str, Enum):
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
FAILED = "failed"
CANCELLED = "cancelled"
class NotificationPriority(str, Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
# ================================================================
# REQUEST SCHEMAS
# ================================================================
class NotificationCreate(BaseModel):
"""Schema for creating a new notification"""
type: NotificationType
recipient_id: Optional[str] = None # For individual notifications
recipient_email: Optional[EmailStr] = None
recipient_phone: Optional[str] = None
# Content
subject: Optional[str] = None
message: str = Field(..., min_length=1, max_length=5000)
html_content: Optional[str] = None
# Template-based content
template_id: Optional[str] = None
template_data: Optional[Dict[str, Any]] = None
# Configuration
priority: NotificationPriority = NotificationPriority.NORMAL
scheduled_at: Optional[datetime] = None
broadcast: bool = False
# Internal fields (set by service)
tenant_id: Optional[str] = None
sender_id: Optional[str] = None
@validator('recipient_phone')
def validate_phone(cls, v):
"""Validate Spanish phone number format"""
if v and not v.startswith(('+34', '6', '7', '9')):
raise ValueError('Invalid Spanish phone number format')
return v
@validator('scheduled_at')
def validate_scheduled_at(cls, v):
"""Ensure scheduled time is in the future"""
if v and v <= datetime.utcnow():
raise ValueError('Scheduled time must be in the future')
return v
class NotificationUpdate(BaseModel):
"""Schema for updating notification status"""
status: Optional[NotificationStatus] = None
error_message: Optional[str] = None
delivered_at: Optional[datetime] = None
read: Optional[bool] = None
read_at: Optional[datetime] = None
class BulkNotificationCreate(BaseModel):
"""Schema for creating bulk notifications"""
type: NotificationType
recipients: List[str] = Field(..., min_items=1, max_items=1000) # User IDs or emails
# Content
subject: Optional[str] = None
message: str = Field(..., min_length=1, max_length=5000)
html_content: Optional[str] = None
# Template-based content
template_id: Optional[str] = None
template_data: Optional[Dict[str, Any]] = None
# Configuration
priority: NotificationPriority = NotificationPriority.NORMAL
scheduled_at: Optional[datetime] = None
# ================================================================
# RESPONSE SCHEMAS
# ================================================================
class NotificationResponse(BaseModel):
"""Schema for notification response"""
id: str
tenant_id: str
sender_id: str
recipient_id: Optional[str]
type: NotificationType
status: NotificationStatus
priority: NotificationPriority
subject: Optional[str]
message: str
recipient_email: Optional[str]
recipient_phone: Optional[str]
scheduled_at: Optional[datetime]
sent_at: Optional[datetime]
delivered_at: Optional[datetime]
broadcast: bool
read: bool
read_at: Optional[datetime]
retry_count: int
error_message: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class NotificationHistory(BaseModel):
"""Schema for notification history"""
notifications: List[NotificationResponse]
total: int
page: int
per_page: int
has_next: bool
has_prev: bool
class NotificationStats(BaseModel):
"""Schema for notification statistics"""
total_sent: int
total_delivered: int
total_failed: int
delivery_rate: float
avg_delivery_time_minutes: Optional[float]
by_type: Dict[str, int]
by_status: Dict[str, int]
recent_activity: List[Dict[str, Any]]
# ================================================================
# PREFERENCE SCHEMAS
# ================================================================
class NotificationPreferences(BaseModel):
"""Schema for user notification preferences"""
user_id: str
tenant_id: str
# Email preferences
email_enabled: bool = True
email_alerts: bool = True
email_marketing: bool = False
email_reports: bool = True
# WhatsApp preferences
whatsapp_enabled: bool = False
whatsapp_alerts: bool = False
whatsapp_reports: bool = False
# Push notification preferences
push_enabled: bool = True
push_alerts: bool = True
push_reports: bool = False
# Timing preferences
quiet_hours_start: str = Field(default="22:00", pattern=r"^([01]?[0-9]|2[0-3]):[0-5][0-9]$")
quiet_hours_end: str = Field(default="08:00", pattern=r"^([01]?[0-9]|2[0-3]):[0-5][0-9]$")
timezone: str = "Europe/Madrid"
# Frequency preferences
digest_frequency: str = Field(default="daily", pattern=r"^(none|daily|weekly)$")
max_emails_per_day: int = Field(default=10, ge=1, le=100)
# Language preference
language: str = Field(default="es", pattern=r"^(es|en)$")
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PreferencesUpdate(BaseModel):
"""Schema for updating notification preferences"""
email_enabled: Optional[bool] = None
email_alerts: Optional[bool] = None
email_marketing: Optional[bool] = None
email_reports: Optional[bool] = None
whatsapp_enabled: Optional[bool] = None
whatsapp_alerts: Optional[bool] = None
whatsapp_reports: Optional[bool] = None
push_enabled: Optional[bool] = None
push_alerts: Optional[bool] = None
push_reports: Optional[bool] = None
quiet_hours_start: Optional[str] = Field(None, pattern=r"^([01]?[0-9]|2[0-3]):[0-5][0-9]$")
quiet_hours_end: Optional[str] = Field(None, pattern=r"^([01]?[0-9]|2[0-3]):[0-5][0-9]$")
timezone: Optional[str] = None
digest_frequency: Optional[str] = Field(None, pattern=r"^(none|daily|weekly)$")
max_emails_per_day: Optional[int] = Field(None, ge=1, le=100)
language: Optional[str] = Field(None, pattern=r"^(es|en)$")
# ================================================================
# TEMPLATE SCHEMAS
# ================================================================
class TemplateCreate(BaseModel):
"""Schema for creating notification templates"""
template_key: str = Field(..., min_length=3, max_length=100)
name: str = Field(..., min_length=3, max_length=255)
description: Optional[str] = None
category: str = Field(..., pattern=r"^(alert|marketing|transactional)$")
type: NotificationType
subject_template: Optional[str] = None
body_template: str = Field(..., min_length=10)
html_template: Optional[str] = None
language: str = Field(default="es", pattern=r"^(es|en)$")
default_priority: NotificationPriority = NotificationPriority.NORMAL
required_variables: Optional[List[str]] = None
class TemplateResponse(BaseModel):
"""Schema for template response"""
id: str
tenant_id: Optional[str]
template_key: str
name: str
description: Optional[str]
category: str
type: NotificationType
subject_template: Optional[str]
body_template: str
html_template: Optional[str]
language: str
is_active: bool
is_system: bool
default_priority: NotificationPriority
required_variables: Optional[List[str]]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ================================================================
# WEBHOOK SCHEMAS
# ================================================================
class DeliveryWebhook(BaseModel):
"""Schema for delivery status webhooks"""
notification_id: str
status: NotificationStatus
provider: str
provider_message_id: Optional[str] = None
delivered_at: Optional[datetime] = None
error_code: Optional[str] = None
error_message: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class ReadReceiptWebhook(BaseModel):
"""Schema for read receipt webhooks"""
notification_id: str
read_at: datetime
user_agent: Optional[str] = None
ip_address: Optional[str] = None

View File

@@ -0,0 +1,370 @@
"""
WhatsApp Business Cloud API Schemas
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
# ============================================================
# Enums
# ============================================================
class WhatsAppMessageType(str, Enum):
"""WhatsApp message types"""
TEMPLATE = "template"
TEXT = "text"
IMAGE = "image"
DOCUMENT = "document"
INTERACTIVE = "interactive"
class WhatsAppMessageStatus(str, Enum):
"""WhatsApp message delivery status"""
PENDING = "pending"
SENT = "sent"
DELIVERED = "delivered"
READ = "read"
FAILED = "failed"
class TemplateCategory(str, Enum):
"""WhatsApp template categories"""
MARKETING = "MARKETING"
UTILITY = "UTILITY"
AUTHENTICATION = "AUTHENTICATION"
# ============================================================
# Template Message Schemas
# ============================================================
class TemplateParameter(BaseModel):
"""Template parameter for dynamic content"""
type: str = Field(default="text", description="Parameter type (text, currency, date_time)")
text: Optional[str] = Field(None, description="Text value for the parameter")
class Config:
json_schema_extra = {
"example": {
"type": "text",
"text": "PO-2024-001"
}
}
class TemplateComponent(BaseModel):
"""Template component (header, body, buttons)"""
type: str = Field(..., description="Component type (header, body, button)")
parameters: Optional[List[TemplateParameter]] = Field(None, description="Component parameters")
sub_type: Optional[str] = Field(None, description="Button sub_type (quick_reply, url)")
index: Optional[int] = Field(None, description="Button index")
class Config:
json_schema_extra = {
"example": {
"type": "body",
"parameters": [
{"type": "text", "text": "PO-2024-001"},
{"type": "text", "text": "100.50"}
]
}
}
class TemplateMessageRequest(BaseModel):
"""Request to send a template message"""
template_name: str = Field(..., description="WhatsApp template name")
language: str = Field(default="es", description="Template language code")
components: List[TemplateComponent] = Field(..., description="Template components with parameters")
class Config:
json_schema_extra = {
"example": {
"template_name": "po_notification",
"language": "es",
"components": [
{
"type": "body",
"parameters": [
{"type": "text", "text": "PO-2024-001"},
{"type": "text", "text": "Supplier XYZ"},
{"type": "text", "text": "€1,250.00"}
]
}
]
}
}
# ============================================================
# Send Message Schemas
# ============================================================
class SendWhatsAppMessageRequest(BaseModel):
"""Request to send a WhatsApp message"""
tenant_id: str = Field(..., description="Tenant ID")
recipient_phone: str = Field(..., description="Recipient phone number (E.164 format)")
recipient_name: Optional[str] = Field(None, description="Recipient name")
message_type: WhatsAppMessageType = Field(..., description="Message type")
template: Optional[TemplateMessageRequest] = Field(None, description="Template details (required for template messages)")
text: Optional[str] = Field(None, description="Text message body (for text messages)")
media_url: Optional[str] = Field(None, description="Media URL (for image/document messages)")
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata (PO number, order ID, etc.)")
notification_id: Optional[str] = Field(None, description="Link to existing notification")
@validator('recipient_phone')
def validate_phone(cls, v):
"""Validate E.164 phone format"""
if not v.startswith('+'):
raise ValueError('Phone number must be in E.164 format (starting with +)')
if len(v) < 10 or len(v) > 16:
raise ValueError('Phone number length must be between 10 and 16 characters')
return v
@validator('template')
def validate_template(cls, v, values):
"""Validate template is provided for template messages"""
if values.get('message_type') == WhatsAppMessageType.TEMPLATE and not v:
raise ValueError('Template details required for template messages')
return v
class Config:
json_schema_extra = {
"example": {
"tenant_id": "123e4567-e89b-12d3-a456-426614174000",
"recipient_phone": "+34612345678",
"recipient_name": "Supplier ABC",
"message_type": "template",
"template": {
"template_name": "po_notification",
"language": "es",
"components": [
{
"type": "body",
"parameters": [
{"type": "text", "text": "PO-2024-001"},
{"type": "text", "text": "€1,250.00"}
]
}
]
},
"metadata": {
"po_number": "PO-2024-001",
"po_id": "123e4567-e89b-12d3-a456-426614174111"
}
}
}
class SendWhatsAppMessageResponse(BaseModel):
"""Response after sending a WhatsApp message"""
success: bool = Field(..., description="Whether message was sent successfully")
message_id: str = Field(..., description="Internal message ID")
whatsapp_message_id: Optional[str] = Field(None, description="WhatsApp's message ID")
status: WhatsAppMessageStatus = Field(..., description="Message status")
error_message: Optional[str] = Field(None, description="Error message if failed")
class Config:
json_schema_extra = {
"example": {
"success": True,
"message_id": "123e4567-e89b-12d3-a456-426614174222",
"whatsapp_message_id": "wamid.HBgNMzQ2MTIzNDU2Nzg5FQIAERgSMzY5RTFFNTdEQzZBRkVCODdBAA==",
"status": "sent",
"error_message": None
}
}
# ============================================================
# Webhook Schemas
# ============================================================
class WebhookValue(BaseModel):
"""Webhook notification value"""
messaging_product: str
metadata: Dict[str, Any]
contacts: Optional[List[Dict[str, Any]]] = None
messages: Optional[List[Dict[str, Any]]] = None
statuses: Optional[List[Dict[str, Any]]] = None
class WebhookEntry(BaseModel):
"""Webhook entry"""
id: str
changes: List[Dict[str, Any]]
class WhatsAppWebhook(BaseModel):
"""WhatsApp webhook payload"""
object: str
entry: List[WebhookEntry]
class WebhookVerification(BaseModel):
"""Webhook verification request"""
mode: str = Field(..., alias="hub.mode")
token: str = Field(..., alias="hub.verify_token")
challenge: str = Field(..., alias="hub.challenge")
class Config:
populate_by_name = True
# ============================================================
# Message Status Schemas
# ============================================================
class MessageStatusUpdate(BaseModel):
"""Message status update"""
whatsapp_message_id: str = Field(..., description="WhatsApp message ID")
status: WhatsAppMessageStatus = Field(..., description="New status")
timestamp: datetime = Field(..., description="Status update timestamp")
error_code: Optional[str] = Field(None, description="Error code if failed")
error_message: Optional[str] = Field(None, description="Error message if failed")
# ============================================================
# Template Management Schemas
# ============================================================
class WhatsAppTemplateCreate(BaseModel):
"""Create a WhatsApp template"""
tenant_id: Optional[str] = Field(None, description="Tenant ID (null for system templates)")
template_name: str = Field(..., description="Template name in WhatsApp")
template_key: str = Field(..., description="Internal template key")
display_name: str = Field(..., description="Display name")
description: Optional[str] = Field(None, description="Template description")
category: TemplateCategory = Field(..., description="Template category")
language: str = Field(default="es", description="Template language")
header_type: Optional[str] = Field(None, description="Header type (TEXT, IMAGE, DOCUMENT, VIDEO)")
header_text: Optional[str] = Field(None, max_length=60, description="Header text (max 60 chars)")
body_text: str = Field(..., description="Body text with {{1}}, {{2}} placeholders")
footer_text: Optional[str] = Field(None, max_length=60, description="Footer text (max 60 chars)")
parameters: Optional[List[Dict[str, Any]]] = Field(None, description="Parameter definitions")
buttons: Optional[List[Dict[str, Any]]] = Field(None, description="Button definitions")
class Config:
json_schema_extra = {
"example": {
"template_name": "po_notification",
"template_key": "po_notification_v1",
"display_name": "Purchase Order Notification",
"description": "Notify supplier of new purchase order",
"category": "UTILITY",
"language": "es",
"body_text": "Hola {{1}}, has recibido una nueva orden de compra {{2}} por un total de {{3}}.",
"parameters": [
{"name": "supplier_name", "example": "Proveedor ABC"},
{"name": "po_number", "example": "PO-2024-001"},
{"name": "total_amount", "example": "€1,250.00"}
]
}
}
class WhatsAppTemplateResponse(BaseModel):
"""WhatsApp template response"""
id: str
tenant_id: Optional[str]
template_name: str
template_key: str
display_name: str
description: Optional[str]
category: str
language: str
status: str
body_text: str
parameter_count: int
is_active: bool
sent_count: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
json_schema_extra = {
"example": {
"id": "123e4567-e89b-12d3-a456-426614174333",
"tenant_id": None,
"template_name": "po_notification",
"template_key": "po_notification_v1",
"display_name": "Purchase Order Notification",
"description": "Notify supplier of new purchase order",
"category": "UTILITY",
"language": "es",
"status": "APPROVED",
"body_text": "Hola {{1}}, has recibido una nueva orden de compra {{2}} por un total de {{3}}.",
"parameter_count": 3,
"is_active": True,
"sent_count": 125,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-01-15T10:30:00"
}
}
# ============================================================
# Message Query Schemas
# ============================================================
class WhatsAppMessageResponse(BaseModel):
"""WhatsApp message response"""
id: str
tenant_id: str
notification_id: Optional[str]
whatsapp_message_id: Optional[str]
recipient_phone: str
recipient_name: Optional[str]
message_type: str
status: str
template_name: Optional[str]
template_language: Optional[str]
message_body: Optional[str]
sent_at: Optional[datetime]
delivered_at: Optional[datetime]
read_at: Optional[datetime]
failed_at: Optional[datetime]
error_message: Optional[str]
metadata: Optional[Dict[str, Any]]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class WhatsAppDeliveryStats(BaseModel):
"""WhatsApp delivery statistics"""
total_messages: int
sent: int
delivered: int
read: int
failed: int
pending: int
unique_recipients: int
total_conversations: int
delivery_rate: float
period: Dict[str, str]
class Config:
json_schema_extra = {
"example": {
"total_messages": 1500,
"sent": 1480,
"delivered": 1450,
"read": 1200,
"failed": 20,
"pending": 0,
"unique_recipients": 350,
"total_conversations": 400,
"delivery_rate": 96.67,
"period": {
"start": "2024-01-01T00:00:00",
"end": "2024-01-31T23:59:59"
}
}
}

View File

@@ -0,0 +1,15 @@
"""
Notification Service Layer
Business logic services for notification operations
"""
from .notification_service import NotificationService, EnhancedNotificationService
from .email_service import EmailService
from .whatsapp_service import WhatsAppService
__all__ = [
"NotificationService",
"EnhancedNotificationService",
"EmailService",
"WhatsAppService"
]

View File

@@ -0,0 +1,559 @@
# ================================================================
# services/notification/app/services/email_service.py
# ================================================================
"""
Email service for sending notifications
Handles SMTP configuration and email delivery
"""
import structlog
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.utils import formataddr
from typing import Optional, List, Dict, Any
import aiosmtplib
from jinja2 import Template
import asyncio
from app.core.config import settings
from shared.monitoring.metrics import MetricsCollector
logger = structlog.get_logger()
metrics = MetricsCollector("notification-service")
class EmailService:
"""
Email service for sending notifications via SMTP
Supports both plain text and HTML emails
"""
def __init__(self):
self.smtp_host = settings.SMTP_HOST
self.smtp_port = settings.SMTP_PORT
self.smtp_user = settings.SMTP_USER
self.smtp_password = settings.SMTP_PASSWORD
self.smtp_tls = settings.SMTP_TLS
self.smtp_ssl = settings.SMTP_SSL
self.default_from_email = settings.DEFAULT_FROM_EMAIL
self.default_from_name = settings.DEFAULT_FROM_NAME
async def send_email(
self,
to_email: str,
subject: str,
text_content: str,
html_content: Optional[str] = None,
from_email: Optional[str] = None,
from_name: Optional[str] = None,
reply_to: Optional[str] = None,
attachments: Optional[List[Dict[str, Any]]] = None
) -> bool:
"""
Send an email notification
Args:
to_email: Recipient email address
subject: Email subject
text_content: Plain text content
html_content: HTML content (optional)
from_email: Sender email (optional, uses default)
from_name: Sender name (optional, uses default)
reply_to: Reply-to address (optional)
attachments: List of attachments (optional)
Returns:
bool: True if email was sent successfully
"""
try:
if not settings.ENABLE_EMAIL_NOTIFICATIONS:
logger.info("Email notifications disabled")
return True # Return success to avoid blocking workflow
if not self.smtp_user or not self.smtp_password:
logger.error("SMTP credentials not configured")
return False
# Validate email address
if not to_email or "@" not in to_email:
logger.error("Invalid recipient email", email=to_email)
return False
# Create message
message = MIMEMultipart('alternative')
message['Subject'] = subject
message['To'] = to_email
# Set From header
sender_email = from_email or self.default_from_email
sender_name = from_name or self.default_from_name
message['From'] = formataddr((sender_name, sender_email))
# Set Reply-To if provided
if reply_to:
message['Reply-To'] = reply_to
# Add text content
text_part = MIMEText(text_content, 'plain', 'utf-8')
message.attach(text_part)
# Add HTML content if provided
if html_content:
html_part = MIMEText(html_content, 'html', 'utf-8')
message.attach(html_part)
# Add attachments if provided
if attachments:
for attachment in attachments:
await self._add_attachment(message, attachment)
# Send email
await self._send_smtp_email(message, sender_email, to_email)
logger.info("Email sent successfully",
to=to_email,
subject=subject,
from_email=sender_email)
# Record success metrics
metrics.increment_counter("emails_sent_total", labels={"status": "success"})
return True
except Exception as e:
logger.error("Failed to send email",
to=to_email,
subject=subject,
error=str(e))
# Record failure metrics
metrics.increment_counter("emails_sent_total", labels={"status": "failed"})
return False
async def send_bulk_emails(
self,
recipients: List[str],
subject: str,
text_content: str,
html_content: Optional[str] = None,
batch_size: int = 50
) -> Dict[str, Any]:
"""
Send bulk emails with rate limiting
Args:
recipients: List of recipient email addresses
subject: Email subject
text_content: Plain text content
html_content: HTML content (optional)
batch_size: Number of emails to send per batch
Returns:
Dict containing success/failure counts
"""
results = {
"total": len(recipients),
"sent": 0,
"failed": 0,
"errors": []
}
try:
# Process in batches to respect rate limits
for i in range(0, len(recipients), batch_size):
batch = recipients[i:i + batch_size]
# Send emails concurrently within batch
tasks = [
self.send_email(
to_email=email,
subject=subject,
text_content=text_content,
html_content=html_content
)
for email in batch
]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for email, result in zip(batch, batch_results):
if isinstance(result, Exception):
results["failed"] += 1
results["errors"].append({"email": email, "error": str(result)})
elif result:
results["sent"] += 1
else:
results["failed"] += 1
results["errors"].append({"email": email, "error": "Unknown error"})
# Rate limiting delay between batches
if i + batch_size < len(recipients):
await asyncio.sleep(1.0) # 1 second delay between batches
logger.info("Bulk email completed",
total=results["total"],
sent=results["sent"],
failed=results["failed"])
return results
except Exception as e:
logger.error("Bulk email failed", error=str(e))
results["errors"].append({"error": str(e)})
return results
async def send_template_email(
self,
to_email: str,
template_name: str,
template_data: Dict[str, Any],
subject_template: Optional[str] = None
) -> bool:
"""
Send email using a template
Args:
to_email: Recipient email address
template_name: Name of the email template
template_data: Data for template rendering
subject_template: Subject template string (optional)
Returns:
bool: True if email was sent successfully
"""
try:
# Load template (simplified - in production, load from database)
template_content = await self._load_email_template(template_name)
if not template_content:
logger.error("Template not found", template=template_name)
return False
# Render subject
subject = template_name.replace("_", " ").title()
if subject_template:
subject_tmpl = Template(subject_template)
subject = subject_tmpl.render(**template_data)
# Render content
text_template = Template(template_content.get("text", ""))
text_content = text_template.render(**template_data)
html_content = None
if template_content.get("html"):
html_template = Template(template_content["html"])
html_content = html_template.render(**template_data)
return await self.send_email(
to_email=to_email,
subject=subject,
text_content=text_content,
html_content=html_content
)
except Exception as e:
logger.error("Failed to send template email",
template=template_name,
error=str(e))
return False
async def health_check(self) -> bool:
"""
Check if email service is healthy
Returns:
bool: True if service is healthy
"""
try:
if not settings.ENABLE_EMAIL_NOTIFICATIONS:
return True # Service is "healthy" if disabled
if not self.smtp_user or not self.smtp_password:
logger.warning("SMTP credentials not configured")
return False
# Test SMTP connection
if self.smtp_ssl:
# Use implicit TLS/SSL connection (port 465 typically)
server = aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port, use_tls=True)
await server.connect()
# No need for starttls() when using implicit TLS
else:
# Use plain connection, optionally upgrade with STARTTLS
server = aiosmtplib.SMTP(hostname=self.smtp_host, port=self.smtp_port)
await server.connect()
if self.smtp_tls:
# Try STARTTLS, but handle case where connection is already secure
try:
await server.starttls()
except Exception as starttls_error:
# If STARTTLS fails because connection is already using TLS, that's okay
if "already using TLS" in str(starttls_error) or "already secure" in str(starttls_error):
logger.debug("SMTP connection already secure, skipping STARTTLS")
else:
# Re-raise other STARTTLS errors
raise starttls_error
await server.login(self.smtp_user, self.smtp_password)
await server.quit()
logger.info("Email service health check passed")
return True
except Exception as e:
logger.error("Email service health check failed", error=str(e))
return False
# ================================================================
# PRIVATE HELPER METHODS
# ================================================================
async def _send_smtp_email(self, message: MIMEMultipart, from_email: str, to_email: str):
"""Send email via SMTP"""
try:
# Create SMTP connection
if self.smtp_ssl:
server = aiosmtplib.SMTP(
hostname=self.smtp_host,
port=self.smtp_port,
use_tls=True,
timeout=30
)
else:
server = aiosmtplib.SMTP(
hostname=self.smtp_host,
port=self.smtp_port,
timeout=30
)
await server.connect()
# Start TLS if required
if self.smtp_tls and not self.smtp_ssl:
await server.starttls()
# Login
await server.login(self.smtp_user, self.smtp_password)
# Send email
await server.send_message(message, from_addr=from_email, to_addrs=[to_email])
# Close connection
await server.quit()
except Exception as e:
logger.error("SMTP send failed", error=str(e))
raise
async def _add_attachment(self, message: MIMEMultipart, attachment: Dict[str, Any]):
"""Add attachment to email message"""
try:
filename = attachment.get("filename", "attachment")
content = attachment.get("content", b"")
content_type = attachment.get("content_type", "application/octet-stream")
# Create attachment part
part = MIMEBase(*content_type.split("/"))
part.set_payload(content)
encoders.encode_base64(part)
part.add_header(
'Content-Disposition',
f'attachment; filename= {filename}'
)
message.attach(part)
except Exception as e:
logger.error("Failed to add attachment", filename=attachment.get("filename"), error=str(e))
async def _load_email_template(self, template_name: str) -> Optional[Dict[str, str]]:
"""Load email template from storage"""
# Simplified template loading - in production, load from database
templates = {
"welcome": {
"text": """
¡Bienvenido a Bakery Forecast, {{user_name}}!
Gracias por registrarte en nuestra plataforma de pronóstico para panaderías.
Tu cuenta ha sido creada exitosamente y ya puedes comenzar a:
- Subir datos de ventas
- Generar pronósticos de demanda
- Optimizar tu producción
Para comenzar, visita: {{dashboard_url}}
Si tienes alguna pregunta, no dudes en contactarnos.
Saludos,
El equipo de Bakery Forecast
""",
"html": """
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">¡Bienvenido a Bakery Forecast!</h1>
</div>
<div style="padding: 20px;">
<p>Hola <strong>{{user_name}}</strong>,</p>
<p>Gracias por registrarte en nuestra plataforma de pronóstico para panaderías.</p>
<p>Tu cuenta ha sido creada exitosamente y ya puedes comenzar a:</p>
<ul style="color: #333;">
<li>📊 Subir datos de ventas</li>
<li>🔮 Generar pronósticos de demanda</li>
<li>⚡ Optimizar tu producción</li>
</ul>
<div style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_url}}"
style="background: #667eea; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Ir al Dashboard
</a>
</div>
<p>Si tienes alguna pregunta, no dudes en contactarnos.</p>
<p>Saludos,<br>
<strong>El equipo de Bakery Forecast</strong></p>
</div>
<div style="background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #666;">
© 2025 Bakery Forecast. Todos los derechos reservados.
</div>
</body>
</html>
"""
},
"forecast_alert": {
"text": """
Alerta de Pronóstico - {{bakery_name}}
Se ha detectado una variación significativa en la demanda prevista:
Producto: {{product_name}}
Fecha: {{forecast_date}}
Demanda prevista: {{predicted_demand}} unidades
Variación: {{variation_percentage}}%
{{alert_message}}
Revisa los pronósticos en: {{dashboard_url}}
Saludos,
El equipo de Bakery Forecast
""",
"html": """
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #ff6b6b; padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">🚨 Alerta de Pronóstico</h1>
</div>
<div style="padding: 20px;">
<h2 style="color: #333;">{{bakery_name}}</h2>
<p>Se ha detectado una variación significativa en la demanda prevista:</p>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Producto:</strong> {{product_name}}</p>
<p><strong>Fecha:</strong> {{forecast_date}}</p>
<p><strong>Demanda prevista:</strong> {{predicted_demand}} unidades</p>
<p><strong>Variación:</strong> <span style="color: #ff6b6b; font-weight: bold;">{{variation_percentage}}%</span></p>
</div>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 0; color: #856404;">{{alert_message}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{dashboard_url}}"
style="background: #ff6b6b; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Ver Pronósticos
</a>
</div>
</div>
</body>
</html>
"""
},
"weekly_report": {
"text": """
Reporte Semanal - {{bakery_name}}
Resumen de la semana del {{week_start}} al {{week_end}}:
Ventas Totales: {{total_sales}} unidades
Precisión del Pronóstico: {{forecast_accuracy}}%
Productos más vendidos:
{{#top_products}}
- {{name}}: {{quantity}} unidades
{{/top_products}}
Recomendaciones:
{{recommendations}}
Ver reporte completo: {{report_url}}
Saludos,
El equipo de Bakery Forecast
""",
"html": """
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #74b9ff; padding: 20px; text-align: center;">
<h1 style="color: white; margin: 0;">📊 Reporte Semanal</h1>
</div>
<div style="padding: 20px;">
<h2 style="color: #333;">{{bakery_name}}</h2>
<p style="color: #666;">Semana del {{week_start}} al {{week_end}}</p>
<div style="display: flex; gap: 20px; margin: 20px 0;">
<div style="background: #dff0d8; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<h3 style="margin: 0; color: #3c763d;">{{total_sales}}</h3>
<p style="margin: 5px 0; color: #3c763d;">Ventas Totales</p>
</div>
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<h3 style="margin: 0; color: #0c5460;">{{forecast_accuracy}}%</h3>
<p style="margin: 5px 0; color: #0c5460;">Precisión</p>
</div>
</div>
<h3 style="color: #333;">Productos más vendidos:</h3>
<ul style="color: #333;">
{{#top_products}}
<li><strong>{{name}}</strong>: {{quantity}} unidades</li>
{{/top_products}}
</ul>
<div style="background: #e7f3ff; padding: 15px; border-radius: 5px; margin: 20px 0;">
<h4 style="margin-top: 0; color: #004085;">Recomendaciones:</h4>
<p style="margin-bottom: 0; color: #004085;">{{recommendations}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{report_url}}"
style="background: #74b9ff; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; font-weight: bold;">
Ver Reporte Completo
</a>
</div>
</div>
</body>
</html>
"""
}
}
return templates.get(template_name)

View File

@@ -0,0 +1,279 @@
# 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

View File

@@ -0,0 +1,696 @@
"""
Enhanced Notification Service
Business logic layer using repository pattern for notification operations
"""
import structlog
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Union
from sqlalchemy.ext.asyncio import AsyncSession
import json
from app.repositories import (
NotificationRepository,
TemplateRepository,
PreferenceRepository,
LogRepository
)
from app.models.notifications import (
Notification, NotificationTemplate, NotificationPreference, NotificationLog,
NotificationStatus, NotificationType, NotificationPriority
)
from shared.database.exceptions import DatabaseError, ValidationError, DuplicateRecordError
from shared.database.transactions import transactional
from shared.database.base import create_database_manager
from shared.database.unit_of_work import UnitOfWork
logger = structlog.get_logger()
class EnhancedNotificationService:
"""Enhanced notification management business logic using repository pattern with dependency injection"""
def __init__(self, database_manager=None):
self.database_manager = database_manager or create_database_manager()
async def _init_repositories(self, session):
"""Initialize repositories with session"""
self.notification_repo = NotificationRepository(session)
self.template_repo = TemplateRepository(session)
self.preference_repo = PreferenceRepository(session)
self.log_repo = LogRepository(session)
return {
'notification': self.notification_repo,
'template': self.template_repo,
'preference': self.preference_repo,
'log': self.log_repo
}
async def create_notification(
self,
tenant_id: str,
sender_id: str,
notification_type: NotificationType,
message: str,
recipient_id: str = None,
recipient_email: str = None,
recipient_phone: str = None,
subject: str = None,
html_content: str = None,
template_key: str = None,
template_data: Dict[str, Any] = None,
priority: NotificationPriority = NotificationPriority.NORMAL,
scheduled_at: datetime = None,
broadcast: bool = False,
session: AsyncSession = None
) -> Notification:
"""Create a new notification with enhanced validation and template support"""
try:
async with self.database_manager.get_session() as db_session:
async with UnitOfWork(db_session) as uow:
# Register repositories with model classes
notification_repo = uow.register_repository("notifications", NotificationRepository, Notification)
template_repo = uow.register_repository("templates", TemplateRepository, NotificationTemplate)
preference_repo = uow.register_repository("preferences", PreferenceRepository, NotificationPreference)
log_repo = uow.register_repository("logs", LogRepository, NotificationLog)
notification_data = {
"tenant_id": tenant_id,
"sender_id": sender_id,
"type": notification_type,
"message": message,
"priority": priority,
"broadcast": broadcast
}
# Add recipient information
if recipient_id:
notification_data["recipient_id"] = recipient_id
if recipient_email:
notification_data["recipient_email"] = recipient_email
if recipient_phone:
notification_data["recipient_phone"] = recipient_phone
# Add optional fields
if subject:
notification_data["subject"] = subject
if html_content:
notification_data["html_content"] = html_content
if scheduled_at:
notification_data["scheduled_at"] = scheduled_at
# Handle template processing
if template_key:
template = await template_repo.get_by_template_key(template_key)
if not template:
raise ValidationError(f"Template with key '{template_key}' not found")
# Process template with provided data
processed_content = await self._process_template(template, template_data or {})
# Update notification data with processed template content
notification_data.update(processed_content)
notification_data["template_id"] = template_key
if template_data:
notification_data["template_data"] = json.dumps(template_data)
# Check recipient preferences if not a broadcast
if not broadcast and recipient_id:
can_send = await self._check_recipient_preferences(
recipient_id, tenant_id, notification_type, priority, preference_repo
)
if not can_send["allowed"]:
logger.info("Notification blocked by recipient preferences",
recipient_id=recipient_id,
reason=can_send["reason"])
raise ValidationError(f"Notification blocked: {can_send['reason']}")
# Create the notification
notification = await notification_repo.create_notification(notification_data)
logger.info("Notification created successfully",
notification_id=notification.id,
tenant_id=tenant_id,
type=notification_type.value,
priority=priority.value,
broadcast=broadcast,
scheduled=scheduled_at is not None)
return notification
except (ValidationError, DatabaseError):
raise
except Exception as e:
logger.error("Failed to create notification",
tenant_id=tenant_id,
type=notification_type.value,
error=str(e))
raise DatabaseError(f"Failed to create notification: {str(e)}")
async def get_notification_by_id(self, notification_id: str) -> Optional[Notification]:
"""Get notification by ID"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
return await self.notification_repo.get_by_id(notification_id)
except Exception as e:
logger.error("Failed to get notification",
notification_id=notification_id,
error=str(e))
return None
async def get_user_notifications(
self,
user_id: str,
tenant_id: str = None,
unread_only: bool = False,
notification_type: NotificationType = None,
skip: int = 0,
limit: int = 50
) -> List[Notification]:
"""Get notifications for a user with filters"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
return await self.notification_repo.get_notifications_by_recipient(
recipient_id=user_id,
tenant_id=tenant_id,
status=None,
notification_type=notification_type,
unread_only=unread_only,
skip=skip,
limit=limit
)
except Exception as e:
logger.error("Failed to get user notifications",
user_id=user_id,
error=str(e))
return []
async def get_tenant_notifications(
self,
tenant_id: str,
status: NotificationStatus = None,
notification_type: NotificationType = None,
skip: int = 0,
limit: int = 50
) -> List[Notification]:
"""Get notifications for a tenant"""
try:
filters = {"tenant_id": tenant_id}
if status:
filters["status"] = status
if notification_type:
filters["type"] = notification_type
return await self.notification_repo.get_multi(
filters=filters,
skip=skip,
limit=limit,
order_by="created_at",
order_desc=True
)
except Exception as e:
logger.error("Failed to get tenant notifications",
tenant_id=tenant_id,
error=str(e))
return []
async def mark_notification_as_read(self, notification_id: str, user_id: str) -> bool:
"""Mark a notification as read by a user"""
try:
# Verify the notification belongs to the user
notification = await self.notification_repo.get_by_id(notification_id)
if not notification:
return False
# Allow if it's the recipient or a broadcast notification
if notification.recipient_id != user_id and not notification.broadcast:
logger.warning("User attempted to mark notification as read without permission",
notification_id=notification_id,
user_id=user_id,
actual_recipient=notification.recipient_id)
return False
updated_notification = await self.notification_repo.mark_as_read(notification_id)
return updated_notification is not None
except Exception as e:
logger.error("Failed to mark notification as read",
notification_id=notification_id,
user_id=user_id,
error=str(e))
return False
async def mark_multiple_as_read(
self,
user_id: str,
notification_ids: List[str] = None,
tenant_id: str = None
) -> int:
"""Mark multiple notifications as read for a user"""
try:
return await self.notification_repo.mark_multiple_as_read(
recipient_id=user_id,
notification_ids=notification_ids,
tenant_id=tenant_id
)
except Exception as e:
logger.error("Failed to mark multiple notifications as read",
user_id=user_id,
error=str(e))
return 0
@transactional
async def update_notification_status(
self,
notification_id: str,
new_status: NotificationStatus,
error_message: str = None,
provider_message_id: str = None,
metadata: Dict[str, Any] = None,
response_time_ms: int = None,
provider: str = None,
session: AsyncSession = None
) -> Optional[Notification]:
"""Update notification status and create log entry"""
try:
# Update the notification status
updated_notification = await self.notification_repo.update_notification_status(
notification_id, new_status, error_message, provider_message_id, metadata
)
if not updated_notification:
return None
# Create a log entry
log_data = {
"notification_id": notification_id,
"attempt_number": updated_notification.retry_count + 1,
"status": new_status,
"provider": provider,
"provider_message_id": provider_message_id,
"response_time_ms": response_time_ms,
"error_message": error_message,
"log_metadata": metadata
}
await self.log_repo.create_log_entry(log_data)
logger.info("Notification status updated with log entry",
notification_id=notification_id,
new_status=new_status.value,
provider=provider)
return updated_notification
except Exception as e:
logger.error("Failed to update notification status",
notification_id=notification_id,
new_status=new_status.value,
error=str(e))
raise DatabaseError(f"Failed to update status: {str(e)}")
async def get_pending_notifications(
self,
limit: int = 100,
notification_type: NotificationType = None
) -> List[Notification]:
"""Get pending notifications for processing"""
try:
pending = await self.notification_repo.get_pending_notifications(limit)
if notification_type:
# Filter by type if specified
pending = [n for n in pending if n.type == notification_type]
return pending
except Exception as e:
logger.error("Failed to get pending notifications",
type=notification_type.value if notification_type else None,
error=str(e))
return []
async def schedule_notification(
self,
notification_id: str,
scheduled_at: datetime
) -> bool:
"""Schedule a notification for future delivery"""
try:
updated_notification = await self.notification_repo.schedule_notification(
notification_id, scheduled_at
)
return updated_notification is not None
except ValidationError as e:
logger.warning("Failed to schedule notification",
notification_id=notification_id,
scheduled_at=scheduled_at,
error=str(e))
return False
except Exception as e:
logger.error("Failed to schedule notification",
notification_id=notification_id,
error=str(e))
return False
async def cancel_notification(
self,
notification_id: str,
reason: str = None
) -> bool:
"""Cancel a pending notification"""
try:
cancelled = await self.notification_repo.cancel_notification(
notification_id, reason
)
return cancelled is not None
except ValidationError as e:
logger.warning("Failed to cancel notification",
notification_id=notification_id,
error=str(e))
return False
except Exception as e:
logger.error("Failed to cancel notification",
notification_id=notification_id,
error=str(e))
return False
async def retry_failed_notification(self, notification_id: str) -> bool:
"""Retry a failed notification"""
try:
notification = await self.notification_repo.get_by_id(notification_id)
if not notification:
return False
if notification.status != NotificationStatus.FAILED:
logger.warning("Cannot retry notification that is not failed",
notification_id=notification_id,
current_status=notification.status.value)
return False
if notification.retry_count >= notification.max_retries:
logger.warning("Cannot retry notification - max retries exceeded",
notification_id=notification_id,
retry_count=notification.retry_count,
max_retries=notification.max_retries)
return False
# Reset status to pending for retry
updated = await self.notification_repo.update_notification_status(
notification_id, NotificationStatus.PENDING
)
if updated:
logger.info("Notification queued for retry",
notification_id=notification_id,
retry_count=notification.retry_count)
return updated is not None
except Exception as e:
logger.error("Failed to retry notification",
notification_id=notification_id,
error=str(e))
return False
async def get_notification_statistics(
self,
tenant_id: str = None,
days_back: int = 30
) -> Dict[str, Any]:
"""Get comprehensive notification statistics"""
try:
# Get notification statistics
notification_stats = await self.notification_repo.get_notification_statistics(
tenant_id, days_back
)
# Get delivery performance statistics
delivery_stats = await self.log_repo.get_delivery_performance_stats(
hours_back=days_back * 24
)
return {
"notifications": notification_stats,
"delivery_performance": delivery_stats
}
except Exception as e:
logger.error("Failed to get notification statistics",
tenant_id=tenant_id,
error=str(e))
return {
"notifications": {},
"delivery_performance": {}
}
# Template Management Methods
@transactional
async def create_template(
self,
template_data: Dict[str, Any],
session: AsyncSession = None
) -> NotificationTemplate:
"""Create a new notification template"""
try:
return await self.template_repo.create_template(template_data)
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create template",
template_key=template_data.get("template_key"),
error=str(e))
raise DatabaseError(f"Failed to create template: {str(e)}")
async def get_template(self, template_key: str) -> Optional[NotificationTemplate]:
"""Get template by key"""
try:
return await self.template_repo.get_by_template_key(template_key)
except Exception as e:
logger.error("Failed to get template",
template_key=template_key,
error=str(e))
return None
async def get_templates_by_category(
self,
category: str,
tenant_id: str = None,
include_system: bool = True
) -> List[NotificationTemplate]:
"""Get templates by category"""
try:
return await self.template_repo.get_templates_by_category(
category, tenant_id, include_system
)
except Exception as e:
logger.error("Failed to get templates by category",
category=category,
tenant_id=tenant_id,
error=str(e))
return []
async def search_templates(
self,
search_term: str,
tenant_id: str = None,
category: str = None,
notification_type: NotificationType = None,
include_system: bool = True
) -> List[NotificationTemplate]:
"""Search templates"""
try:
return await self.template_repo.search_templates(
search_term, tenant_id, category, notification_type, include_system
)
except Exception as e:
logger.error("Failed to search templates",
search_term=search_term,
error=str(e))
return []
# Preference Management Methods
@transactional
async def create_user_preferences(
self,
user_id: str,
tenant_id: str,
preferences: Dict[str, Any] = None,
session: AsyncSession = None
) -> NotificationPreference:
"""Create user notification preferences"""
try:
preference_data = {
"user_id": user_id,
"tenant_id": tenant_id
}
if preferences:
preference_data.update(preferences)
return await self.preference_repo.create_preferences(preference_data)
except (ValidationError, DuplicateRecordError):
raise
except Exception as e:
logger.error("Failed to create user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to create preferences: {str(e)}")
async def get_user_preferences(
self,
user_id: str,
tenant_id: str
) -> Optional[NotificationPreference]:
"""Get user notification preferences"""
try:
return await self.preference_repo.get_user_preferences(user_id, tenant_id)
except Exception as e:
logger.error("Failed to get user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
return None
@transactional
async def update_user_preferences(
self,
user_id: str,
tenant_id: str,
updates: Dict[str, Any],
session: AsyncSession = None
) -> Optional[NotificationPreference]:
"""Update user notification preferences"""
try:
return await self.preference_repo.update_user_preferences(
user_id, tenant_id, updates
)
except ValidationError:
raise
except Exception as e:
logger.error("Failed to update user preferences",
user_id=user_id,
tenant_id=tenant_id,
error=str(e))
raise DatabaseError(f"Failed to update preferences: {str(e)}")
# Helper Methods
async def _process_template(
self,
template: NotificationTemplate,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""Process template with provided data"""
try:
result = {}
# Process subject if available
if template.subject_template:
result["subject"] = self._replace_template_variables(
template.subject_template, data
)
# Process body template
result["message"] = self._replace_template_variables(
template.body_template, data
)
# Process HTML template if available
if template.html_template:
result["html_content"] = self._replace_template_variables(
template.html_template, data
)
return result
except Exception as e:
logger.error("Failed to process template",
template_key=template.template_key,
error=str(e))
raise ValidationError(f"Template processing failed: {str(e)}")
def _replace_template_variables(self, template_text: str, data: Dict[str, Any]) -> str:
"""Replace template variables with actual values"""
try:
# Simple variable replacement using format()
# In a real implementation, you might use Jinja2 or similar
result = template_text
for key, value in data.items():
placeholder = f"{{{key}}}"
if placeholder in result:
result = result.replace(placeholder, str(value))
return result
except Exception as e:
logger.error("Failed to replace template variables", error=str(e))
return template_text
async def _check_recipient_preferences(
self,
recipient_id: str,
tenant_id: str,
notification_type: NotificationType,
priority: NotificationPriority,
preference_repo: PreferenceRepository = None
) -> Dict[str, Any]:
"""Check if notification can be sent based on recipient preferences"""
try:
# Get notification category based on type
category = "alerts" # Default
if notification_type == NotificationType.EMAIL:
category = "alerts" # You might have more sophisticated logic here
# Check if email can be sent based on preferences
if notification_type == NotificationType.EMAIL:
repo = preference_repo or self.preference_repo
return await repo.can_send_email(
recipient_id, tenant_id, category
)
# For other types, implement similar checks
# For now, allow all other types
return {"allowed": True, "reason": "No restrictions"}
except Exception as e:
logger.error("Failed to check recipient preferences",
recipient_id=recipient_id,
tenant_id=tenant_id,
error=str(e))
# Default to allowing on error
return {"allowed": True, "reason": "Error checking preferences"}
# Legacy compatibility alias
NotificationService = EnhancedNotificationService

View File

@@ -0,0 +1,277 @@
# services/notification/app/services/sse_service.py
"""
Server-Sent Events service for real-time notifications
Integrated within the notification service for alerts and recommendations
"""
import asyncio
import json
from typing import Dict, Set, Any
from datetime import datetime
import structlog
from shared.redis_utils import initialize_redis, get_redis_client, close_redis
logger = structlog.get_logger()
class SSEService:
"""
Server-Sent Events service for real-time notifications
Handles both alerts and recommendations through unified SSE streams
"""
def __init__(self):
self.redis = None
self.redis_url = None
self.active_connections: Dict[str, Set[asyncio.Queue]] = {}
self.pubsub_tasks: Dict[str, asyncio.Task] = {}
async def initialize(self, redis_url: str):
"""Initialize Redis connection"""
try:
self.redis_url = redis_url
# Initialize shared Redis connection for SSE
await initialize_redis(redis_url, db=0, max_connections=30)
self.redis = await get_redis_client()
logger.info("SSE Service initialized with shared Redis connection")
except Exception as e:
logger.error("Failed to initialize SSE service", error=str(e))
raise
async def shutdown(self):
"""Clean shutdown"""
try:
# Cancel all pubsub tasks
for task in self.pubsub_tasks.values():
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Close all client connections
for tenant_id, connections in self.active_connections.items():
for queue in connections.copy():
try:
await queue.put({"event": "shutdown", "data": json.dumps({"status": "server_shutdown"})})
except:
pass
# Close shared Redis connection
await close_redis()
logger.info("SSE Service shutdown completed")
except Exception as e:
logger.error("Error during SSE shutdown", error=str(e))
async def add_client(self, tenant_id: str, client_queue: asyncio.Queue):
"""Add a new SSE client connection"""
try:
if tenant_id not in self.active_connections:
self.active_connections[tenant_id] = set()
# Start pubsub listener for this tenant if not exists
if tenant_id not in self.pubsub_tasks:
task = asyncio.create_task(self._listen_to_tenant_channel(tenant_id))
self.pubsub_tasks[tenant_id] = task
self.active_connections[tenant_id].add(client_queue)
client_count = len(self.active_connections[tenant_id])
logger.info("SSE client added",
tenant_id=tenant_id,
total_clients=client_count)
# Send connection confirmation
await client_queue.put({
"event": "connected",
"data": json.dumps({
"status": "connected",
"tenant_id": tenant_id,
"timestamp": datetime.utcnow().isoformat(),
"client_count": client_count
})
})
# Send any active items (alerts and recommendations)
active_items = await self.get_active_items(tenant_id)
if active_items:
await client_queue.put({
"event": "initial_items",
"data": json.dumps(active_items)
})
except Exception as e:
logger.error("Error adding SSE client", tenant_id=tenant_id, error=str(e))
async def remove_client(self, tenant_id: str, client_queue: asyncio.Queue):
"""Remove SSE client connection"""
try:
if tenant_id in self.active_connections:
self.active_connections[tenant_id].discard(client_queue)
# If no more clients for this tenant, stop the pubsub listener
if not self.active_connections[tenant_id]:
del self.active_connections[tenant_id]
if tenant_id in self.pubsub_tasks:
task = self.pubsub_tasks[tenant_id]
if not task.done():
task.cancel()
del self.pubsub_tasks[tenant_id]
logger.info("SSE client removed", tenant_id=tenant_id)
except Exception as e:
logger.error("Error removing SSE client", tenant_id=tenant_id, error=str(e))
async def _listen_to_tenant_channel(self, tenant_id: str):
"""Listen to Redis channel for tenant-specific items"""
pubsub = None
try:
# Use the shared Redis client for pubsub
pubsub = self.redis.pubsub()
channel = f"alerts:{tenant_id}"
await pubsub.subscribe(channel)
logger.info("Started listening to tenant channel",
tenant_id=tenant_id,
channel=channel)
async for message in pubsub.listen():
if message["type"] == "message":
# Broadcast to all connected clients for this tenant
await self.broadcast_to_tenant(tenant_id, message["data"])
except asyncio.CancelledError:
logger.info("Stopped listening to tenant channel", tenant_id=tenant_id)
except Exception as e:
logger.error("Error in pubsub listener", tenant_id=tenant_id, error=str(e))
finally:
if pubsub:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except:
pass
async def broadcast_to_tenant(self, tenant_id: str, message: str):
"""Broadcast message to all connected clients of a tenant"""
if tenant_id not in self.active_connections:
return
try:
item_data = json.loads(message)
event = {
"event": item_data.get('item_type', 'item'), # 'alert' or 'recommendation'
"data": json.dumps(item_data),
"id": item_data.get("id")
}
# Send to all connected clients
disconnected = []
for client_queue in self.active_connections[tenant_id]:
try:
# Use put_nowait to avoid blocking
client_queue.put_nowait(event)
except asyncio.QueueFull:
logger.warning("Client queue full, dropping message", tenant_id=tenant_id)
disconnected.append(client_queue)
except Exception as e:
logger.warning("Failed to send to client", tenant_id=tenant_id, error=str(e))
disconnected.append(client_queue)
# Clean up disconnected clients
for queue in disconnected:
await self.remove_client(tenant_id, queue)
if disconnected:
logger.info("Cleaned up disconnected clients",
tenant_id=tenant_id,
count=len(disconnected))
except Exception as e:
logger.error("Error broadcasting to tenant", tenant_id=tenant_id, error=str(e))
async def send_item_notification(self, tenant_id: str, item: Dict[str, Any]):
"""
Send alert or recommendation via SSE (called by notification orchestrator)
"""
try:
# Publish to Redis for SSE streaming
channel = f"alerts:{tenant_id}"
item_message = {
'id': item.get('id'),
'item_type': item.get('type'), # 'alert' or 'recommendation'
'type': item.get('alert_type', item.get('type')),
'severity': item.get('severity'),
'title': item.get('title'),
'message': item.get('message'),
'actions': item.get('actions', []),
'metadata': item.get('metadata', {}),
'timestamp': item.get('timestamp', datetime.utcnow().isoformat()),
'status': 'active'
}
await self.redis.publish(channel, json.dumps(item_message))
logger.info("Item published to SSE",
tenant_id=tenant_id,
item_type=item.get('type'),
item_id=item.get('id'))
except Exception as e:
logger.error("Error sending item notification via SSE",
tenant_id=tenant_id,
error=str(e))
async def get_active_items(self, tenant_id: str) -> list:
"""
Fetch active alerts and recommendations from Redis cache.
NOTE: We use Redis as the source of truth for active alerts to maintain
microservices architecture. The alert_processor service caches active alerts
in Redis when they are created, and we read from that cache here.
This avoids direct database coupling between services.
"""
try:
if not self.redis:
logger.warning("Redis not available, returning empty list", tenant_id=tenant_id)
return []
# Try to get cached active alerts for this tenant from Redis
cache_key = f"active_alerts:{tenant_id}"
cached_data = await self.redis.get(cache_key)
if cached_data:
active_items = json.loads(cached_data)
logger.info("Fetched active alerts from Redis cache",
tenant_id=tenant_id,
count=len(active_items))
return active_items
else:
logger.info("No cached alerts found for tenant",
tenant_id=tenant_id)
return []
except Exception as e:
logger.error("Error fetching active items from Redis",
tenant_id=tenant_id,
error=str(e),
exc_info=True)
return []
def get_metrics(self) -> Dict[str, Any]:
"""Get SSE service metrics"""
redis_connected = False
try:
redis_connected = self.redis and hasattr(self.redis, 'connection_pool') and self.redis.connection_pool
except:
redis_connected = False
return {
"active_tenants": len(self.active_connections),
"total_connections": sum(len(connections) for connections in self.active_connections.values()),
"active_listeners": len(self.pubsub_tasks),
"redis_connected": redis_connected
}

View File

@@ -0,0 +1,248 @@
# services/notification/app/services/tenant_deletion_service.py
"""
Tenant Data Deletion Service for Notification Service
Handles deletion of all notification-related data for a tenant
"""
from typing import Dict
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import UUID
import structlog
from shared.services.tenant_deletion import (
BaseTenantDataDeletionService,
TenantDataDeletionResult
)
from app.models import (
Notification,
NotificationTemplate,
NotificationPreference,
NotificationLog,
AuditLog
)
logger = structlog.get_logger(__name__)
class NotificationTenantDeletionService(BaseTenantDataDeletionService):
"""Service for deleting all notification-related data for a tenant"""
def __init__(self, db: AsyncSession):
self.db = db
self.service_name = "notification"
async def get_tenant_data_preview(self, tenant_id: str) -> Dict[str, int]:
"""
Get counts of what would be deleted for a tenant (dry-run)
Args:
tenant_id: The tenant ID to preview deletion for
Returns:
Dictionary with entity names and their counts
"""
logger.info("notification.tenant_deletion.preview", tenant_id=tenant_id)
preview = {}
try:
# Count notifications
notification_count = await self.db.scalar(
select(func.count(Notification.id)).where(
Notification.tenant_id == tenant_id
)
)
preview["notifications"] = notification_count or 0
# Count tenant-specific notification templates
template_count = await self.db.scalar(
select(func.count(NotificationTemplate.id)).where(
NotificationTemplate.tenant_id == tenant_id,
NotificationTemplate.is_system == False # Don't delete system templates
)
)
preview["notification_templates"] = template_count or 0
# Count notification preferences
preference_count = await self.db.scalar(
select(func.count(NotificationPreference.id)).where(
NotificationPreference.tenant_id == tenant_id
)
)
preview["notification_preferences"] = preference_count or 0
# Count notification logs (join with Notification to get tenant_id)
log_count = await self.db.scalar(
select(func.count(NotificationLog.id)).select_from(NotificationLog).join(
Notification, NotificationLog.notification_id == Notification.id
).where(
Notification.tenant_id == tenant_id
)
)
preview["notification_logs"] = log_count or 0
# Count audit logs
audit_count = await self.db.scalar(
select(func.count(AuditLog.id)).where(
AuditLog.tenant_id == tenant_id
)
)
preview["audit_logs"] = audit_count or 0
logger.info(
"notification.tenant_deletion.preview_complete",
tenant_id=tenant_id,
preview=preview
)
except Exception as e:
logger.error(
"notification.tenant_deletion.preview_error",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
raise
return preview
async def delete_tenant_data(self, tenant_id: str) -> TenantDataDeletionResult:
"""
Permanently delete all notification data for a tenant
Deletion order:
1. NotificationLog (independent)
2. NotificationPreference (independent)
3. Notification (main records)
4. NotificationTemplate (only tenant-specific, preserve system templates)
5. AuditLog (independent)
Note: System templates (is_system=True) are NOT deleted
Args:
tenant_id: The tenant ID to delete data for
Returns:
TenantDataDeletionResult with deletion counts and any errors
"""
logger.info("notification.tenant_deletion.started", tenant_id=tenant_id)
result = TenantDataDeletionResult(tenant_id=tenant_id, service_name=self.service_name)
try:
# Step 1: Delete notification logs (via subquery to get notification_ids for this tenant)
logger.info("notification.tenant_deletion.deleting_logs", tenant_id=tenant_id)
notification_ids_subquery = select(Notification.id).where(Notification.tenant_id == tenant_id)
logs_result = await self.db.execute(
delete(NotificationLog).where(
NotificationLog.notification_id.in_(notification_ids_subquery)
)
)
result.deleted_counts["notification_logs"] = logs_result.rowcount
logger.info(
"notification.tenant_deletion.logs_deleted",
tenant_id=tenant_id,
count=logs_result.rowcount
)
# Step 2: Delete notification preferences
logger.info("notification.tenant_deletion.deleting_preferences", tenant_id=tenant_id)
preferences_result = await self.db.execute(
delete(NotificationPreference).where(
NotificationPreference.tenant_id == tenant_id
)
)
result.deleted_counts["notification_preferences"] = preferences_result.rowcount
logger.info(
"notification.tenant_deletion.preferences_deleted",
tenant_id=tenant_id,
count=preferences_result.rowcount
)
# Step 3: Delete notifications
logger.info("notification.tenant_deletion.deleting_notifications", tenant_id=tenant_id)
notifications_result = await self.db.execute(
delete(Notification).where(
Notification.tenant_id == tenant_id
)
)
result.deleted_counts["notifications"] = notifications_result.rowcount
logger.info(
"notification.tenant_deletion.notifications_deleted",
tenant_id=tenant_id,
count=notifications_result.rowcount
)
# Step 4: Delete tenant-specific templates (preserve system templates)
logger.info("notification.tenant_deletion.deleting_templates", tenant_id=tenant_id)
templates_result = await self.db.execute(
delete(NotificationTemplate).where(
NotificationTemplate.tenant_id == tenant_id,
NotificationTemplate.is_system == False
)
)
result.deleted_counts["notification_templates"] = templates_result.rowcount
logger.info(
"notification.tenant_deletion.templates_deleted",
tenant_id=tenant_id,
count=templates_result.rowcount,
note="System templates preserved"
)
# Step 5: Delete audit logs
logger.info("notification.tenant_deletion.deleting_audit_logs", tenant_id=tenant_id)
audit_result = await self.db.execute(
delete(AuditLog).where(
AuditLog.tenant_id == tenant_id
)
)
result.deleted_counts["audit_logs"] = audit_result.rowcount
logger.info(
"notification.tenant_deletion.audit_logs_deleted",
tenant_id=tenant_id,
count=audit_result.rowcount
)
# Commit the transaction
await self.db.commit()
# Calculate total deleted
total_deleted = sum(result.deleted_counts.values())
logger.info(
"notification.tenant_deletion.completed",
tenant_id=tenant_id,
total_deleted=total_deleted,
breakdown=result.deleted_counts,
note="System templates preserved"
)
result.success = True
except Exception as e:
await self.db.rollback()
error_msg = f"Failed to delete notification data for tenant {tenant_id}: {str(e)}"
logger.error(
"notification.tenant_deletion.failed",
tenant_id=tenant_id,
error=str(e),
exc_info=True
)
result.errors.append(error_msg)
result.success = False
return result
def get_notification_tenant_deletion_service(
db: AsyncSession
) -> NotificationTenantDeletionService:
"""
Factory function to create NotificationTenantDeletionService instance
Args:
db: AsyncSession database session
Returns:
NotificationTenantDeletionService instance
"""
return NotificationTenantDeletionService(db)

View File

@@ -0,0 +1,560 @@
# ================================================================
# services/notification/app/services/whatsapp_business_service.py
# ================================================================
"""
WhatsApp Business Cloud API Service
Direct integration with Meta's WhatsApp Business Cloud API
Supports template messages for proactive notifications
"""
import structlog
import httpx
from typing import Optional, Dict, Any, List
import asyncio
from datetime import datetime
import uuid
from app.core.config import settings
from app.schemas.whatsapp import (
SendWhatsAppMessageRequest,
SendWhatsAppMessageResponse,
TemplateComponent,
WhatsAppMessageStatus,
WhatsAppMessageType
)
from app.repositories.whatsapp_message_repository import (
WhatsAppMessageRepository,
WhatsAppTemplateRepository
)
from app.models.whatsapp_messages import WhatsAppMessage
from shared.monitoring.metrics import MetricsCollector
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
metrics = MetricsCollector("notification-service")
class WhatsAppBusinessService:
"""
WhatsApp Business Cloud API Service
Direct integration with Meta/Facebook WhatsApp Business Cloud API
"""
def __init__(self, session: Optional[AsyncSession] = None, tenant_client=None):
# Global configuration (fallback)
self.global_access_token = settings.WHATSAPP_ACCESS_TOKEN
self.global_phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID
self.global_business_account_id = settings.WHATSAPP_BUSINESS_ACCOUNT_ID
self.api_version = settings.WHATSAPP_API_VERSION or "v18.0"
self.base_url = f"https://graph.facebook.com/{self.api_version}"
self.enabled = settings.ENABLE_WHATSAPP_NOTIFICATIONS
# Tenant client for fetching per-tenant settings
self.tenant_client = tenant_client
# Repository dependencies (will be injected)
self.session = session
self.message_repo = WhatsAppMessageRepository(session) if session else None
self.template_repo = WhatsAppTemplateRepository(session) if session else None
async def _get_whatsapp_credentials(self, tenant_id: str) -> Dict[str, str]:
"""
Get WhatsApp credentials for a tenant (Shared Account Model)
Uses global master account credentials with tenant-specific phone number
Args:
tenant_id: Tenant ID
Returns:
Dictionary with access_token, phone_number_id, business_account_id
"""
# Always use global master account credentials
access_token = self.global_access_token
business_account_id = self.global_business_account_id
phone_number_id = self.global_phone_number_id # Default fallback
# Try to fetch tenant-specific phone number
if self.tenant_client:
try:
notification_settings = await self.tenant_client.get_notification_settings(tenant_id)
if notification_settings and notification_settings.get('whatsapp_enabled'):
tenant_phone_id = notification_settings.get('whatsapp_phone_number_id', '').strip()
# Use tenant's assigned phone number if configured
if tenant_phone_id:
phone_number_id = tenant_phone_id
logger.info(
"Using tenant-assigned WhatsApp phone number with shared account",
tenant_id=tenant_id,
phone_number_id=phone_number_id
)
else:
logger.info(
"Tenant WhatsApp enabled but no phone number assigned, using default",
tenant_id=tenant_id
)
else:
logger.info(
"Tenant WhatsApp not enabled, using default phone number",
tenant_id=tenant_id
)
except Exception as e:
logger.warning(
"Failed to fetch tenant notification settings, using default phone number",
error=str(e),
tenant_id=tenant_id
)
logger.info(
"Using shared WhatsApp account",
tenant_id=tenant_id,
phone_number_id=phone_number_id
)
return {
'access_token': access_token,
'phone_number_id': phone_number_id,
'business_account_id': business_account_id
}
async def send_message(
self,
request: SendWhatsAppMessageRequest
) -> SendWhatsAppMessageResponse:
"""
Send WhatsApp message via Cloud API
Args:
request: Message request with all details
Returns:
SendWhatsAppMessageResponse with status
"""
try:
if not self.enabled:
logger.info("WhatsApp notifications disabled")
return SendWhatsAppMessageResponse(
success=False,
message_id=str(uuid.uuid4()),
status=WhatsAppMessageStatus.FAILED,
error_message="WhatsApp notifications are disabled"
)
# Get tenant-specific or global credentials
credentials = await self._get_whatsapp_credentials(request.tenant_id)
access_token = credentials['access_token']
phone_number_id = credentials['phone_number_id']
# Validate configuration
if not access_token or not phone_number_id:
logger.error("WhatsApp Cloud API not configured properly", tenant_id=request.tenant_id)
return SendWhatsAppMessageResponse(
success=False,
message_id=str(uuid.uuid4()),
status=WhatsAppMessageStatus.FAILED,
error_message="WhatsApp Cloud API credentials not configured"
)
# Create message record in database
message_data = {
"tenant_id": request.tenant_id,
"notification_id": request.notification_id,
"recipient_phone": request.recipient_phone,
"recipient_name": request.recipient_name,
"message_type": request.message_type,
"status": WhatsAppMessageStatus.PENDING,
"metadata": request.metadata,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
# Add template details if template message
if request.message_type == WhatsAppMessageType.TEMPLATE and request.template:
message_data["template_name"] = request.template.template_name
message_data["template_language"] = request.template.language
message_data["template_parameters"] = [
comp.model_dump() for comp in request.template.components
]
# Add text details if text message
if request.message_type == WhatsAppMessageType.TEXT and request.text:
message_data["message_body"] = request.text
# Save to database
if self.message_repo:
db_message = await self.message_repo.create_message(message_data)
message_id = str(db_message.id)
else:
message_id = str(uuid.uuid4())
# Send message via Cloud API
if request.message_type == WhatsAppMessageType.TEMPLATE:
result = await self._send_template_message(
recipient_phone=request.recipient_phone,
template=request.template,
message_id=message_id,
access_token=access_token,
phone_number_id=phone_number_id
)
elif request.message_type == WhatsAppMessageType.TEXT:
result = await self._send_text_message(
recipient_phone=request.recipient_phone,
text=request.text,
message_id=message_id,
access_token=access_token,
phone_number_id=phone_number_id
)
else:
logger.error(f"Unsupported message type: {request.message_type}")
result = {
"success": False,
"error_message": f"Unsupported message type: {request.message_type}"
}
# Update database with result
if self.message_repo and result.get("success"):
await self.message_repo.update_message_status(
message_id=message_id,
status=WhatsAppMessageStatus.SENT,
whatsapp_message_id=result.get("whatsapp_message_id"),
provider_response=result.get("provider_response")
)
elif self.message_repo:
await self.message_repo.update_message_status(
message_id=message_id,
status=WhatsAppMessageStatus.FAILED,
error_message=result.get("error_message"),
provider_response=result.get("provider_response")
)
# Record metrics
status = "success" if result.get("success") else "failed"
metrics.increment_counter("whatsapp_sent_total", labels={"status": status})
return SendWhatsAppMessageResponse(
success=result.get("success", False),
message_id=message_id,
whatsapp_message_id=result.get("whatsapp_message_id"),
status=WhatsAppMessageStatus.SENT if result.get("success") else WhatsAppMessageStatus.FAILED,
error_message=result.get("error_message")
)
except Exception as e:
logger.error("Failed to send WhatsApp message", error=str(e))
metrics.increment_counter("whatsapp_sent_total", labels={"status": "failed"})
return SendWhatsAppMessageResponse(
success=False,
message_id=str(uuid.uuid4()),
status=WhatsAppMessageStatus.FAILED,
error_message=str(e)
)
async def _send_template_message(
self,
recipient_phone: str,
template: Any,
message_id: str,
access_token: str,
phone_number_id: str
) -> Dict[str, Any]:
"""Send template message via WhatsApp Cloud API"""
try:
# Build template payload
payload = {
"messaging_product": "whatsapp",
"to": recipient_phone,
"type": "template",
"template": {
"name": template.template_name,
"language": {
"code": template.language
},
"components": [
{
"type": comp.type,
"parameters": [
param.model_dump() for param in (comp.parameters or [])
]
}
for comp in template.components
]
}
}
# Send request to WhatsApp Cloud API
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.base_url}/{phone_number_id}/messages",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
},
json=payload
)
response_data = response.json()
if response.status_code == 200:
whatsapp_message_id = response_data.get("messages", [{}])[0].get("id")
logger.info(
"WhatsApp template message sent successfully",
message_id=message_id,
whatsapp_message_id=whatsapp_message_id,
template=template.template_name,
recipient=recipient_phone
)
# Increment template usage count
if self.template_repo:
template_obj = await self.template_repo.get_by_template_name(
template.template_name,
template.language
)
if template_obj:
await self.template_repo.increment_usage(str(template_obj.id))
return {
"success": True,
"whatsapp_message_id": whatsapp_message_id,
"provider_response": response_data
}
else:
error_message = response_data.get("error", {}).get("message", "Unknown error")
error_code = response_data.get("error", {}).get("code")
logger.error(
"WhatsApp Cloud API error",
status_code=response.status_code,
error_code=error_code,
error_message=error_message,
template=template.template_name
)
return {
"success": False,
"error_message": f"{error_code}: {error_message}",
"provider_response": response_data
}
except Exception as e:
logger.error(
"Failed to send template message",
template=template.template_name,
error=str(e)
)
return {
"success": False,
"error_message": str(e)
}
async def _send_text_message(
self,
recipient_phone: str,
text: str,
message_id: str,
access_token: str,
phone_number_id: str
) -> Dict[str, Any]:
"""Send text message via WhatsApp Cloud API"""
try:
payload = {
"messaging_product": "whatsapp",
"to": recipient_phone,
"type": "text",
"text": {
"body": text
}
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.base_url}/{phone_number_id}/messages",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
},
json=payload
)
response_data = response.json()
if response.status_code == 200:
whatsapp_message_id = response_data.get("messages", [{}])[0].get("id")
logger.info(
"WhatsApp text message sent successfully",
message_id=message_id,
whatsapp_message_id=whatsapp_message_id,
recipient=recipient_phone
)
return {
"success": True,
"whatsapp_message_id": whatsapp_message_id,
"provider_response": response_data
}
else:
error_message = response_data.get("error", {}).get("message", "Unknown error")
error_code = response_data.get("error", {}).get("code")
logger.error(
"WhatsApp Cloud API error",
status_code=response.status_code,
error_code=error_code,
error_message=error_message
)
return {
"success": False,
"error_message": f"{error_code}: {error_message}",
"provider_response": response_data
}
except Exception as e:
logger.error("Failed to send text message", error=str(e))
return {
"success": False,
"error_message": str(e)
}
async def send_bulk_messages(
self,
requests: List[SendWhatsAppMessageRequest],
batch_size: int = 20
) -> Dict[str, Any]:
"""
Send bulk WhatsApp messages with rate limiting
Args:
requests: List of message requests
batch_size: Number of messages to send per batch
Returns:
Dict containing success/failure counts
"""
results = {
"total": len(requests),
"sent": 0,
"failed": 0,
"errors": []
}
try:
# Process in batches to respect WhatsApp rate limits
for i in range(0, len(requests), batch_size):
batch = requests[i:i + batch_size]
# Send messages concurrently within batch
tasks = [self.send_message(req) for req in batch]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for req, result in zip(batch, batch_results):
if isinstance(result, Exception):
results["failed"] += 1
results["errors"].append({
"phone": req.recipient_phone,
"error": str(result)
})
elif result.success:
results["sent"] += 1
else:
results["failed"] += 1
results["errors"].append({
"phone": req.recipient_phone,
"error": result.error_message
})
# Rate limiting delay between batches
if i + batch_size < len(requests):
await asyncio.sleep(2.0)
logger.info(
"Bulk WhatsApp completed",
total=results["total"],
sent=results["sent"],
failed=results["failed"]
)
return results
except Exception as e:
logger.error("Bulk WhatsApp failed", error=str(e))
results["errors"].append({"error": str(e)})
return results
async def health_check(self) -> bool:
"""
Check if WhatsApp Cloud API is healthy
Returns:
bool: True if service is healthy
"""
try:
if not self.enabled:
return True # Service is "healthy" if disabled
if not self.global_access_token or not self.global_phone_number_id:
logger.warning("WhatsApp Cloud API not configured")
return False
# Test API connectivity
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{self.base_url}/{self.global_phone_number_id}",
headers={
"Authorization": f"Bearer {self.global_access_token}"
},
params={
"fields": "verified_name,code_verification_status"
}
)
if response.status_code == 200:
logger.info("WhatsApp Cloud API health check passed")
return True
else:
logger.error(
"WhatsApp Cloud API health check failed",
status_code=response.status_code
)
return False
except Exception as e:
logger.error("WhatsApp Cloud API health check failed", error=str(e))
return False
def _format_phone_number(self, phone: str) -> Optional[str]:
"""
Format phone number for WhatsApp (E.164 format)
Args:
phone: Input phone number
Returns:
Formatted phone number or None if invalid
"""
if not phone:
return None
# If already in E.164 format, return as is
if phone.startswith('+'):
return phone
# Remove spaces, dashes, and other non-digit characters
clean_phone = "".join(filter(str.isdigit, phone))
# Handle Spanish phone numbers
if clean_phone.startswith("34"):
return f"+{clean_phone}"
elif clean_phone.startswith(("6", "7", "9")) and len(clean_phone) == 9:
return f"+34{clean_phone}"
else:
# Try to add + if it looks like a complete international number
if len(clean_phone) > 10:
return f"+{clean_phone}"
logger.warning("Unrecognized phone format", phone=phone)
return None

View File

@@ -0,0 +1,256 @@
# ================================================================
# services/notification/app/services/whatsapp_service.py
# ================================================================
"""
WhatsApp service for sending notifications
Integrates with WhatsApp Business Cloud API (Meta/Facebook)
This is a backward-compatible wrapper around the new WhatsAppBusinessService
"""
import structlog
import httpx
from typing import Optional, Dict, Any, List
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.services.whatsapp_business_service import WhatsAppBusinessService
from app.schemas.whatsapp import (
SendWhatsAppMessageRequest,
TemplateMessageRequest,
TemplateComponent,
TemplateParameter,
WhatsAppMessageType
)
from shared.monitoring.metrics import MetricsCollector
logger = structlog.get_logger()
metrics = MetricsCollector("notification-service")
class WhatsAppService:
"""
WhatsApp service for sending notifications via WhatsApp Business Cloud API
Backward-compatible wrapper for existing code
"""
def __init__(self, session: Optional[AsyncSession] = None, tenant_client=None):
self.enabled = settings.ENABLE_WHATSAPP_NOTIFICATIONS
self.business_service = WhatsAppBusinessService(session, tenant_client)
async def send_message(
self,
to_phone: str,
message: str,
template_name: Optional[str] = None,
template_params: Optional[List[str]] = None,
tenant_id: Optional[str] = None
) -> bool:
"""
Send WhatsApp message (backward-compatible wrapper)
Args:
to_phone: Recipient phone number (with country code)
message: Message text
template_name: WhatsApp template name (optional)
template_params: Template parameters (optional)
tenant_id: Tenant ID (optional, defaults to system tenant)
Returns:
bool: True if message was sent successfully
"""
try:
if not self.enabled:
logger.info("WhatsApp notifications disabled")
return True # Return success to avoid blocking workflow
# Format phone number
phone = self._format_phone_number(to_phone)
if not phone:
logger.error("Invalid phone number", phone=to_phone)
return False
# Use default tenant if not provided
if not tenant_id:
tenant_id = "00000000-0000-0000-0000-000000000000" # System tenant
# Build request
if template_name:
# Template message
components = []
if template_params:
# Build body component with parameters
parameters = [
TemplateParameter(type="text", text=param)
for param in template_params
]
components.append(
TemplateComponent(type="body", parameters=parameters)
)
template_request = TemplateMessageRequest(
template_name=template_name,
language="es",
components=components
)
request = SendWhatsAppMessageRequest(
tenant_id=tenant_id,
recipient_phone=phone,
message_type=WhatsAppMessageType.TEMPLATE,
template=template_request
)
else:
# Text message
request = SendWhatsAppMessageRequest(
tenant_id=tenant_id,
recipient_phone=phone,
message_type=WhatsAppMessageType.TEXT,
text=message
)
# Send via business service
response = await self.business_service.send_message(request)
if response.success:
logger.info(
"WhatsApp message sent successfully",
to=phone,
template=template_name
)
return response.success
except Exception as e:
logger.error(
"Failed to send WhatsApp message",
to=to_phone,
error=str(e)
)
metrics.increment_counter("whatsapp_sent_total", labels={"status": "failed"})
return False
async def send_bulk_messages(
self,
recipients: List[str],
message: str,
template_name: Optional[str] = None,
template_params: Optional[List[str]] = None,
batch_size: int = 20,
tenant_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Send bulk WhatsApp messages with rate limiting (backward-compatible wrapper)
Args:
recipients: List of recipient phone numbers
message: Message text
template_name: WhatsApp template name (optional)
template_params: Template parameters (optional)
batch_size: Number of messages to send per batch
tenant_id: Tenant ID (optional)
Returns:
Dict containing success/failure counts
"""
results = {
"total": len(recipients),
"sent": 0,
"failed": 0,
"errors": []
}
try:
# Use default tenant if not provided
if not tenant_id:
tenant_id = "00000000-0000-0000-0000-000000000000"
# Build requests for all recipients
requests = []
for phone in recipients:
formatted_phone = self._format_phone_number(phone)
if not formatted_phone:
results["failed"] += 1
results["errors"].append({"phone": phone, "error": "Invalid phone format"})
continue
if template_name:
# Template message
components = []
if template_params:
parameters = [
TemplateParameter(type="text", text=param)
for param in template_params
]
components.append(
TemplateComponent(type="body", parameters=parameters)
)
template_request = TemplateMessageRequest(
template_name=template_name,
language="es",
components=components
)
request = SendWhatsAppMessageRequest(
tenant_id=tenant_id,
recipient_phone=formatted_phone,
message_type=WhatsAppMessageType.TEMPLATE,
template=template_request
)
else:
# Text message
request = SendWhatsAppMessageRequest(
tenant_id=tenant_id,
recipient_phone=formatted_phone,
message_type=WhatsAppMessageType.TEXT,
text=message
)
requests.append(request)
# Send via business service
bulk_result = await self.business_service.send_bulk_messages(
requests,
batch_size=batch_size
)
# Update results
results["sent"] = bulk_result.get("sent", 0)
results["failed"] += bulk_result.get("failed", 0)
results["errors"].extend(bulk_result.get("errors", []))
logger.info(
"Bulk WhatsApp completed",
total=results["total"],
sent=results["sent"],
failed=results["failed"]
)
return results
except Exception as e:
logger.error("Bulk WhatsApp failed", error=str(e))
results["errors"].append({"error": str(e)})
return results
async def health_check(self) -> bool:
"""
Check if WhatsApp service is healthy
Returns:
bool: True if service is healthy
"""
return await self.business_service.health_check()
def _format_phone_number(self, phone: str) -> Optional[str]:
"""
Format phone number for WhatsApp (E.164 format)
Args:
phone: Input phone number
Returns:
Formatted phone number or None if invalid
"""
return self.business_service._format_phone_number(phone)

View File

@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🚨 URGENTE: Fallo de Equipo - {{ equipment_name }}</title>
<style>
body {
font-family: 'Arial', 'Helvetica', sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fff;
}
.header {
background-color: #dc2626;
color: white;
padding: 15px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.content {
padding: 20px;
}
.equipment-info {
background-color: #f8f9fa;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.equipment-info dt {
font-weight: bold;
margin-bottom: 5px;
}
.equipment-info dd {
margin-left: 0;
margin-bottom: 10px;
}
.failure-details {
background-color: #fef2f2;
border: 1px solid #fecaca;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.action-button {
display: inline-block;
background-color: #dc2626;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
margin: 10px 0;
}
.support-contact {
background-color: #f0f9ff;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.footer {
text-align: center;
margin-top: 20px;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚨 URGENTE: Fallo de Equipo</h1>
<p>Acción Inmediata Requerida</p>
</div>
<div class="content">
<p>Estimado Equipo de Soporte,</p>
<p>Se ha reportado un fallo en el equipo <strong>{{ equipment_name }}</strong> en {{ equipment_location }}. Esto requiere su atención inmediata.</p>
<div class="equipment-info">
<h2>Información del Equipo</h2>
<dl>
<dt>Nombre del Equipo:</dt>
<dd>{{ equipment_name }}</dd>
<dt>Tipo:</dt>
<dd>{{ equipment_type }}</dd>
<dt>Modelo:</dt>
<dd>{{ equipment_model }}</dd>
<dt>Número de Serie:</dt>
<dd>{{ equipment_serial_number }}</dd>
<dt>Ubicación:</dt>
<dd>{{ equipment_location }}</dd>
</dl>
</div>
<div class="failure-details">
<h2>Detalles del Fallo</h2>
<dl>
<dt>Tipo de Fallo:</dt>
<dd>{{ failure_type }}</dd>
<dt>Gravedad:</dt>
<dd><strong style="color: {{ 'red' if severity == 'urgent' else 'orange' }};">{{ severity.upper() }}</strong></dd>
<dt>Descripción:</dt>
<dd>{{ description }}</dd>
<dt>Fecha/Hora Reportado:</dt>
<dd>{{ reported_time }}</dd>
<dt>Impacto Estimado:</dt>
<dd>{{ 'SÍ - Afecta producción' if estimated_impact else 'NO - Sin impacto en producción' }}</dd>
</dl>
</div>
<p>Este equipo ha sido marcado automáticamente como <strong>FUERA DE SERVICIO</strong> y retirado de producción para evitar más problemas.</p>
<div class="support-contact">
<h2>Información de Contacto de Soporte</h2>
{% if support_contact.email %}
<p><strong>Email:</strong> {{ support_contact.email }}</p>
{% endif %}
{% if support_contact.phone %}
<p><strong>Teléfono:</strong> {{ support_contact.phone }}</p>
{% endif %}
{% if support_contact.company %}
<p><strong>Empresa:</strong> {{ support_contact.company }}</p>
{% endif %}
{% if support_contact.contract_number %}
<p><strong>Número de Contrato:</strong> {{ support_contact.contract_number }}</p>
{% endif %}
{% if support_contact.response_time_sla %}
<p><strong>Tiempo de Respuesta Esperado:</strong> {{ support_contact.response_time_sla }} horas</p>
{% endif %}
</div>
<p><a href="{{ equipment_link }}" class="action-button">Ver Detalles del Equipo</a></p>
<p>Por favor responda dentro del plazo de SLA y actualice el sistema cuando la reparación esté completada.</p>
<p>Gracias,<br>
Equipo de {{ bakery_name }}</p>
</div>
<div class="footer">
<p>Esta es una notificación automática del Sistema de Monitoreo de Equipos de {{ bakery_name }}</p>
<p>© {{ current_year }} {{ bakery_name }}. Todos los derechos reservados.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>✅ Equipo Reparado - {{ equipment_name }}</title>
<style>
body {
font-family: 'Arial', 'Helvetica', sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fff;
}
.header {
background-color: #10b981;
color: white;
padding: 15px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.content {
padding: 20px;
}
.equipment-info {
background-color: #f8f9fa;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.equipment-info dt {
font-weight: bold;
margin-bottom: 5px;
}
.equipment-info dd {
margin-left: 0;
margin-bottom: 10px;
}
.repair-details {
background-color: #dcfce7;
border: 1px solid #a3e635;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.action-button {
display: inline-block;
background-color: #10b981;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 6px;
font-weight: bold;
margin: 10px 0;
}
.downtime-summary {
background-color: #fef3c7;
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.footer {
text-align: center;
margin-top: 20px;
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✅ Equipo Reparado</h1>
<p>Equipo de Vuelta en Servicio</p>
</div>
<div class="content">
<p>Estimado Equipo,</p>
<p>Nos complace informarle que el equipo <strong>{{ equipment_name }}</strong> ha sido reparado y vuelto al servicio con éxito.</p>
<div class="equipment-info">
<h2>Información del Equipo</h2>
<dl>
<dt>Nombre del Equipo:</dt>
<dd>{{ equipment_name }}</dd>
<dt>Tipo:</dt>
<dd>{{ equipment_type }}</dd>
<dt>Modelo:</dt>
<dd>{{ equipment_model }}</dd>
<dt>Ubicación:</dt>
<dd>{{ equipment_location }}</dd>
</dl>
</div>
<div class="repair-details">
<h2>Detalles de la Reparación</h2>
<dl>
<dt>Fecha de Reparación:</dt>
<dd>{{ repair_date }}</dd>
<dt>Técnico:</dt>
<dd>{{ technician_name }}</dd>
<dt>Descripción de la Reparación:</dt>
<dd>{{ repair_description }}</dd>
{% if parts_replaced and parts_replaced|length > 0 %}
<dt>Piezas Reemplazadas:</dt>
<dd>{{ parts_replaced|join(', ') }}</dd>
{% endif %}
<dt>Costo de Reparación:</dt>
<dd>€{{ "%.2f"|format(cost) }}</dd>
<dt>Resultados de Pruebas:</dt>
<dd>{{ '✅ Equipo probado y operativo' if test_results else '⚠️ Equipo requiere pruebas adicionales' }}</dd>
</dl>
</div>
<div class="downtime-summary">
<h2>Resumen de Tiempo de Inactividad</h2>
<dl>
<dt>Tiempo Total de Inactividad:</dt>
<dd>{{ downtime_hours }} horas</dd>
<dt>Impacto de Costo:</dt>
<dd>€{{ "%.2f"|format(cost) }}</dd>
</dl>
</div>
<p>El equipo ahora está <strong>OPERATIVO</strong> y disponible para producción.</p>
<p><a href="{{ equipment_link }}" class="action-button">Ver Detalles del Equipo</a></p>
<p>Gracias por su pronta atención a este asunto.</p>
<p>Atentamente,<br>
Equipo de {{ bakery_name }}</p>
</div>
<div class="footer">
<p>Esta es una notificación automática del Sistema de Monitoreo de Equipos de {{ bakery_name }}</p>
<p>© {{ current_year }} {{ bakery_name }}. Todos los derechos reservados.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Purchase Order - {{po_number}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f5f5f5;
margin: 0;
padding: 0;
}
.email-container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
color: #ffffff;
padding: 30px 20px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.header p {
margin: 8px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.content {
padding: 30px 20px;
}
.greeting {
font-size: 16px;
margin-bottom: 20px;
}
.info-box {
background-color: #F9FAFB;
border-left: 4px solid #4F46E5;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.info-box h3 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
color: #6B7280;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #E5E7EB;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: #6B7280;
}
.info-value {
font-weight: 600;
color: #111827;
}
.items-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.items-table thead {
background-color: #F3F4F6;
}
.items-table th {
padding: 12px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #6B7280;
text-transform: uppercase;
border-bottom: 2px solid #E5E7EB;
}
.items-table td {
padding: 12px;
border-bottom: 1px solid #E5E7EB;
font-size: 14px;
}
.items-table tbody tr:last-child td {
border-bottom: none;
}
.items-table tbody tr:hover {
background-color: #F9FAFB;
}
.text-right {
text-align: right;
}
.total-row {
background-color: #F3F4F6;
font-weight: 600;
font-size: 16px;
}
.total-row td {
padding: 16px 12px;
border-top: 2px solid #4F46E5;
}
.cta-button {
display: inline-block;
background: linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%);
color: #ffffff;
text-decoration: none;
padding: 14px 28px;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
margin: 20px 0;
text-align: center;
box-shadow: 0 4px 6px rgba(79, 70, 229, 0.2);
}
.cta-button:hover {
opacity: 0.9;
}
.notes {
background-color: #FEF3C7;
border-left: 4px solid #F59E0B;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.notes h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: #92400E;
}
.notes p {
margin: 0;
font-size: 14px;
color: #78350F;
}
.footer {
background-color: #F9FAFB;
padding: 20px;
text-align: center;
font-size: 12px;
color: #6B7280;
border-top: 1px solid #E5E7EB;
}
.footer p {
margin: 4px 0;
}
.footer a {
color: #4F46E5;
text-decoration: none;
}
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.content {
padding: 20px 16px;
}
.items-table th,
.items-table td {
padding: 8px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="header">
<h1>📦 New Purchase Order</h1>
<p>Order #{{po_number}}</p>
</div>
<!-- Content -->
<div class="content">
<div class="greeting">
<p>Dear {{supplier_name}},</p>
<p>We would like to place the following purchase order:</p>
</div>
<!-- Order Information -->
<div class="info-box">
<h3>Order Details</h3>
<div class="info-row">
<span class="info-label">PO Number:</span>
<span class="info-value">{{po_number}}</span>
</div>
<div class="info-row">
<span class="info-label">Order Date:</span>
<span class="info-value">{{order_date}}</span>
</div>
<div class="info-row">
<span class="info-label">Required Delivery:</span>
<span class="info-value">{{required_delivery_date}}</span>
</div>
<div class="info-row">
<span class="info-label">Delivery Address:</span>
<span class="info-value">{{delivery_address}}</span>
</div>
</div>
<!-- Order Items -->
<table class="items-table">
<thead>
<tr>
<th>Product</th>
<th class="text-right">Quantity</th>
<th class="text-right">Unit Price</th>
<th class="text-right">Total</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{item.product_name}}</td>
<td class="text-right">{{item.ordered_quantity}} {{item.unit_of_measure}}</td>
<td class="text-right">{{currency_symbol}}{{item.unit_price}}</td>
<td class="text-right">{{currency_symbol}}{{item.line_total}}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3" class="text-right">Total Amount:</td>
<td class="text-right">{{currency_symbol}}{{total_amount}}</td>
</tr>
</tfoot>
</table>
<!-- Call to Action -->
<div style="text-align: center;">
<p style="margin-bottom: 10px;">Please confirm receipt of this order:</p>
<a href="mailto:{{bakery_email}}?subject=RE: PO {{po_number}} - Confirmation" class="cta-button">
Confirm Order
</a>
</div>
<!-- Important Notes -->
{% if notes %}
<div class="notes">
<h4>⚠️ Important Notes</h4>
<p>{{notes}}</p>
</div>
{% endif %}
<!-- Payment & Delivery Instructions -->
<div class="info-box" style="margin-top: 30px;">
<h3>Payment & Delivery</h3>
<p style="margin: 0; font-size: 14px; color: #6B7280;">
• Payment Terms: {{payment_terms}}<br>
• Delivery Instructions: {{delivery_instructions}}<br>
• Contact Person: {{contact_person}}<br>
• Phone: {{contact_phone}}
</p>
</div>
<!-- Footer Message -->
<p style="margin-top: 30px; font-size: 14px; color: #6B7280;">
Thank you for your continued partnership. If you have any questions about this order,
please don't hesitate to contact us.
</p>
<p style="font-size: 14px; color: #6B7280;">
Best regards,<br>
<strong>{{bakery_name}}</strong>
</p>
</div>
<!-- Footer -->
<div class="footer">
<p><strong>{{bakery_name}}</strong></p>
<p>{{bakery_address}}</p>
<p>Phone: {{bakery_phone}} | Email: <a href="mailto:{{bakery_email}}">{{bakery_email}}</a></p>
<p style="margin-top: 16px; font-size: 11px; color: #9CA3AF;">
This is an automated email. Please do not reply directly to this message.
</p>
</div>
</div>
</body>
</html>