import logging from datetime import datetime from google.cloud import firestore from ..config import Settings from ..models import ConversationSessionDTO, ConversationEntryDTO from ..models.notification import Notification logger = logging.getLogger(__name__) class FirestoreService: """Service for Firestore operations on conversations.""" def __init__(self, settings: Settings): """Initialize Firestore client.""" self.settings = settings self.db = firestore.AsyncClient( project=settings.gcp_project_id, database=settings.firestore_database_id, ) self.conversations_collection = ( f"artifacts/{settings.gcp_project_id}/conversations" ) self.entries_subcollection = "mensajes" self.notifications_collection = ( f"artifacts/{settings.gcp_project_id}/notifications" ) logger.info( f"Firestore client initialized for project: {settings.gcp_project_id}" ) async def close(self): """Close Firestore client.""" self.db.close() logger.info("Firestore client closed") def _session_ref(self, session_id: str): """Get Firestore document reference for session.""" return self.db.collection(self.conversations_collection).document(session_id) async def get_session(self, session_id: str) -> ConversationSessionDTO | None: """Retrieve conversation session from Firestore by session ID.""" try: doc_ref = self._session_ref(session_id) doc = await doc_ref.get() if not doc.exists: logger.debug(f"Session not found in Firestore: {session_id}") return None data = doc.to_dict() session = ConversationSessionDTO.model_validate(data) logger.debug(f"Retrieved session from Firestore: {session_id}") return session except Exception as e: logger.error( f"Error retrieving session {session_id} from Firestore: {str(e)}" ) return None async def get_session_by_phone( self, telefono: str ) -> ConversationSessionDTO | None: """ Retrieve most recent conversation session from Firestore by phone number. Args: telefono: User phone number Returns: Most recent session for this phone, or None if not found """ try: query = ( self.db.collection(self.conversations_collection) .where("telefono", "==", telefono) .order_by("lastModified", direction=firestore.Query.DESCENDING) .limit(1) ) docs = query.stream() async for doc in docs: data = doc.to_dict() session = ConversationSessionDTO.model_validate(data) logger.debug( f"Retrieved session from Firestore for phone {telefono}: {session.sessionId}" ) return session logger.debug(f"No session found in Firestore for phone: {telefono}") return None except Exception as e: logger.error( f"Error querying session by phone {telefono} from Firestore: {str(e)}" ) return None async def save_session(self, session: ConversationSessionDTO) -> bool: """Save conversation session to Firestore.""" try: doc_ref = self._session_ref(session.sessionId) data = session.model_dump() await doc_ref.set(data, merge=True) logger.debug(f"Saved session to Firestore: {session.sessionId}") return True except Exception as e: logger.error( f"Error saving session {session.sessionId} to Firestore: {str(e)}" ) return False async def create_session( self, session_id: str, user_id: str, telefono: str, pantalla_contexto: str | None = None, last_message: str | None = None, ) -> ConversationSessionDTO: """Create and save a new conversation session to Firestore. Args: session_id: Unique session identifier user_id: User identifier telefono: User phone number pantalla_contexto: Optional screen context for the conversation last_message: Optional last message in the conversation Returns: The created session Raises: Exception: If session creation or save fails """ session = ConversationSessionDTO.create( session_id=session_id, user_id=user_id, telefono=telefono, pantalla_contexto=pantalla_contexto, last_message=last_message, ) doc_ref = self._session_ref(session.sessionId) data = session.model_dump() await doc_ref.set(data, merge=True) logger.info(f"Created new session in Firestore: {session_id}") return session async def save_entry(self, session_id: str, entry: ConversationEntryDTO) -> bool: """Save conversation entry to Firestore subcollection.""" try: doc_ref = self._session_ref(session_id) entries_ref = doc_ref.collection(self.entries_subcollection) # Use timestamp as document ID for chronological ordering entry_id = entry.timestamp.isoformat() entry_doc = entries_ref.document(entry_id) data = entry.model_dump() await entry_doc.set(data) logger.debug(f"Saved entry to Firestore for session: {session_id}") return True except Exception as e: logger.error( f"Error saving entry for session {session_id} to Firestore: {str(e)}" ) return False async def get_entries( self, session_id: str, limit: int = 10 ) -> list[ConversationEntryDTO]: """Retrieve recent conversation entries from Firestore.""" try: doc_ref = self._session_ref(session_id) entries_ref = doc_ref.collection(self.entries_subcollection) # Get entries ordered by timestamp descending query = entries_ref.order_by( "timestamp", direction=firestore.Query.DESCENDING ).limit(limit) docs = query.stream() entries = [] async for doc in docs: entry_data = doc.to_dict() entry = ConversationEntryDTO.model_validate(entry_data) entries.append(entry) # Reverse to get chronological order entries.reverse() logger.debug(f"Retrieved {len(entries)} entries for session: {session_id}") return entries except Exception as e: logger.error( f"Error retrieving entries for session {session_id} from Firestore: {str(e)}" ) return [] async def delete_session(self, session_id: str) -> bool: """Delete conversation session and all entries from Firestore.""" try: doc_ref = self._session_ref(session_id) # Delete all entries first entries_ref = doc_ref.collection(self.entries_subcollection) async for doc in entries_ref.stream(): await doc.reference.delete() # Delete session document await doc_ref.delete() logger.debug(f"Deleted session from Firestore: {session_id}") return True except Exception as e: logger.error( f"Error deleting session {session_id} from Firestore: {str(e)}" ) return False async def update_pantalla_contexto( self, session_id: str, pantalla_contexto: str | None ) -> bool: """Update the pantallaContexto field for a conversation session. Args: session_id: Session ID to update pantalla_contexto: New pantalla contexto value Returns: True if update was successful, False otherwise """ try: doc_ref = self._session_ref(session_id) doc = await doc_ref.get() if not doc.exists: logger.warning( f"Session {session_id} not found in Firestore. Cannot update pantallaContexto" ) return False await doc_ref.update( { "pantallaContexto": pantalla_contexto, "lastModified": datetime.now(), } ) logger.debug( f"Updated pantallaContexto for session {session_id} in Firestore" ) return True except Exception as e: logger.error( f"Error updating pantallaContexto for session {session_id} in Firestore: {str(e)}" ) return False # ====== Notification Methods ====== def _notification_ref(self, notification_id: str): """Get Firestore document reference for notification.""" return self.db.collection(self.notifications_collection).document( notification_id ) async def save_or_append_notification(self, new_entry: Notification) -> None: """ Save or append notification entry to Firestore. Args: new_entry: Notification entry to save Raises: ValueError: If phone number is missing """ phone_number = new_entry.telefono if not phone_number or not phone_number.strip(): raise ValueError("Phone number is required to manage notification entries") # Use phone number as document ID notification_session_id = phone_number try: doc_ref = self._notification_ref(notification_session_id) doc = await doc_ref.get() entry_dict = new_entry.model_dump() if doc.exists: # Append to existing session await doc_ref.update( { "notificaciones": firestore.ArrayUnion([entry_dict]), "ultimaActualizacion": datetime.now(), } ) logger.info( f"Successfully appended notification entry to session {notification_session_id} in Firestore" ) else: # Create new notification session new_session_data = { "sessionId": notification_session_id, "telefono": phone_number, "fechaCreacion": datetime.now(), "ultimaActualizacion": datetime.now(), "notificaciones": [entry_dict], } await doc_ref.set(new_session_data) logger.info( f"Successfully created new notification session {notification_session_id} in Firestore" ) except Exception as e: logger.error( f"Error saving notification to Firestore for phone {phone_number}: {str(e)}", exc_info=True, ) raise async def update_notification_status(self, session_id: str, status: str) -> None: """ Update the status of all notifications in a session. Args: session_id: Notification session ID (phone number) status: New status value """ try: doc_ref = self._notification_ref(session_id) doc = await doc_ref.get() if not doc.exists: logger.warning( f"Notification session {session_id} not found in Firestore. Cannot update status" ) return session_data = doc.to_dict() notifications = session_data.get("notificaciones", []) # Update status for all notifications updated_notifications = [ {**notif, "status": status} for notif in notifications ] await doc_ref.update( { "notificaciones": updated_notifications, "ultimaActualizacion": datetime.now(), } ) logger.info( f"Successfully updated notification status to '{status}' for session {session_id} in Firestore" ) except Exception as e: logger.error( f"Error updating notification status in Firestore for session {session_id}: {str(e)}", exc_info=True, ) raise async def delete_notification(self, notification_id: str) -> bool: """Delete notification session from Firestore.""" try: logger.info( f"Deleting notification session {notification_id} from Firestore" ) doc_ref = self._notification_ref(notification_id) await doc_ref.delete() logger.info( f"Successfully deleted notification session {notification_id} from Firestore" ) return True except Exception as e: logger.error( f"Error deleting notification session {notification_id} from Firestore: {str(e)}", exc_info=True, ) return False