Initial commit - production deployment

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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