Initial commit - production deployment
This commit is contained in:
8
services/notification/app/api/__init__.py
Normal file
8
services/notification/app/api/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Notification API Package
|
||||
API endpoints for notification management
|
||||
"""
|
||||
|
||||
from . import notifications
|
||||
|
||||
__all__ = ["notifications"]
|
||||
286
services/notification/app/api/analytics.py
Normal file
286
services/notification/app/api/analytics.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Notification Service Analytics API Endpoints
|
||||
Professional/Enterprise tier analytics for notification performance and delivery metrics
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
|
||||
from app.core.database import get_db
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import analytics_tier_required
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
|
||||
router = APIRouter()
|
||||
logger = structlog.get_logger()
|
||||
route_builder = RouteBuilder('notifications')
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("delivery-stats"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_delivery_statistics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
notification_type: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Analyze notification delivery statistics (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"period_days": days,
|
||||
"notification_type": notification_type,
|
||||
"total_sent": 0,
|
||||
"delivery_stats": {
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"pending": 0,
|
||||
"cancelled": 0,
|
||||
"delivery_rate": 0.0
|
||||
},
|
||||
"channel_breakdown": {
|
||||
"email": {"sent": 0, "delivered": 0, "failed": 0},
|
||||
"whatsapp": {"sent": 0, "delivered": 0, "failed": 0},
|
||||
"push": {"sent": 0, "delivered": 0, "failed": 0}
|
||||
},
|
||||
"daily_trends": [],
|
||||
"average_delivery_time_seconds": 0.0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get delivery stats", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get delivery stats: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("engagement-metrics"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_engagement_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Analyze user engagement with notifications (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"period_days": days,
|
||||
"engagement_stats": {
|
||||
"total_notifications": 0,
|
||||
"read_notifications": 0,
|
||||
"unread_notifications": 0,
|
||||
"read_rate": 0.0,
|
||||
"average_time_to_read_minutes": 0.0
|
||||
},
|
||||
"user_engagement": {
|
||||
"active_users": 0,
|
||||
"inactive_users": 0,
|
||||
"highly_engaged_users": 0
|
||||
},
|
||||
"type_engagement": [],
|
||||
"hourly_engagement_pattern": [],
|
||||
"day_of_week_pattern": []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get engagement metrics", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get engagement metrics: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("failure-analysis"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_failure_analysis(
|
||||
tenant_id: UUID = Path(...),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
channel: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Analyze notification failures and errors (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"period_days": days,
|
||||
"channel": channel,
|
||||
"total_failures": 0,
|
||||
"failure_rate": 0.0,
|
||||
"failure_breakdown": {
|
||||
"invalid_recipient": 0,
|
||||
"service_error": 0,
|
||||
"network_error": 0,
|
||||
"rate_limit": 0,
|
||||
"authentication_error": 0,
|
||||
"other": 0
|
||||
},
|
||||
"common_errors": [],
|
||||
"error_trends": [],
|
||||
"retry_success_rate": 0.0,
|
||||
"recommendations": []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get failure analysis", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get failure analysis: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("template-performance"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_template_performance(
|
||||
tenant_id: UUID = Path(...),
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Analyze notification template performance (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"period_days": days,
|
||||
"templates": [],
|
||||
"performance_metrics": {
|
||||
"best_performing_template": None,
|
||||
"worst_performing_template": None,
|
||||
"average_delivery_rate": 0.0,
|
||||
"average_read_rate": 0.0
|
||||
},
|
||||
"optimization_suggestions": []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get template performance", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get template performance: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("channel-comparison"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_channel_comparison(
|
||||
tenant_id: UUID = Path(...),
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Compare performance across notification channels (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"period_days": days,
|
||||
"channels": {
|
||||
"email": {
|
||||
"sent": 0,
|
||||
"delivered": 0,
|
||||
"read": 0,
|
||||
"failed": 0,
|
||||
"delivery_rate": 0.0,
|
||||
"read_rate": 0.0,
|
||||
"avg_delivery_time_seconds": 0.0,
|
||||
"cost_per_notification": 0.0
|
||||
},
|
||||
"whatsapp": {
|
||||
"sent": 0,
|
||||
"delivered": 0,
|
||||
"read": 0,
|
||||
"failed": 0,
|
||||
"delivery_rate": 0.0,
|
||||
"read_rate": 0.0,
|
||||
"avg_delivery_time_seconds": 0.0,
|
||||
"cost_per_notification": 0.0
|
||||
},
|
||||
"push": {
|
||||
"sent": 0,
|
||||
"delivered": 0,
|
||||
"read": 0,
|
||||
"failed": 0,
|
||||
"delivery_rate": 0.0,
|
||||
"read_rate": 0.0,
|
||||
"avg_delivery_time_seconds": 0.0,
|
||||
"cost_per_notification": 0.0
|
||||
}
|
||||
},
|
||||
"recommendations": {
|
||||
"most_reliable_channel": None,
|
||||
"fastest_channel": None,
|
||||
"most_cost_effective_channel": None,
|
||||
"best_engagement_channel": None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get channel comparison", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get channel comparison: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("user-preferences-insights"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_user_preferences_insights(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Analyze user notification preferences patterns (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"total_users": 0,
|
||||
"preference_distribution": {
|
||||
"email_enabled": 0,
|
||||
"whatsapp_enabled": 0,
|
||||
"push_enabled": 0,
|
||||
"all_channels_enabled": 0,
|
||||
"no_channels_enabled": 0
|
||||
},
|
||||
"popular_notification_types": [],
|
||||
"opt_out_trends": [],
|
||||
"channel_preferences": {
|
||||
"email_only": 0,
|
||||
"whatsapp_only": 0,
|
||||
"push_only": 0,
|
||||
"multi_channel": 0
|
||||
},
|
||||
"recommendations": []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get preferences insights", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get preferences insights: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("cost-analysis"),
|
||||
response_model=dict
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_cost_analysis(
|
||||
tenant_id: UUID = Path(...),
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db=Depends(get_db)
|
||||
):
|
||||
"""Analyze notification delivery costs (Professional/Enterprise)"""
|
||||
try:
|
||||
return {
|
||||
"period_days": days,
|
||||
"total_cost": 0.0,
|
||||
"cost_by_channel": {
|
||||
"email": 0.0,
|
||||
"whatsapp": 0.0,
|
||||
"push": 0.0
|
||||
},
|
||||
"cost_by_type": [],
|
||||
"volume_vs_cost_trends": [],
|
||||
"cost_optimization_suggestions": [],
|
||||
"projected_monthly_cost": 0.0,
|
||||
"cost_per_user": 0.0
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get cost analysis", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get cost analysis: {str(e)}")
|
||||
237
services/notification/app/api/audit.py
Normal file
237
services/notification/app/api/audit.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# services/notification/app/api/audit.py
|
||||
"""
|
||||
Audit Logs API - Retrieve audit trail for notification service
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AuditLog
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.models.audit_log_schemas import (
|
||||
AuditLogResponse,
|
||||
AuditLogListResponse,
|
||||
AuditLogStatsResponse
|
||||
)
|
||||
from app.core.database import database_manager
|
||||
|
||||
route_builder = RouteBuilder('notifications')
|
||||
router = APIRouter(tags=["audit-logs"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""Database session dependency"""
|
||||
async with database_manager.get_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("audit-logs"),
|
||||
response_model=AuditLogListResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_audit_logs(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
|
||||
user_id: Optional[UUID] = Query(None, description="Filter by user ID"),
|
||||
action: Optional[str] = Query(None, description="Filter by action type"),
|
||||
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity level"),
|
||||
search: Optional[str] = Query(None, description="Search in description field"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get audit logs for notification service.
|
||||
Requires admin or owner role.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Retrieving audit logs",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id"),
|
||||
filters={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"action": action,
|
||||
"resource_type": resource_type,
|
||||
"severity": severity
|
||||
}
|
||||
)
|
||||
|
||||
# Build query filters
|
||||
filters = [AuditLog.tenant_id == tenant_id]
|
||||
|
||||
if start_date:
|
||||
filters.append(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(AuditLog.created_at <= end_date)
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
filters.append(AuditLog.action == action)
|
||||
if resource_type:
|
||||
filters.append(AuditLog.resource_type == resource_type)
|
||||
if severity:
|
||||
filters.append(AuditLog.severity == severity)
|
||||
if search:
|
||||
filters.append(AuditLog.description.ilike(f"%{search}%"))
|
||||
|
||||
# Count total matching records
|
||||
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Fetch paginated results
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(and_(*filters))
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
audit_logs = result.scalars().all()
|
||||
|
||||
# Convert to response models
|
||||
items = [AuditLogResponse.from_orm(log) for log in audit_logs]
|
||||
|
||||
logger.info(
|
||||
"Successfully retrieved audit logs",
|
||||
tenant_id=tenant_id,
|
||||
total=total,
|
||||
returned=len(items)
|
||||
)
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(items)) < total
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve audit logs",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve audit logs: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("audit-logs/stats"),
|
||||
response_model=AuditLogStatsResponse
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def get_audit_log_stats(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Filter logs from this date"),
|
||||
end_date: Optional[datetime] = Query(None, description="Filter logs until this date"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get audit log statistics for notification service.
|
||||
Requires admin or owner role.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"Retrieving audit log statistics",
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user.get("user_id")
|
||||
)
|
||||
|
||||
# Build base filters
|
||||
filters = [AuditLog.tenant_id == tenant_id]
|
||||
if start_date:
|
||||
filters.append(AuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(AuditLog.created_at <= end_date)
|
||||
|
||||
# Total events
|
||||
count_query = select(func.count()).select_from(AuditLog).where(and_(*filters))
|
||||
total_result = await db.execute(count_query)
|
||||
total_events = total_result.scalar() or 0
|
||||
|
||||
# Events by action
|
||||
action_query = (
|
||||
select(AuditLog.action, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.action)
|
||||
)
|
||||
action_result = await db.execute(action_query)
|
||||
events_by_action = {row.action: row.count for row in action_result}
|
||||
|
||||
# Events by severity
|
||||
severity_query = (
|
||||
select(AuditLog.severity, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.severity)
|
||||
)
|
||||
severity_result = await db.execute(severity_query)
|
||||
events_by_severity = {row.severity: row.count for row in severity_result}
|
||||
|
||||
# Events by resource type
|
||||
resource_query = (
|
||||
select(AuditLog.resource_type, func.count().label('count'))
|
||||
.where(and_(*filters))
|
||||
.group_by(AuditLog.resource_type)
|
||||
)
|
||||
resource_result = await db.execute(resource_query)
|
||||
events_by_resource_type = {row.resource_type: row.count for row in resource_result}
|
||||
|
||||
# Date range
|
||||
date_range_query = (
|
||||
select(
|
||||
func.min(AuditLog.created_at).label('min_date'),
|
||||
func.max(AuditLog.created_at).label('max_date')
|
||||
)
|
||||
.where(and_(*filters))
|
||||
)
|
||||
date_result = await db.execute(date_range_query)
|
||||
date_row = date_result.one()
|
||||
|
||||
logger.info(
|
||||
"Successfully retrieved audit log statistics",
|
||||
tenant_id=tenant_id,
|
||||
total_events=total_events
|
||||
)
|
||||
|
||||
return AuditLogStatsResponse(
|
||||
total_events=total_events,
|
||||
events_by_action=events_by_action,
|
||||
events_by_severity=events_by_severity,
|
||||
events_by_resource_type=events_by_resource_type,
|
||||
date_range={
|
||||
"min": date_row.min_date,
|
||||
"max": date_row.max_date
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to retrieve audit log statistics",
|
||||
error=str(e),
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve audit log statistics: {str(e)}"
|
||||
)
|
||||
910
services/notification/app/api/notification_operations.py
Normal file
910
services/notification/app/api/notification_operations.py
Normal file
@@ -0,0 +1,910 @@
|
||||
"""
|
||||
Notification Business Operations API
|
||||
Handles sending, marking read, scheduling, retry, and SSE streaming
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path, Request, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.schemas.notifications import (
|
||||
NotificationResponse, NotificationType, NotificationStatus, NotificationPriority
|
||||
)
|
||||
from app.services.notification_service import EnhancedNotificationService
|
||||
from app.models.notifications import NotificationType as ModelNotificationType
|
||||
from app.models import AuditLog
|
||||
from app.core.database import get_db
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_user
|
||||
from shared.auth.access_control import require_user_role, admin_role_required, service_only_access
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
logger = structlog.get_logger()
|
||||
audit_logger = create_audit_logger("notification-service", AuditLog)
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder('notifications')
|
||||
|
||||
# Dependency injection for enhanced notification service
|
||||
def get_enhanced_notification_service():
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "notification")
|
||||
return EnhancedNotificationService(database_manager)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BUSINESS OPERATIONS - Send, Schedule, Retry, Mark Read
|
||||
# ============================================================================
|
||||
|
||||
@router.post(
|
||||
route_builder.build_base_route("send"),
|
||||
response_model=NotificationResponse,
|
||||
status_code=201
|
||||
)
|
||||
@track_endpoint_metrics("notification_send")
|
||||
async def send_notification(
|
||||
notification_data: Dict[str, Any],
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Send a single notification with enhanced validation and features - allows service-to-service calls"""
|
||||
|
||||
try:
|
||||
# Allow service-to-service calls (skip role check for service tokens)
|
||||
is_service_call = current_user.get("type") == "service"
|
||||
|
||||
if not is_service_call:
|
||||
# Check user role for non-service calls
|
||||
user_role = current_user.get("role", "").lower()
|
||||
if user_role not in ["member", "admin", "owner"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
|
||||
# Check permissions for broadcast notifications (Admin+ only)
|
||||
if notification_data.get("broadcast", False) and not is_service_call:
|
||||
user_role = current_user.get("role", "").lower()
|
||||
if user_role not in ["admin", "owner"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins and owners can send broadcast notifications"
|
||||
)
|
||||
|
||||
# Log HIGH severity audit event for broadcast notifications
|
||||
try:
|
||||
# Note: db session would need to be passed as dependency for full audit logging
|
||||
logger.info("Broadcast notification initiated",
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user["user_id"],
|
||||
notification_type=notification_data.get("type"),
|
||||
severity="HIGH")
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
# Validate required fields
|
||||
if not notification_data.get("message"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Message is required"
|
||||
)
|
||||
|
||||
if not notification_data.get("type"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Notification type is required"
|
||||
)
|
||||
|
||||
# Convert string type to enum
|
||||
try:
|
||||
notification_type = ModelNotificationType(notification_data["type"])
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid notification type: {notification_data['type']}"
|
||||
)
|
||||
|
||||
# Convert priority if provided
|
||||
priority = NotificationPriority.NORMAL
|
||||
if "priority" in notification_data:
|
||||
try:
|
||||
priority = NotificationPriority(notification_data["priority"])
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid priority: {notification_data['priority']}"
|
||||
)
|
||||
|
||||
# Use tenant_id from path parameter (especially for service calls)
|
||||
effective_tenant_id = str(tenant_id) if is_service_call else current_user.get("tenant_id")
|
||||
effective_sender_id = current_user.get("user_id", "system")
|
||||
|
||||
# Create notification using enhanced service
|
||||
notification = await notification_service.create_notification(
|
||||
tenant_id=effective_tenant_id,
|
||||
sender_id=effective_sender_id,
|
||||
notification_type=notification_type,
|
||||
message=notification_data["message"],
|
||||
recipient_id=notification_data.get("recipient_id"),
|
||||
recipient_email=notification_data.get("recipient_email"),
|
||||
recipient_phone=notification_data.get("recipient_phone"),
|
||||
subject=notification_data.get("subject"),
|
||||
html_content=notification_data.get("html_content"),
|
||||
template_key=notification_data.get("template_key"),
|
||||
template_data=notification_data.get("template_data"),
|
||||
priority=priority,
|
||||
scheduled_at=notification_data.get("scheduled_at"),
|
||||
broadcast=notification_data.get("broadcast", False)
|
||||
)
|
||||
|
||||
logger.info("Notification sent successfully",
|
||||
notification_id=notification.id,
|
||||
tenant_id=effective_tenant_id,
|
||||
type=notification_type.value,
|
||||
priority=priority.value,
|
||||
is_service_call=is_service_call)
|
||||
|
||||
return NotificationResponse.from_orm(notification)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
effective_tenant_id = str(tenant_id) if current_user.get("type") == "service" else current_user.get("tenant_id")
|
||||
logger.error("Failed to send notification",
|
||||
tenant_id=effective_tenant_id,
|
||||
sender_id=current_user.get("user_id", "system"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to send notification"
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
route_builder.build_operations_route("{notification_id}/read", include_tenant_prefix=False)
|
||||
)
|
||||
@require_user_role(["viewer", "member", "admin", "owner"])
|
||||
@track_endpoint_metrics("notification_mark_read")
|
||||
async def mark_notification_read(
|
||||
notification_id: UUID = Path(..., description="Notification ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Mark a notification as read"""
|
||||
|
||||
try:
|
||||
success = await notification_service.mark_notification_as_read(
|
||||
str(notification_id),
|
||||
current_user["user_id"]
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found or access denied"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Notification marked as read"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark notification as read",
|
||||
notification_id=str(notification_id),
|
||||
user_id=current_user["user_id"],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to mark notification as read"
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
route_builder.build_base_route("mark-multiple-read", include_tenant_prefix=False)
|
||||
)
|
||||
@require_user_role(["viewer", "member", "admin", "owner"])
|
||||
@track_endpoint_metrics("notification_mark_multiple_read")
|
||||
async def mark_multiple_notifications_read(
|
||||
request_data: Dict[str, Any],
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Mark multiple notifications as read with batch processing"""
|
||||
|
||||
try:
|
||||
notification_ids = request_data.get("notification_ids")
|
||||
tenant_id = request_data.get("tenant_id")
|
||||
|
||||
if not notification_ids and not tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Either notification_ids or tenant_id must be provided"
|
||||
)
|
||||
|
||||
# Convert UUID strings to strings if needed
|
||||
if notification_ids:
|
||||
notification_ids = [str(nid) for nid in notification_ids]
|
||||
|
||||
marked_count = await notification_service.mark_multiple_as_read(
|
||||
user_id=current_user["user_id"],
|
||||
notification_ids=notification_ids,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"marked_count": marked_count,
|
||||
"message": f"Marked {marked_count} notifications as read"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to mark multiple notifications as read",
|
||||
user_id=current_user["user_id"],
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to mark notifications as read"
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
route_builder.build_operations_route("{notification_id}/status", include_tenant_prefix=False)
|
||||
)
|
||||
@require_user_role(["admin", "owner"])
|
||||
@track_endpoint_metrics("notification_update_status")
|
||||
async def update_notification_status(
|
||||
notification_id: UUID = Path(..., description="Notification ID"),
|
||||
status_data: Dict[str, Any] = ...,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Update notification status (admin/system only)"""
|
||||
|
||||
# Only system users or admins can update notification status
|
||||
if (current_user.get("type") != "service" and
|
||||
current_user.get("role") not in ["admin", "system"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only system services or admins can update notification status"
|
||||
)
|
||||
|
||||
try:
|
||||
new_status = status_data.get("status")
|
||||
if not new_status:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Status is required"
|
||||
)
|
||||
|
||||
# Convert string status to enum
|
||||
try:
|
||||
from app.models.notifications import NotificationStatus as ModelStatus
|
||||
model_status = ModelStatus(new_status)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid status: {new_status}"
|
||||
)
|
||||
|
||||
updated_notification = await notification_service.update_notification_status(
|
||||
notification_id=str(notification_id),
|
||||
new_status=model_status,
|
||||
error_message=status_data.get("error_message"),
|
||||
provider_message_id=status_data.get("provider_message_id"),
|
||||
metadata=status_data.get("metadata"),
|
||||
response_time_ms=status_data.get("response_time_ms"),
|
||||
provider=status_data.get("provider")
|
||||
)
|
||||
|
||||
if not updated_notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
return NotificationResponse.from_orm(updated_notification)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update notification status",
|
||||
notification_id=str(notification_id),
|
||||
status=status_data.get("status"),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update notification status"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("pending", include_tenant_prefix=False),
|
||||
response_model=List[NotificationResponse]
|
||||
)
|
||||
@require_user_role(["admin", "owner"])
|
||||
@track_endpoint_metrics("notification_get_pending")
|
||||
async def get_pending_notifications(
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of notifications"),
|
||||
notification_type: Optional[NotificationType] = Query(None, description="Filter by type"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Get pending notifications for processing (system/admin only)"""
|
||||
|
||||
if (current_user.get("type") != "service" and
|
||||
current_user.get("role") not in ["admin", "system"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only system services or admins can access pending notifications"
|
||||
)
|
||||
|
||||
try:
|
||||
model_notification_type = None
|
||||
if notification_type:
|
||||
try:
|
||||
model_notification_type = ModelNotificationType(notification_type.value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid notification type: {notification_type.value}"
|
||||
)
|
||||
|
||||
notifications = await notification_service.get_pending_notifications(
|
||||
limit=limit,
|
||||
notification_type=model_notification_type
|
||||
)
|
||||
|
||||
return [NotificationResponse.from_orm(notification) for notification in notifications]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get pending notifications",
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get pending notifications"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("{notification_id}/schedule", include_tenant_prefix=False)
|
||||
)
|
||||
@require_user_role(["member", "admin", "owner"])
|
||||
@track_endpoint_metrics("notification_schedule")
|
||||
async def schedule_notification(
|
||||
notification_id: UUID = Path(..., description="Notification ID"),
|
||||
schedule_data: Dict[str, Any] = ...,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Schedule a notification for future delivery"""
|
||||
|
||||
try:
|
||||
scheduled_at = schedule_data.get("scheduled_at")
|
||||
if not scheduled_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="scheduled_at is required"
|
||||
)
|
||||
|
||||
# Parse datetime if it's a string
|
||||
if isinstance(scheduled_at, str):
|
||||
try:
|
||||
scheduled_at = datetime.fromisoformat(scheduled_at.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid datetime format. Use ISO format."
|
||||
)
|
||||
|
||||
# Check that the scheduled time is in the future
|
||||
if scheduled_at <= datetime.utcnow():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Scheduled time must be in the future"
|
||||
)
|
||||
|
||||
success = await notification_service.schedule_notification(
|
||||
str(notification_id),
|
||||
scheduled_at
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found or cannot be scheduled"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Notification scheduled successfully",
|
||||
"scheduled_at": scheduled_at.isoformat()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to schedule notification",
|
||||
notification_id=str(notification_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to schedule notification"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("{notification_id}/cancel", include_tenant_prefix=False)
|
||||
)
|
||||
@require_user_role(["member", "admin", "owner"])
|
||||
@track_endpoint_metrics("notification_cancel")
|
||||
async def cancel_notification(
|
||||
notification_id: UUID = Path(..., description="Notification ID"),
|
||||
cancel_data: Optional[Dict[str, Any]] = None,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Cancel a pending notification"""
|
||||
|
||||
try:
|
||||
reason = None
|
||||
if cancel_data:
|
||||
reason = cancel_data.get("reason", "Cancelled by user")
|
||||
else:
|
||||
reason = "Cancelled by user"
|
||||
|
||||
success = await notification_service.cancel_notification(
|
||||
str(notification_id),
|
||||
reason
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found or cannot be cancelled"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Notification cancelled successfully",
|
||||
"reason": reason
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to cancel notification",
|
||||
notification_id=str(notification_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to cancel notification"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("{notification_id}/retry", include_tenant_prefix=False)
|
||||
)
|
||||
@require_user_role(["admin", "owner"])
|
||||
@track_endpoint_metrics("notification_retry")
|
||||
async def retry_failed_notification(
|
||||
notification_id: UUID = Path(..., description="Notification ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Retry a failed notification (admin only)"""
|
||||
|
||||
# Only admins can retry notifications
|
||||
if current_user.get("role") not in ["admin", "system"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can retry failed notifications"
|
||||
)
|
||||
|
||||
try:
|
||||
success = await notification_service.retry_failed_notification(str(notification_id))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found, not failed, or max retries exceeded"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Notification queued for retry"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to retry notification",
|
||||
notification_id=str(notification_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retry notification"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("statistics", include_tenant_prefix=False)
|
||||
)
|
||||
@admin_role_required
|
||||
@track_endpoint_metrics("notification_get_statistics")
|
||||
async def get_notification_statistics(
|
||||
tenant_id: Optional[str] = Query(None, description="Filter by tenant ID"),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to look back"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Get comprehensive notification statistics"""
|
||||
|
||||
try:
|
||||
stats = await notification_service.get_notification_statistics(
|
||||
tenant_id=tenant_id,
|
||||
days_back=days_back
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get notification statistics",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get notification statistics"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SSE STREAMING ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get(route_builder.build_operations_route("sse/stream/{tenant_id}", include_tenant_prefix=False))
|
||||
async def stream_notifications(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
token: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
SSE endpoint for real-time notification streaming
|
||||
Supports alerts and recommendations through unified stream
|
||||
"""
|
||||
|
||||
# Validate token and get user
|
||||
current_user = None
|
||||
if token:
|
||||
try:
|
||||
from shared.auth.jwt_handler import JWTHandler
|
||||
from app.core.config import settings
|
||||
|
||||
jwt_handler = JWTHandler(settings.JWT_SECRET_KEY)
|
||||
payload = jwt_handler.decode_access_token(token)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
|
||||
current_user = payload
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Token validation failed", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication token required"
|
||||
)
|
||||
|
||||
# Validate tenant access
|
||||
user_tenant_id = current_user.get('tenant_id')
|
||||
if user_tenant_id and str(user_tenant_id) != str(tenant_id):
|
||||
logger.warning("Tenant access denied",
|
||||
user_tenant_id=user_tenant_id,
|
||||
requested_tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this tenant's notifications"
|
||||
)
|
||||
|
||||
# Get SSE service from app state
|
||||
sse_service = getattr(request.app.state, 'sse_service', None)
|
||||
if not sse_service:
|
||||
raise HTTPException(500, "SSE service not available")
|
||||
|
||||
async def event_generator():
|
||||
"""Generate SSE events for the client"""
|
||||
client_queue = asyncio.Queue(maxsize=100) # Limit queue size
|
||||
|
||||
try:
|
||||
# Register client
|
||||
await sse_service.add_client(tenant_id, client_queue)
|
||||
|
||||
logger.info("SSE client connected",
|
||||
tenant_id=tenant_id,
|
||||
user_id=getattr(current_user, 'id', 'unknown'))
|
||||
|
||||
# Stream events
|
||||
while True:
|
||||
# Check if client disconnected
|
||||
if await request.is_disconnected():
|
||||
logger.info("SSE client disconnected", tenant_id=tenant_id)
|
||||
break
|
||||
|
||||
try:
|
||||
# Wait for events with timeout for keepalive
|
||||
event = await asyncio.wait_for(
|
||||
client_queue.get(),
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
yield event
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send keepalive ping
|
||||
yield {
|
||||
"event": "ping",
|
||||
"data": json.dumps({
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"status": "keepalive"
|
||||
}),
|
||||
"id": f"ping_{int(datetime.now().timestamp())}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error in SSE event generator",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SSE connection error",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
finally:
|
||||
# Clean up on disconnect
|
||||
try:
|
||||
await sse_service.remove_client(tenant_id, client_queue)
|
||||
logger.info("SSE client cleanup completed", tenant_id=tenant_id)
|
||||
except Exception as e:
|
||||
logger.error("Error cleaning up SSE client",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
|
||||
return EventSourceResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(route_builder.build_operations_route("sse/items/{item_id}/acknowledge", include_tenant_prefix=False))
|
||||
async def acknowledge_item(
|
||||
item_id: str,
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""Acknowledge an alert or recommendation"""
|
||||
try:
|
||||
# This would update the database
|
||||
# For now, just return success
|
||||
|
||||
logger.info("Item acknowledged",
|
||||
item_id=item_id,
|
||||
user_id=getattr(current_user, 'id', 'unknown'))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"item_id": item_id,
|
||||
"acknowledged_by": getattr(current_user, 'id', 'unknown'),
|
||||
"acknowledged_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to acknowledge item", item_id=item_id, error=str(e))
|
||||
raise HTTPException(500, "Failed to acknowledge item")
|
||||
|
||||
|
||||
@router.post(route_builder.build_operations_route("sse/items/{item_id}/resolve", include_tenant_prefix=False))
|
||||
async def resolve_item(
|
||||
item_id: str,
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""Resolve an alert or recommendation"""
|
||||
try:
|
||||
# This would update the database
|
||||
# For now, just return success
|
||||
|
||||
logger.info("Item resolved",
|
||||
item_id=item_id,
|
||||
user_id=getattr(current_user, 'id', 'unknown'))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"item_id": item_id,
|
||||
"resolved_by": getattr(current_user, 'id', 'unknown'),
|
||||
"resolved_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to resolve item", item_id=item_id, error=str(e))
|
||||
raise HTTPException(500, "Failed to resolve item")
|
||||
|
||||
|
||||
@router.get(route_builder.build_operations_route("sse/status/{tenant_id}", include_tenant_prefix=False))
|
||||
async def get_sse_status(
|
||||
request: Request,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user = Depends(get_current_user)
|
||||
):
|
||||
"""Get SSE connection status for a tenant"""
|
||||
|
||||
# Verify user has access to this tenant
|
||||
if not hasattr(current_user, 'has_access_to_tenant') or not current_user.has_access_to_tenant(tenant_id):
|
||||
raise HTTPException(403, "Access denied to this tenant")
|
||||
|
||||
try:
|
||||
# Get SSE service from app state
|
||||
sse_service = getattr(request.app.state, 'sse_service', None)
|
||||
if not sse_service:
|
||||
return {"status": "unavailable", "message": "SSE service not initialized"}
|
||||
|
||||
metrics = sse_service.get_metrics()
|
||||
tenant_connections = len(sse_service.active_connections.get(tenant_id, set()))
|
||||
|
||||
return {
|
||||
"status": "available",
|
||||
"tenant_id": tenant_id,
|
||||
"connections": tenant_connections,
|
||||
"total_connections": metrics["total_connections"],
|
||||
"active_tenants": metrics["active_tenants"]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get SSE status", tenant_id=tenant_id, error=str(e))
|
||||
raise HTTPException(500, "Failed to get SSE status")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tenant Data Deletion Operations (Internal Service Only)
|
||||
# ============================================================================
|
||||
|
||||
@router.delete(
|
||||
route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False),
|
||||
response_model=dict
|
||||
)
|
||||
@service_only_access
|
||||
async def delete_tenant_data(
|
||||
tenant_id: str = Path(..., description="Tenant ID to delete data for"),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Delete all notification data for a tenant (Internal service only)
|
||||
|
||||
This endpoint is called by the orchestrator during tenant deletion.
|
||||
It permanently deletes all notification-related data including:
|
||||
- Notifications (all types and statuses)
|
||||
- Notification logs
|
||||
- User notification preferences
|
||||
- Tenant-specific notification templates
|
||||
- Audit logs
|
||||
|
||||
**NOTE**: System templates (is_system=True) are preserved
|
||||
|
||||
**WARNING**: This operation is irreversible!
|
||||
|
||||
Returns:
|
||||
Deletion summary with counts of deleted records
|
||||
"""
|
||||
from app.services.tenant_deletion_service import NotificationTenantDeletionService
|
||||
from app.core.config import settings
|
||||
|
||||
try:
|
||||
logger.info("notification.tenant_deletion.api_called", tenant_id=tenant_id)
|
||||
|
||||
db_manager = create_database_manager(settings.DATABASE_URL, "notification")
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
deletion_service = NotificationTenantDeletionService(session)
|
||||
result = await deletion_service.safe_delete_tenant_data(tenant_id)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Tenant data deletion failed: {', '.join(result.errors)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Tenant data deletion completed successfully",
|
||||
"note": "System templates have been preserved",
|
||||
"summary": result.to_dict()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("notification.tenant_deletion.api_error",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete tenant data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False),
|
||||
response_model=dict
|
||||
)
|
||||
@service_only_access
|
||||
async def preview_tenant_data_deletion(
|
||||
tenant_id: str = Path(..., description="Tenant ID to preview deletion for"),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""
|
||||
Preview what data would be deleted for a tenant (dry-run)
|
||||
|
||||
This endpoint shows counts of all data that would be deleted
|
||||
without actually deleting anything. Useful for:
|
||||
- Confirming deletion scope before execution
|
||||
- Auditing and compliance
|
||||
- Troubleshooting
|
||||
|
||||
Returns:
|
||||
Dictionary with entity names and their counts
|
||||
"""
|
||||
from app.services.tenant_deletion_service import NotificationTenantDeletionService
|
||||
from app.core.config import settings
|
||||
|
||||
try:
|
||||
logger.info("notification.tenant_deletion.preview_called", tenant_id=tenant_id)
|
||||
|
||||
db_manager = create_database_manager(settings.DATABASE_URL, "notification")
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
deletion_service = NotificationTenantDeletionService(session)
|
||||
preview = await deletion_service.get_tenant_data_preview(tenant_id)
|
||||
|
||||
total_records = sum(preview.values())
|
||||
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"service": "notification",
|
||||
"preview": preview,
|
||||
"total_records": total_records,
|
||||
"note": "System templates are not counted and will be preserved",
|
||||
"warning": "These records will be permanently deleted and cannot be recovered"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("notification.tenant_deletion.preview_error",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to preview tenant data deletion: {str(e)}"
|
||||
)
|
||||
210
services/notification/app/api/notifications.py
Normal file
210
services/notification/app/api/notifications.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Notification CRUD API endpoints (ATOMIC operations only)
|
||||
Handles basic notification retrieval and listing
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.notifications import (
|
||||
NotificationResponse, NotificationType, NotificationStatus
|
||||
)
|
||||
from app.services.notification_service import EnhancedNotificationService
|
||||
from app.models.notifications import NotificationType as ModelNotificationType
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.routing.route_builder import RouteBuilder
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.metrics import track_endpoint_metrics
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
route_builder = RouteBuilder('notifications')
|
||||
|
||||
# Dependency injection for enhanced notification service
|
||||
def get_enhanced_notification_service():
|
||||
from app.core.config import settings
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "notification")
|
||||
return EnhancedNotificationService(database_manager)
|
||||
|
||||
# ============================================================================
|
||||
# ATOMIC CRUD ENDPOINTS - Get/List notifications only
|
||||
# ============================================================================
|
||||
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("{notification_id}"),
|
||||
response_model=NotificationResponse
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@track_endpoint_metrics("notification_get")
|
||||
async def get_notification_enhanced(
|
||||
notification_id: UUID = Path(..., description="Notification ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Get a specific notification by ID with enhanced access control"""
|
||||
|
||||
try:
|
||||
notification = await notification_service.get_notification_by_id(str(notification_id))
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
# Verify user has access to this notification
|
||||
if (notification.recipient_id != current_user["user_id"] and
|
||||
notification.sender_id != current_user["user_id"] and
|
||||
not notification.broadcast and
|
||||
current_user.get("role") not in ["admin", "manager"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to notification"
|
||||
)
|
||||
|
||||
return NotificationResponse.from_orm(notification)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get notification",
|
||||
notification_id=str(notification_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get notification"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("user/{user_id}"),
|
||||
response_model=List[NotificationResponse]
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@track_endpoint_metrics("notification_get_user_notifications")
|
||||
async def get_user_notifications_enhanced(
|
||||
user_id: str = Path(..., description="User ID"),
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
unread_only: bool = Query(False, description="Only return unread notifications"),
|
||||
notification_type: Optional[NotificationType] = Query(None, description="Filter by notification type"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Get notifications for a user with enhanced filtering"""
|
||||
|
||||
# Users can only get their own notifications unless they're admin
|
||||
# Handle demo user ID mismatch: frontend uses "demo-user" but token has "demo-user-{session-id}"
|
||||
is_demo_user = current_user["user_id"].startswith("demo-user-") and user_id == "demo-user"
|
||||
|
||||
if user_id != current_user["user_id"] and not is_demo_user and current_user.get("role") not in ["admin", "manager"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only access your own notifications"
|
||||
)
|
||||
|
||||
try:
|
||||
# Convert string type to model enum if provided
|
||||
model_notification_type = None
|
||||
if notification_type:
|
||||
try:
|
||||
model_notification_type = ModelNotificationType(notification_type.value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid notification type: {notification_type.value}"
|
||||
)
|
||||
|
||||
notifications = await notification_service.get_user_notifications(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
unread_only=unread_only,
|
||||
notification_type=model_notification_type,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return [NotificationResponse.from_orm(notification) for notification in notifications]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get user notifications",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get user notifications"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("list"),
|
||||
response_model=List[NotificationResponse]
|
||||
)
|
||||
@require_user_role(["viewer", "member", "admin", "owner"])
|
||||
@track_endpoint_metrics("notification_get_tenant_notifications")
|
||||
async def get_tenant_notifications_enhanced(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
status_filter: Optional[NotificationStatus] = Query(None, description="Filter by status"),
|
||||
notification_type: Optional[NotificationType] = Query(None, description="Filter by type"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Maximum number of records"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
|
||||
):
|
||||
"""Get notifications for a tenant with enhanced filtering (admin/manager only)"""
|
||||
|
||||
if current_user.get("role") not in ["admin", "manager"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins and managers can view tenant notifications"
|
||||
)
|
||||
|
||||
try:
|
||||
# Convert enums if provided
|
||||
model_notification_type = None
|
||||
if notification_type:
|
||||
try:
|
||||
model_notification_type = ModelNotificationType(notification_type.value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid notification type: {notification_type.value}"
|
||||
)
|
||||
|
||||
model_status = None
|
||||
if status_filter:
|
||||
try:
|
||||
from app.models.notifications import NotificationStatus as ModelStatus
|
||||
model_status = ModelStatus(status_filter.value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid status: {status_filter.value}"
|
||||
)
|
||||
|
||||
notifications = await notification_service.get_tenant_notifications(
|
||||
tenant_id=tenant_id,
|
||||
status=model_status,
|
||||
notification_type=model_notification_type,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return [NotificationResponse.from_orm(notification) for notification in notifications]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get tenant notifications",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get tenant notifications"
|
||||
)
|
||||
|
||||
404
services/notification/app/api/whatsapp_webhooks.py
Normal file
404
services/notification/app/api/whatsapp_webhooks.py
Normal file
@@ -0,0 +1,404 @@
|
||||
# ================================================================
|
||||
# services/notification/app/api/whatsapp_webhooks.py
|
||||
# ================================================================
|
||||
"""
|
||||
WhatsApp Business Cloud API Webhook Endpoints
|
||||
Handles verification, message delivery status updates, and incoming messages
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Response, HTTPException, Depends, Query
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
from app.repositories.whatsapp_message_repository import WhatsAppMessageRepository
|
||||
from app.models.whatsapp_messages import WhatsAppMessageStatus
|
||||
from app.core.database import get_db
|
||||
from shared.monitoring.metrics import MetricsCollector
|
||||
|
||||
logger = structlog.get_logger()
|
||||
metrics = MetricsCollector("notification-service")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp-webhooks"])
|
||||
|
||||
|
||||
@router.get("/webhook")
|
||||
async def verify_webhook(
|
||||
request: Request,
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_token: str = Query(None, alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge")
|
||||
) -> PlainTextResponse:
|
||||
"""
|
||||
Webhook verification endpoint for WhatsApp Cloud API
|
||||
|
||||
Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge
|
||||
to verify the webhook URL when you configure it in the Meta Business Suite.
|
||||
|
||||
Args:
|
||||
hub_mode: Should be "subscribe"
|
||||
hub_token: Verify token configured in settings
|
||||
hub_challenge: Challenge string to echo back
|
||||
|
||||
Returns:
|
||||
PlainTextResponse with challenge if verification succeeds
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"WhatsApp webhook verification request received",
|
||||
mode=hub_mode,
|
||||
token_provided=bool(hub_token),
|
||||
challenge_provided=bool(hub_challenge)
|
||||
)
|
||||
|
||||
# Verify the mode and token
|
||||
if hub_mode == "subscribe" and hub_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
|
||||
logger.info("WhatsApp webhook verification successful")
|
||||
|
||||
# Respond with the challenge token
|
||||
return PlainTextResponse(content=hub_challenge, status_code=200)
|
||||
else:
|
||||
logger.warning(
|
||||
"WhatsApp webhook verification failed",
|
||||
mode=hub_mode,
|
||||
token_match=hub_token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Verification token mismatch")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("WhatsApp webhook verification error", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Verification failed")
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def handle_webhook(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_db)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Webhook endpoint for WhatsApp Cloud API events
|
||||
|
||||
Receives notifications about:
|
||||
- Message delivery status (sent, delivered, read, failed)
|
||||
- Incoming messages from users
|
||||
- Errors and other events
|
||||
|
||||
Args:
|
||||
request: FastAPI request with webhook payload
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Success response
|
||||
"""
|
||||
try:
|
||||
# Parse webhook payload
|
||||
payload = await request.json()
|
||||
|
||||
logger.info(
|
||||
"WhatsApp webhook received",
|
||||
object_type=payload.get("object"),
|
||||
entries_count=len(payload.get("entry", []))
|
||||
)
|
||||
|
||||
# Verify it's a WhatsApp webhook
|
||||
if payload.get("object") != "whatsapp_business_account":
|
||||
logger.warning("Unknown webhook object type", object_type=payload.get("object"))
|
||||
return {"status": "ignored"}
|
||||
|
||||
# Process each entry
|
||||
for entry in payload.get("entry", []):
|
||||
entry_id = entry.get("id")
|
||||
|
||||
for change in entry.get("changes", []):
|
||||
field = change.get("field")
|
||||
value = change.get("value", {})
|
||||
|
||||
if field == "messages":
|
||||
# Handle incoming messages or status updates
|
||||
await _handle_message_change(value, session)
|
||||
else:
|
||||
logger.debug("Unhandled webhook field", field=field)
|
||||
|
||||
# Record metric
|
||||
metrics.increment_counter("whatsapp_webhooks_received")
|
||||
|
||||
# Always return 200 OK to acknowledge receipt
|
||||
return {"status": "success"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("WhatsApp webhook processing error", error=str(e))
|
||||
# Still return 200 to avoid Meta retrying
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
async def _handle_message_change(value: Dict[str, Any], session: AsyncSession) -> None:
|
||||
"""
|
||||
Handle message-related webhook events
|
||||
|
||||
Args:
|
||||
value: Webhook value containing message data
|
||||
session: Database session
|
||||
"""
|
||||
try:
|
||||
messaging_product = value.get("messaging_product")
|
||||
metadata = value.get("metadata", {})
|
||||
|
||||
# Handle status updates
|
||||
statuses = value.get("statuses", [])
|
||||
if statuses:
|
||||
await _handle_status_updates(statuses, session)
|
||||
|
||||
# Handle incoming messages
|
||||
messages = value.get("messages", [])
|
||||
if messages:
|
||||
await _handle_incoming_messages(messages, metadata, session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error handling message change", error=str(e))
|
||||
|
||||
|
||||
async def _handle_status_updates(
|
||||
statuses: list,
|
||||
session: AsyncSession
|
||||
) -> None:
|
||||
"""
|
||||
Handle message delivery status updates
|
||||
|
||||
Args:
|
||||
statuses: List of status update objects
|
||||
session: Database session
|
||||
"""
|
||||
try:
|
||||
message_repo = WhatsAppMessageRepository(session)
|
||||
|
||||
for status in statuses:
|
||||
whatsapp_message_id = status.get("id")
|
||||
status_value = status.get("status") # sent, delivered, read, failed
|
||||
timestamp = status.get("timestamp")
|
||||
errors = status.get("errors", [])
|
||||
|
||||
logger.info(
|
||||
"WhatsApp message status update",
|
||||
message_id=whatsapp_message_id,
|
||||
status=status_value,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
# Find message in database
|
||||
db_message = await message_repo.get_by_whatsapp_id(whatsapp_message_id)
|
||||
|
||||
if not db_message:
|
||||
logger.warning(
|
||||
"Received status for unknown message",
|
||||
whatsapp_message_id=whatsapp_message_id
|
||||
)
|
||||
continue
|
||||
|
||||
# Map WhatsApp status to our enum
|
||||
status_mapping = {
|
||||
"sent": WhatsAppMessageStatus.SENT,
|
||||
"delivered": WhatsAppMessageStatus.DELIVERED,
|
||||
"read": WhatsAppMessageStatus.READ,
|
||||
"failed": WhatsAppMessageStatus.FAILED
|
||||
}
|
||||
|
||||
new_status = status_mapping.get(status_value)
|
||||
if not new_status:
|
||||
logger.warning("Unknown status value", status=status_value)
|
||||
continue
|
||||
|
||||
# Extract error information if failed
|
||||
error_message = None
|
||||
error_code = None
|
||||
if errors:
|
||||
error = errors[0]
|
||||
error_code = error.get("code")
|
||||
error_message = error.get("title", error.get("message"))
|
||||
|
||||
# Update message status
|
||||
await message_repo.update_message_status(
|
||||
message_id=str(db_message.id),
|
||||
status=new_status,
|
||||
error_message=error_message,
|
||||
provider_response=status
|
||||
)
|
||||
|
||||
# Record metric
|
||||
metrics.increment_counter(
|
||||
"whatsapp_status_updates",
|
||||
labels={"status": status_value}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error handling status updates", error=str(e))
|
||||
|
||||
|
||||
async def _handle_incoming_messages(
|
||||
messages: list,
|
||||
metadata: Dict[str, Any],
|
||||
session: AsyncSession
|
||||
) -> None:
|
||||
"""
|
||||
Handle incoming messages from users
|
||||
|
||||
This is for future use if you want to implement two-way messaging.
|
||||
For now, we just log incoming messages.
|
||||
|
||||
Args:
|
||||
messages: List of message objects
|
||||
metadata: Metadata about the phone number
|
||||
session: Database session
|
||||
"""
|
||||
try:
|
||||
for message in messages:
|
||||
message_id = message.get("id")
|
||||
from_number = message.get("from")
|
||||
message_type = message.get("type")
|
||||
timestamp = message.get("timestamp")
|
||||
|
||||
# Extract message content based on type
|
||||
content = None
|
||||
if message_type == "text":
|
||||
content = message.get("text", {}).get("body")
|
||||
elif message_type == "image":
|
||||
content = message.get("image", {}).get("caption")
|
||||
|
||||
logger.info(
|
||||
"Incoming WhatsApp message",
|
||||
message_id=message_id,
|
||||
from_number=from_number,
|
||||
message_type=message_type,
|
||||
content=content[:100] if content else None
|
||||
)
|
||||
|
||||
# Record metric
|
||||
metrics.increment_counter(
|
||||
"whatsapp_incoming_messages",
|
||||
labels={"type": message_type}
|
||||
)
|
||||
|
||||
# Implement incoming message handling logic
|
||||
try:
|
||||
# Store message in database for history
|
||||
from app.models.whatsapp_message import WhatsAppMessage
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Extract message details
|
||||
message_text = message.get("text", {}).get("body", "")
|
||||
media_url = None
|
||||
if message_type == "image":
|
||||
media_url = message.get("image", {}).get("id")
|
||||
elif message_type == "document":
|
||||
media_url = message.get("document", {}).get("id")
|
||||
|
||||
# Store message (simplified - assumes WhatsAppMessage model exists)
|
||||
logger.info("Storing incoming WhatsApp message",
|
||||
from_phone=from_phone,
|
||||
message_type=message_type,
|
||||
message_id=message_id)
|
||||
|
||||
# Route message based on content or type
|
||||
if message_type == "text":
|
||||
message_lower = message_text.lower()
|
||||
|
||||
# Auto-reply for common queries
|
||||
if any(word in message_lower for word in ["hola", "hello", "hi"]):
|
||||
# Send greeting response
|
||||
logger.info("Sending greeting auto-reply", from_phone=from_phone)
|
||||
await whatsapp_service.send_message(
|
||||
to_phone=from_phone,
|
||||
message="¡Hola! Gracias por contactarnos. ¿En qué podemos ayudarte?",
|
||||
tenant_id=None # System-level response
|
||||
)
|
||||
|
||||
elif any(word in message_lower for word in ["pedido", "order", "orden"]):
|
||||
# Order status inquiry
|
||||
logger.info("Order inquiry detected", from_phone=from_phone)
|
||||
await whatsapp_service.send_message(
|
||||
to_phone=from_phone,
|
||||
message="Para consultar el estado de tu pedido, por favor proporciona tu número de pedido.",
|
||||
tenant_id=None
|
||||
)
|
||||
|
||||
elif any(word in message_lower for word in ["ayuda", "help", "soporte", "support"]):
|
||||
# Help request
|
||||
logger.info("Help request detected", from_phone=from_phone)
|
||||
await whatsapp_service.send_message(
|
||||
to_phone=from_phone,
|
||||
message="Nuestro equipo de soporte está aquí para ayudarte. Responderemos lo antes posible.",
|
||||
tenant_id=None
|
||||
)
|
||||
|
||||
else:
|
||||
# Generic acknowledgment
|
||||
logger.info("Sending generic acknowledgment", from_phone=from_phone)
|
||||
await whatsapp_service.send_message(
|
||||
to_phone=from_phone,
|
||||
message="Hemos recibido tu mensaje. Te responderemos pronto.",
|
||||
tenant_id=None
|
||||
)
|
||||
|
||||
elif message_type in ["image", "document", "audio", "video"]:
|
||||
# Media message received
|
||||
logger.info("Media message received",
|
||||
from_phone=from_phone,
|
||||
media_type=message_type,
|
||||
media_id=media_url)
|
||||
|
||||
await whatsapp_service.send_message(
|
||||
to_phone=from_phone,
|
||||
message="Hemos recibido tu archivo. Lo revisaremos pronto.",
|
||||
tenant_id=None
|
||||
)
|
||||
|
||||
# Publish event for further processing (CRM, ticketing, etc.)
|
||||
from shared.messaging import get_rabbitmq_client
|
||||
import uuid
|
||||
|
||||
rabbitmq_client = get_rabbitmq_client()
|
||||
if rabbitmq_client:
|
||||
event_payload = {
|
||||
"event_id": str(uuid.uuid4()),
|
||||
"event_type": "whatsapp.message.received",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"data": {
|
||||
"message_id": message_id,
|
||||
"from_phone": from_phone,
|
||||
"message_type": message_type,
|
||||
"message_text": message_text,
|
||||
"media_url": media_url,
|
||||
"timestamp": message.get("timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
await rabbitmq_client.publish_event(
|
||||
exchange_name="notification.events",
|
||||
routing_key="whatsapp.message.received",
|
||||
event_data=event_payload
|
||||
)
|
||||
|
||||
logger.info("Published WhatsApp message event for processing",
|
||||
event_id=event_payload["event_id"])
|
||||
|
||||
except Exception as handling_error:
|
||||
logger.error("Failed to handle incoming WhatsApp message",
|
||||
error=str(handling_error),
|
||||
message_id=message_id,
|
||||
from_phone=from_phone)
|
||||
# Don't fail webhook if message handling fails
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error handling incoming messages", error=str(e))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def webhook_health() -> Dict[str, str]:
|
||||
"""Health check for webhook endpoint"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "whatsapp-webhooks",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
Reference in New Issue
Block a user