Initial commit - production deployment
This commit is contained in:
0
services/notification/app/__init__.py
Normal file
0
services/notification/app/__init__.py
Normal file
8
services/notification/app/api/__init__.py
Normal file
8
services/notification/app/api/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Notification API Package
|
||||
API endpoints for notification management
|
||||
"""
|
||||
|
||||
from . import notifications
|
||||
|
||||
__all__ = ["notifications"]
|
||||
286
services/notification/app/api/analytics.py
Normal file
286
services/notification/app/api/analytics.py
Normal 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)}")
|
||||
237
services/notification/app/api/audit.py
Normal file
237
services/notification/app/api/audit.py
Normal 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)}"
|
||||
)
|
||||
910
services/notification/app/api/notification_operations.py
Normal file
910
services/notification/app/api/notification_operations.py
Normal 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)}"
|
||||
)
|
||||
210
services/notification/app/api/notifications.py
Normal file
210
services/notification/app/api/notifications.py
Normal 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"
|
||||
)
|
||||
|
||||
404
services/notification/app/api/whatsapp_webhooks.py
Normal file
404
services/notification/app/api/whatsapp_webhooks.py
Normal 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()
|
||||
}
|
||||
6
services/notification/app/consumers/__init__.py
Normal file
6
services/notification/app/consumers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Event consumers for notification service
|
||||
"""
|
||||
from .po_event_consumer import POEventConsumer
|
||||
|
||||
__all__ = ["POEventConsumer"]
|
||||
395
services/notification/app/consumers/po_event_consumer.py
Normal file
395
services/notification/app/consumers/po_event_consumer.py
Normal 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
|
||||
0
services/notification/app/core/__init__.py
Normal file
0
services/notification/app/core/__init__.py
Normal file
104
services/notification/app/core/config.py
Normal file
104
services/notification/app/core/config.py
Normal 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()
|
||||
430
services/notification/app/core/database.py
Normal file
430
services/notification/app/core/database.py
Normal 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
|
||||
315
services/notification/app/main.py
Normal file
315
services/notification/app/main.py
Normal 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)
|
||||
49
services/notification/app/models/__init__.py
Normal file
49
services/notification/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
184
services/notification/app/models/notifications.py
Normal file
184
services/notification/app/models/notifications.py
Normal 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)
|
||||
84
services/notification/app/models/templates.py
Normal file
84
services/notification/app/models/templates.py
Normal 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)
|
||||
135
services/notification/app/models/whatsapp_messages.py
Normal file
135
services/notification/app/models/whatsapp_messages.py
Normal 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)
|
||||
18
services/notification/app/repositories/__init__.py
Normal file
18
services/notification/app/repositories/__init__.py
Normal 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"
|
||||
]
|
||||
265
services/notification/app/repositories/base.py
Normal file
265
services/notification/app/repositories/base.py
Normal 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
|
||||
}
|
||||
470
services/notification/app/repositories/log_repository.py
Normal file
470
services/notification/app/repositories/log_repository.py
Normal 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)
|
||||
}
|
||||
@@ -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)}")
|
||||
474
services/notification/app/repositories/preference_repository.py
Normal file
474
services/notification/app/repositories/preference_repository.py
Normal 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}")
|
||||
450
services/notification/app/repositories/template_repository.py
Normal file
450
services/notification/app/repositories/template_repository.py
Normal 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 []
|
||||
@@ -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)
|
||||
)
|
||||
0
services/notification/app/schemas/__init__.py
Normal file
0
services/notification/app/schemas/__init__.py
Normal file
291
services/notification/app/schemas/notifications.py
Normal file
291
services/notification/app/schemas/notifications.py
Normal 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
|
||||
370
services/notification/app/schemas/whatsapp.py
Normal file
370
services/notification/app/schemas/whatsapp.py
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
services/notification/app/services/__init__.py
Normal file
15
services/notification/app/services/__init__.py
Normal 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"
|
||||
]
|
||||
559
services/notification/app/services/email_service.py
Normal file
559
services/notification/app/services/email_service.py
Normal 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)
|
||||
279
services/notification/app/services/notification_orchestrator.py
Normal file
279
services/notification/app/services/notification_orchestrator.py
Normal 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
|
||||
696
services/notification/app/services/notification_service.py
Normal file
696
services/notification/app/services/notification_service.py
Normal 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
|
||||
277
services/notification/app/services/sse_service.py
Normal file
277
services/notification/app/services/sse_service.py
Normal 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
|
||||
}
|
||||
248
services/notification/app/services/tenant_deletion_service.py
Normal file
248
services/notification/app/services/tenant_deletion_service.py
Normal 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)
|
||||
560
services/notification/app/services/whatsapp_business_service.py
Normal file
560
services/notification/app/services/whatsapp_business_service.py
Normal 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
|
||||
256
services/notification/app/services/whatsapp_service.py
Normal file
256
services/notification/app/services/whatsapp_service.py
Normal 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)
|
||||
165
services/notification/app/templates/equipment_failure_email.html
Normal file
165
services/notification/app/templates/equipment_failure_email.html
Normal 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>
|
||||
@@ -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>
|
||||
297
services/notification/app/templates/po_approved_email.html
Normal file
297
services/notification/app/templates/po_approved_email.html
Normal 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>
|
||||
Reference in New Issue
Block a user