""" Document Notifications Service Provides convenience helpers to broadcast real-time document processing status updates over the centralized WebSocket pool. Targets both per-file topics for end users and an admin-wide topic for monitoring. """ from __future__ import annotations from typing import Any, Dict, Optional from datetime import datetime, timezone from uuid import uuid4 from app.core.logging import get_logger from app.middleware.websocket_middleware import get_websocket_manager from app.database.base import SessionLocal from app.models.document_workflows import EventLog logger = get_logger("document_notifications") # Topic helpers def topic_for_file(file_no: str) -> str: return f"documents_{file_no}" ADMIN_DOCUMENTS_TOPIC = "admin_documents" # ---------------------------------------------------------------------------- # Lightweight in-memory status store for backfill # ---------------------------------------------------------------------------- _last_status_by_file: Dict[str, Dict[str, Any]] = {} def _record_last_status(*, file_no: str, status: str, data: Optional[Dict[str, Any]] = None) -> None: try: _last_status_by_file[file_no] = { "file_no": file_no, "status": status, "data": dict(data or {}), "timestamp": datetime.now(timezone.utc), } except Exception: # Avoid ever failing core path pass def get_last_status(file_no: str) -> Optional[Dict[str, Any]]: """Return the last known status record for a file, if any. Record shape: { file_no, status, data, timestamp: datetime } """ try: return _last_status_by_file.get(file_no) except Exception: return None async def broadcast_status( *, file_no: str, status: str, # "processing" | "completed" | "failed" data: Optional[Dict[str, Any]] = None, user_id: Optional[int] = None, ) -> int: """ Broadcast a document status update to: - The per-file topic for subscribers - The admin monitoring topic - Optionally to a specific user's active connections Returns number of messages successfully sent to the per-file topic. """ wm = get_websocket_manager() event_data: Dict[str, Any] = { "file_no": file_no, "status": status, **(data or {}), } # Update in-memory last-known status for backfill _record_last_status(file_no=file_no, status=status, data=data) # Best-effort persistence to event log for history/backfill try: db = SessionLocal() try: ev = EventLog( event_id=str(uuid4()), event_type=f"document_{status}", event_source="document_management", file_no=file_no, user_id=user_id, resource_type="document", resource_id=str(event_data.get("document_id") or event_data.get("job_id") or ""), event_data=event_data, previous_state=None, new_state={"status": status}, occurred_at=datetime.now(timezone.utc), ) db.add(ev) db.commit() except Exception: try: db.rollback() except Exception: pass finally: try: db.close() except Exception: pass except Exception: # Never fail core path pass # Per-file topic broadcast topic = topic_for_file(file_no) sent_to_file = await wm.broadcast_to_topic( topic=topic, message_type=f"document_{status}", data=event_data, ) # Admin monitoring broadcast (best-effort) try: await wm.broadcast_to_topic( topic=ADMIN_DOCUMENTS_TOPIC, message_type="admin_document_event", data=event_data, ) except Exception: # Never fail core path if admin broadcast fails pass # Optional direct-to-user notification if user_id is not None: try: await wm.send_to_user( user_id=user_id, message_type=f"document_{status}", data=event_data, ) except Exception: # Ignore failures to keep UX resilient pass logger.info( "Document notification broadcast", file_no=file_no, status=status, sent_to_file_topic=sent_to_file, ) return sent_to_file async def notify_processing( *, file_no: str, user_id: Optional[int] = None, data: Optional[Dict[str, Any]] = None ) -> int: return await broadcast_status(file_no=file_no, status="processing", data=data, user_id=user_id) async def notify_completed( *, file_no: str, user_id: Optional[int] = None, data: Optional[Dict[str, Any]] = None ) -> int: return await broadcast_status(file_no=file_no, status="completed", data=data, user_id=user_id) async def notify_failed( *, file_no: str, user_id: Optional[int] = None, data: Optional[Dict[str, Any]] = None ) -> int: return await broadcast_status(file_no=file_no, status="failed", data=data, user_id=user_id)