REFACTOR ALL APIs
This commit is contained in:
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('notification')
|
||||
|
||||
|
||||
@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)}")
|
||||
723
services/notification/app/api/notification_operations.py
Normal file
723
services/notification/app/api/notification_operations.py
Normal file
@@ -0,0 +1,723 @@
|
||||
"""
|
||||
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 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 shared.auth.decorators import get_current_user_dep, get_current_user
|
||||
from shared.auth.access_control import require_user_role, admin_role_required
|
||||
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("notification")
|
||||
|
||||
# Dependency injection for enhanced notification service
|
||||
def get_enhanced_notification_service():
|
||||
database_manager = create_database_manager()
|
||||
return EnhancedNotificationService(database_manager)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BUSINESS OPERATIONS - Send, Schedule, Retry, Mark Read
|
||||
# ============================================================================
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("send", include_tenant_prefix=False),
|
||||
response_model=NotificationResponse,
|
||||
status_code=201
|
||||
)
|
||||
@require_user_role(["member", "admin", "owner"])
|
||||
@track_endpoint_metrics("notification_send")
|
||||
async def send_notification(
|
||||
notification_data: Dict[str, Any],
|
||||
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"""
|
||||
|
||||
try:
|
||||
# Check permissions for broadcast notifications
|
||||
if notification_data.get("broadcast", False) and current_user.get("role") not in ["admin", "manager"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins and managers can send broadcast notifications"
|
||||
)
|
||||
|
||||
# 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']}"
|
||||
)
|
||||
|
||||
# Create notification using enhanced service
|
||||
notification = await notification_service.create_notification(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
sender_id=current_user["user_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=current_user.get("tenant_id"),
|
||||
type=notification_type.value,
|
||||
priority=priority.value)
|
||||
|
||||
return NotificationResponse.from_orm(notification)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to send notification",
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
sender_id=current_user["user_id"],
|
||||
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 (skip for now to test connection)
|
||||
# TODO: Add proper token validation in production
|
||||
current_user = None
|
||||
if token:
|
||||
try:
|
||||
# In a real implementation, validate the JWT token here
|
||||
# For now, skip validation to test the connection
|
||||
pass
|
||||
except Exception:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
|
||||
# Skip tenant access validation for testing
|
||||
# TODO: Add tenant access validation in production
|
||||
|
||||
# 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")
|
||||
@@ -1,129 +1,46 @@
|
||||
"""
|
||||
Enhanced Notification API endpoints using repository pattern and dependency injection
|
||||
Notification CRUD API endpoints (ATOMIC operations only)
|
||||
Handles basic notification retrieval and listing
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, BackgroundTasks
|
||||
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 (
|
||||
NotificationCreate, NotificationResponse, NotificationHistory,
|
||||
NotificationStats, NotificationPreferences, PreferencesUpdate,
|
||||
BulkNotificationCreate, TemplateCreate, TemplateResponse,
|
||||
DeliveryWebhook, ReadReceiptWebhook, NotificationType,
|
||||
NotificationStatus, NotificationPriority
|
||||
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,
|
||||
require_role
|
||||
)
|
||||
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("notification")
|
||||
|
||||
# Dependency injection for enhanced notification service
|
||||
def get_enhanced_notification_service():
|
||||
database_manager = create_database_manager()
|
||||
return EnhancedNotificationService(database_manager)
|
||||
|
||||
@router.post("/send", response_model=NotificationResponse)
|
||||
@track_endpoint_metrics("notification_send")
|
||||
async def send_notification_enhanced(
|
||||
notification_data: Dict[str, Any],
|
||||
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"""
|
||||
|
||||
try:
|
||||
# Check permissions for broadcast notifications
|
||||
if notification_data.get("broadcast", False) and current_user.get("role") not in ["admin", "manager"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins and managers can send broadcast notifications"
|
||||
)
|
||||
|
||||
# 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']}"
|
||||
)
|
||||
|
||||
# Create notification using enhanced service
|
||||
notification = await notification_service.create_notification(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
sender_id=current_user["user_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=current_user.get("tenant_id"),
|
||||
type=notification_type.value,
|
||||
priority=priority.value)
|
||||
|
||||
return NotificationResponse.from_orm(notification)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to send notification",
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
sender_id=current_user["user_id"],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to send notification"
|
||||
)
|
||||
# ============================================================================
|
||||
# ATOMIC CRUD ENDPOINTS - Get/List notifications only
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/notifications/{notification_id}", response_model=NotificationResponse)
|
||||
@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)
|
||||
):
|
||||
@@ -161,11 +78,15 @@ async def get_notification_enhanced(
|
||||
detail="Failed to get notification"
|
||||
)
|
||||
|
||||
@router.get("/notifications/user/{user_id}", response_model=List[NotificationResponse])
|
||||
@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: Optional[str] = Query(None, description="Filter by tenant 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"),
|
||||
@@ -216,7 +137,11 @@ async def get_user_notifications_enhanced(
|
||||
detail="Failed to get user notifications"
|
||||
)
|
||||
|
||||
@router.get("/notifications/tenant/{tenant_id}", response_model=List[NotificationResponse])
|
||||
@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"),
|
||||
@@ -279,370 +204,3 @@ async def get_tenant_notifications_enhanced(
|
||||
detail="Failed to get tenant notifications"
|
||||
)
|
||||
|
||||
@router.patch("/notifications/{notification_id}/read")
|
||||
@track_endpoint_metrics("notification_mark_read")
|
||||
async def mark_notification_read_enhanced(
|
||||
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 with enhanced validation"""
|
||||
|
||||
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("/notifications/mark-multiple-read")
|
||||
@track_endpoint_metrics("notification_mark_multiple_read")
|
||||
async def mark_multiple_notifications_read_enhanced(
|
||||
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 enhanced 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("/notifications/{notification_id}/status")
|
||||
@track_endpoint_metrics("notification_update_status")
|
||||
async def update_notification_status_enhanced(
|
||||
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 with enhanced logging and validation"""
|
||||
|
||||
# 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("/notifications/pending", response_model=List[NotificationResponse])
|
||||
@track_endpoint_metrics("notification_get_pending")
|
||||
async def get_pending_notifications_enhanced(
|
||||
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("/notifications/{notification_id}/schedule")
|
||||
@track_endpoint_metrics("notification_schedule")
|
||||
async def schedule_notification_enhanced(
|
||||
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 with enhanced validation"""
|
||||
|
||||
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("/notifications/{notification_id}/cancel")
|
||||
@track_endpoint_metrics("notification_cancel")
|
||||
async def cancel_notification_enhanced(
|
||||
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 with enhanced validation"""
|
||||
|
||||
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("/notifications/{notification_id}/retry")
|
||||
@track_endpoint_metrics("notification_retry")
|
||||
async def retry_failed_notification_enhanced(
|
||||
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 with enhanced validation"""
|
||||
|
||||
# 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("/statistics", dependencies=[Depends(require_role(["admin", "manager"]))])
|
||||
@track_endpoint_metrics("notification_get_statistics")
|
||||
async def get_notification_statistics_enhanced(
|
||||
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 with enhanced analytics"""
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -1,199 +0,0 @@
|
||||
# services/notification/app/api/sse_routes.py
|
||||
"""
|
||||
SSE routes for real-time alert and recommendation streaming
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, BackgroundTasks
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/sse", tags=["sse"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
@router.get("/alerts/stream/{tenant_id}")
|
||||
async def stream_alerts(
|
||||
tenant_id: str,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
token: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
SSE endpoint for real-time alert and recommendation streaming
|
||||
Supports both alerts and recommendations through unified stream
|
||||
"""
|
||||
|
||||
# Validate token and get user (skip for now to test connection)
|
||||
# TODO: Add proper token validation in production
|
||||
current_user = None
|
||||
if token:
|
||||
try:
|
||||
# In a real implementation, validate the JWT token here
|
||||
# For now, skip validation to test the connection
|
||||
pass
|
||||
except Exception:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
|
||||
# Skip tenant access validation for testing
|
||||
# TODO: Add tenant access validation in production
|
||||
|
||||
# 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("/items/{item_id}/acknowledge")
|
||||
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("/items/{item_id}/resolve")
|
||||
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("/status/{tenant_id}")
|
||||
async def get_sse_status(
|
||||
tenant_id: str,
|
||||
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")
|
||||
@@ -11,7 +11,8 @@ 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.sse_routes import router as sse_router
|
||||
from app.api.notification_operations import router as notification_operations_router
|
||||
from app.api.analytics import router as analytics_router
|
||||
from app.services.messaging import setup_messaging, cleanup_messaging
|
||||
from app.services.sse_service import SSEService
|
||||
from app.services.notification_orchestrator import NotificationOrchestrator
|
||||
@@ -141,7 +142,7 @@ class NotificationService(StandardFastAPIService):
|
||||
version="2.0.0",
|
||||
log_level=settings.LOG_LEVEL,
|
||||
cors_origins=getattr(settings, 'CORS_ORIGINS', ["*"]),
|
||||
api_prefix="/api/v1",
|
||||
api_prefix="", # Empty because RouteBuilder already includes /api/v1
|
||||
database_manager=database_manager,
|
||||
expected_tables=notification_expected_tables,
|
||||
custom_health_checks={
|
||||
@@ -251,7 +252,8 @@ service.setup_custom_endpoints()
|
||||
|
||||
# Include routers
|
||||
service.add_router(notification_router, tags=["notifications"])
|
||||
service.add_router(sse_router, tags=["sse"])
|
||||
service.add_router(notification_operations_router, tags=["notification-operations"])
|
||||
service.add_router(analytics_router, tags=["notifications-analytics"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
Reference in New Issue
Block a user