# app/modules/messaging/routes/api/admin_messages.py """ Admin messaging endpoints. Provides endpoints for: - Viewing conversations (admin_store and admin_customer channels) - Sending and receiving messages - Managing conversation status - File attachments """ import logging from typing import Any from fastapi import APIRouter, Depends, File, Form, Query, UploadFile from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_admin_api from app.core.database import get_db from app.modules.messaging.exceptions import ( ConversationClosedException, ConversationNotFoundException, InvalidConversationTypeException, InvalidRecipientTypeException, MessageAttachmentException, ) from app.modules.messaging.models import ConversationType, ParticipantType from app.modules.messaging.schemas import ( AdminConversationListResponse, AdminConversationSummary, AttachmentResponse, CloseConversationResponse, ConversationCreate, ConversationDetailResponse, MarkReadResponse, MessageResponse, NotificationPreferencesUpdate, ParticipantInfo, ParticipantResponse, RecipientListResponse, RecipientOption, ReopenConversationResponse, UnreadCountResponse, ) from app.modules.messaging.services.message_attachment_service import ( message_attachment_service, ) from app.modules.messaging.services.messaging_service import messaging_service from app.modules.tenancy.schemas.auth import UserContext admin_messages_router = APIRouter(prefix="/messages") logger = logging.getLogger(__name__) # ============================================================================ # HELPER FUNCTIONS # ============================================================================ def _enrich_message( db: Session, message: Any, include_attachments: bool = True ) -> MessageResponse: """Enrich message with sender info and attachments.""" sender_info = messaging_service.get_participant_info( db, message.sender_type, message.sender_id ) attachments = [] if include_attachments and message.attachments: for att in message.attachments: attachments.append( AttachmentResponse( id=att.id, filename=att.filename, original_filename=att.original_filename, file_size=att.file_size, mime_type=att.mime_type, is_image=att.is_image, image_width=att.image_width, image_height=att.image_height, download_url=message_attachment_service.get_download_url( att.file_path ), thumbnail_url=( message_attachment_service.get_download_url(att.thumbnail_path) if att.thumbnail_path else None ), ) ) return MessageResponse( id=message.id, conversation_id=message.conversation_id, sender_type=message.sender_type, sender_id=message.sender_id, content=message.content, is_system_message=message.is_system_message, is_deleted=message.is_deleted, created_at=message.created_at, sender_name=sender_info["name"] if sender_info else None, sender_email=sender_info["email"] if sender_info else None, attachments=attachments, ) def _enrich_conversation_summary( db: Session, conversation: Any, current_user_id: int ) -> AdminConversationSummary: """Enrich conversation with other participant info and unread count.""" # Get current user's participant record my_participant = next( ( p for p in conversation.participants if p.participant_type == ParticipantType.ADMIN and p.participant_id == current_user_id ), None, ) unread_count = my_participant.unread_count if my_participant else 0 # Get other participant info other = messaging_service.get_other_participant( conversation, ParticipantType.ADMIN, current_user_id ) other_info = None if other: info = messaging_service.get_participant_info( db, other.participant_type, other.participant_id ) if info: other_info = ParticipantInfo( id=info["id"], type=info["type"], name=info["name"], email=info.get("email"), ) # Get last message preview last_message_preview = None if conversation.messages: last_msg = conversation.messages[-1] if conversation.messages else None if last_msg: preview = last_msg.content[:100] if len(last_msg.content) > 100: preview += "..." last_message_preview = preview # Get store info if applicable store_name = None store_code = None if conversation.store: store_name = conversation.store.name store_code = conversation.store.store_code return AdminConversationSummary( id=conversation.id, conversation_type=conversation.conversation_type, subject=conversation.subject, store_id=conversation.store_id, is_closed=conversation.is_closed, closed_at=conversation.closed_at, last_message_at=conversation.last_message_at, message_count=conversation.message_count, created_at=conversation.created_at, unread_count=unread_count, other_participant=other_info, last_message_preview=last_message_preview, store_name=store_name, store_code=store_code, ) # ============================================================================ # CONVERSATION LIST # ============================================================================ @admin_messages_router.get("", response_model=AdminConversationListResponse) def list_conversations( conversation_type: ConversationType | None = Query(None, description="Filter by type"), is_closed: bool | None = Query(None, description="Filter by status"), skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> AdminConversationListResponse: """List conversations for admin (admin_store and admin_customer channels).""" conversations, total, total_unread = messaging_service.list_conversations( db=db, participant_type=ParticipantType.ADMIN, participant_id=current_admin.id, conversation_type=conversation_type, is_closed=is_closed, skip=skip, limit=limit, ) return AdminConversationListResponse( conversations=[ _enrich_conversation_summary(db, c, current_admin.id) for c in conversations ], total=total, total_unread=total_unread, skip=skip, limit=limit, ) @admin_messages_router.get("/unread-count", response_model=UnreadCountResponse) def get_unread_count( db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> UnreadCountResponse: """Get total unread message count for header badge.""" count = messaging_service.get_unread_count( db=db, participant_type=ParticipantType.ADMIN, participant_id=current_admin.id, ) return UnreadCountResponse(total_unread=count) # ============================================================================ # RECIPIENTS # ============================================================================ @admin_messages_router.get("/recipients", response_model=RecipientListResponse) def get_recipients( recipient_type: ParticipantType = Query(..., description="Type of recipients to list"), search: str | None = Query(None, description="Search by name/email"), store_id: int | None = Query(None, description="Filter by store"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> RecipientListResponse: """Get list of available recipients for compose modal.""" if recipient_type == ParticipantType.STORE: recipient_data, total = messaging_service.get_store_recipients( db=db, store_id=store_id, search=search, skip=skip, limit=limit, ) recipients = [ RecipientOption( id=r["id"], type=r["type"], name=r["name"], email=r["email"], store_id=r["store_id"], store_name=r.get("store_name"), ) for r in recipient_data ] elif recipient_type == ParticipantType.CUSTOMER: recipient_data, total = messaging_service.get_customer_recipients( db=db, store_id=store_id, search=search, skip=skip, limit=limit, ) recipients = [ RecipientOption( id=r["id"], type=r["type"], name=r["name"], email=r["email"], store_id=r["store_id"], ) for r in recipient_data ] else: recipients = [] total = 0 return RecipientListResponse(recipients=recipients, total=total) # ============================================================================ # CREATE CONVERSATION # ============================================================================ @admin_messages_router.post("", response_model=ConversationDetailResponse) def create_conversation( data: ConversationCreate, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> ConversationDetailResponse: """Create a new conversation.""" # Validate conversation type for admin if data.conversation_type not in [ ConversationType.ADMIN_STORE, ConversationType.ADMIN_CUSTOMER, ]: raise InvalidConversationTypeException( message="Admin can only create admin_store or admin_customer conversations", allowed_types=["admin_store", "admin_customer"], ) # Validate recipient type matches conversation type if ( data.conversation_type == ConversationType.ADMIN_STORE and data.recipient_type != ParticipantType.STORE ): raise InvalidRecipientTypeException( conversation_type="admin_store", expected_recipient_type="store", ) if ( data.conversation_type == ConversationType.ADMIN_CUSTOMER and data.recipient_type != ParticipantType.CUSTOMER ): raise InvalidRecipientTypeException( conversation_type="admin_customer", expected_recipient_type="customer", ) # Create conversation conversation = messaging_service.create_conversation( db=db, conversation_type=data.conversation_type, subject=data.subject, initiator_type=ParticipantType.ADMIN, initiator_id=current_admin.id, recipient_type=data.recipient_type, recipient_id=data.recipient_id, store_id=data.store_id, initial_message=data.initial_message, ) db.commit() db.refresh(conversation) logger.info( f"Admin {current_admin.username} created conversation {conversation.id} " f"with {data.recipient_type.value}:{data.recipient_id}" ) # Return full detail response return _build_conversation_detail(db, conversation, current_admin.id) # ============================================================================ # CONVERSATION DETAIL # ============================================================================ def _build_conversation_detail( db: Session, conversation: Any, current_user_id: int ) -> ConversationDetailResponse: """Build full conversation detail response.""" # Get my participant for unread count my_participant = next( ( p for p in conversation.participants if p.participant_type == ParticipantType.ADMIN and p.participant_id == current_user_id ), None, ) unread_count = my_participant.unread_count if my_participant else 0 # Build participant responses participants = [] for p in conversation.participants: info = messaging_service.get_participant_info( db, p.participant_type, p.participant_id ) participants.append( ParticipantResponse( id=p.id, participant_type=p.participant_type, participant_id=p.participant_id, unread_count=p.unread_count, last_read_at=p.last_read_at, email_notifications=p.email_notifications, muted=p.muted, participant_info=( ParticipantInfo( id=info["id"], type=info["type"], name=info["name"], email=info.get("email"), ) if info else None ), ) ) # Build message responses messages = [_enrich_message(db, m) for m in conversation.messages] # Get store name if applicable store_name = None if conversation.store: store_name = conversation.store.name return ConversationDetailResponse( id=conversation.id, conversation_type=conversation.conversation_type, subject=conversation.subject, store_id=conversation.store_id, is_closed=conversation.is_closed, closed_at=conversation.closed_at, closed_by_type=conversation.closed_by_type, closed_by_id=conversation.closed_by_id, last_message_at=conversation.last_message_at, message_count=conversation.message_count, created_at=conversation.created_at, updated_at=conversation.updated_at, participants=participants, messages=messages, unread_count=unread_count, store_name=store_name, ) @admin_messages_router.get("/{conversation_id}", response_model=ConversationDetailResponse) def get_conversation( conversation_id: int, mark_read: bool = Query(True, description="Automatically mark as read"), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> ConversationDetailResponse: """Get conversation detail with messages.""" conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.ADMIN, participant_id=current_admin.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) # Mark as read if requested if mark_read: messaging_service.mark_conversation_read( db=db, conversation_id=conversation_id, reader_type=ParticipantType.ADMIN, reader_id=current_admin.id, ) db.commit() return _build_conversation_detail(db, conversation, current_admin.id) # ============================================================================ # SEND MESSAGE # ============================================================================ @admin_messages_router.post("/{conversation_id}/messages", response_model=MessageResponse) async def send_message( conversation_id: int, content: str = Form(...), files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> MessageResponse: """Send a message in a conversation, optionally with attachments.""" # Verify access conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.ADMIN, participant_id=current_admin.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) if conversation.is_closed: raise ConversationClosedException(conversation_id) # Process attachments attachments = [] for file in files: try: att_data = await message_attachment_service.validate_and_store( db=db, file=file, conversation_id=conversation_id ) attachments.append(att_data) except ValueError as e: raise MessageAttachmentException(str(e)) # Send message message = messaging_service.send_message( db=db, conversation_id=conversation_id, sender_type=ParticipantType.ADMIN, sender_id=current_admin.id, content=content, attachments=attachments if attachments else None, ) db.commit() db.refresh(message) logger.info( f"Admin {current_admin.username} sent message {message.id} " f"in conversation {conversation_id}" ) return _enrich_message(db, message) # ============================================================================ # CONVERSATION ACTIONS # ============================================================================ @admin_messages_router.post("/{conversation_id}/close", response_model=CloseConversationResponse) def close_conversation( conversation_id: int, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> CloseConversationResponse: """Close a conversation.""" conversation = messaging_service.close_conversation( db=db, conversation_id=conversation_id, closer_type=ParticipantType.ADMIN, closer_id=current_admin.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) db.commit() logger.info( f"Admin {current_admin.username} closed conversation {conversation_id}" ) return CloseConversationResponse( success=True, message="Conversation closed", conversation_id=conversation_id, ) @admin_messages_router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse) def reopen_conversation( conversation_id: int, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> ReopenConversationResponse: """Reopen a closed conversation.""" conversation = messaging_service.reopen_conversation( db=db, conversation_id=conversation_id, opener_type=ParticipantType.ADMIN, opener_id=current_admin.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) db.commit() logger.info( f"Admin {current_admin.username} reopened conversation {conversation_id}" ) return ReopenConversationResponse( success=True, message="Conversation reopened", conversation_id=conversation_id, ) @admin_messages_router.put("/{conversation_id}/read", response_model=MarkReadResponse) def mark_read( conversation_id: int, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> MarkReadResponse: """Mark conversation as read.""" success = messaging_service.mark_conversation_read( db=db, conversation_id=conversation_id, reader_type=ParticipantType.ADMIN, reader_id=current_admin.id, ) db.commit() return MarkReadResponse( success=success, conversation_id=conversation_id, unread_count=0, ) class PreferencesUpdateResponse(BaseModel): """Response for preferences update.""" success: bool @admin_messages_router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse) def update_preferences( conversation_id: int, preferences: NotificationPreferencesUpdate, db: Session = Depends(get_db), current_admin: UserContext = Depends(get_current_admin_api), ) -> PreferencesUpdateResponse: """Update notification preferences for a conversation.""" success = messaging_service.update_notification_preferences( db=db, conversation_id=conversation_id, participant_type=ParticipantType.ADMIN, participant_id=current_admin.id, email_notifications=preferences.email_notifications, muted=preferences.muted, ) db.commit() return PreferencesUpdateResponse(success=success)