# app/api/v1/shop/messages.py """ Shop Messages API (Customer authenticated) Endpoints for customer messaging in shop frontend. Uses vendor from request.state (injected by VendorContextMiddleware). Requires customer authentication. Customers can only: - View their own vendor_customer conversations - Reply to existing conversations - Mark conversations as read """ import logging from typing import List, Optional from fastapi import APIRouter, Depends, File, Form, Path, Query, Request, UploadFile from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db from app.exceptions import ( AttachmentNotFoundException, ConversationClosedException, ConversationNotFoundException, VendorNotFoundException, ) from app.services.message_attachment_service import message_attachment_service from app.services.messaging_service import messaging_service from models.database.customer import Customer from models.database.message import ConversationType, ParticipantType from models.schema.message import ( ConversationDetailResponse, ConversationListResponse, ConversationSummary, MessageResponse, UnreadCountResponse, ) router = APIRouter() logger = logging.getLogger(__name__) # ============================================================================ # Response Models # ============================================================================ class SendMessageResponse(BaseModel): """Response for send message.""" success: bool message: MessageResponse # ============================================================================ # API Endpoints # ============================================================================ @router.get("/messages", response_model=ConversationListResponse) # authenticated def list_conversations( request: Request, skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), status: Optional[str] = Query(None, regex="^(open|closed)$"), customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ List conversations for authenticated customer. Customers only see their vendor_customer conversations. Query Parameters: - skip: Pagination offset - limit: Max items to return - status: Filter by open/closed """ vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] list_conversations for customer {customer.id}", extra={ "vendor_id": vendor.id, "customer_id": customer.id, "skip": skip, "limit": limit, "status": status, }, ) # Build filter - customers only see vendor_customer conversations is_closed = None if status == "open": is_closed = False elif status == "closed": is_closed = True # Get conversations where customer is a participant conversations, total = messaging_service.list_conversations( db=db, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, conversation_type=ConversationType.VENDOR_CUSTOMER, is_closed=is_closed, skip=skip, limit=limit, ) # Convert to summaries summaries = [] for conv, unread in conversations: summaries.append( ConversationSummary( id=conv.id, subject=conv.subject, conversation_type=conv.conversation_type.value, is_closed=conv.is_closed, last_message_at=conv.last_message_at, message_count=conv.message_count, unread_count=unread, other_participant_name=_get_other_participant_name(conv, customer.id), ) ) return ConversationListResponse( conversations=summaries, total=total, skip=skip, limit=limit, ) @router.get("/messages/unread-count", response_model=UnreadCountResponse) def get_unread_count( request: Request, customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Get total unread message count for header badge. """ vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") count = messaging_service.get_unread_count( db=db, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, ) return UnreadCountResponse(unread_count=count) @router.get("/messages/{conversation_id}", response_model=ConversationDetailResponse) def get_conversation( request: Request, conversation_id: int = Path(..., description="Conversation ID", gt=0), customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Get conversation detail with messages. Validates that customer is a participant. Automatically marks conversation as read. """ vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] get_conversation {conversation_id} for customer {customer.id}", extra={ "vendor_id": vendor.id, "customer_id": customer.id, "conversation_id": conversation_id, }, ) # Get conversation with access check conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) # Mark as read messaging_service.mark_conversation_read( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, ) # Build response messages = [] for msg in conversation.messages: if msg.is_deleted: continue messages.append( MessageResponse( id=msg.id, content=msg.content, sender_type=msg.sender_type.value, sender_id=msg.sender_id, sender_name=_get_sender_name(msg), is_system_message=msg.is_system_message, attachments=[ { "id": att.id, "filename": att.original_filename, "file_size": att.file_size, "mime_type": att.mime_type, "is_image": att.is_image, "download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}", "thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail" if att.thumbnail_path else None, } for att in msg.attachments ], created_at=msg.created_at, ) ) return ConversationDetailResponse( id=conversation.id, subject=conversation.subject, conversation_type=conversation.conversation_type.value, is_closed=conversation.is_closed, closed_at=conversation.closed_at, last_message_at=conversation.last_message_at, message_count=conversation.message_count, messages=messages, other_participant_name=_get_other_participant_name(conversation, customer.id), ) @router.post("/messages/{conversation_id}/messages", response_model=SendMessageResponse) async def send_message( request: Request, conversation_id: int = Path(..., description="Conversation ID", gt=0), content: str = Form(..., min_length=1, max_length=10000), attachments: List[UploadFile] = File(default=[]), customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Send a message in a conversation. Validates that customer is a participant. Supports file attachments. """ vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") logger.debug( f"[SHOP_API] send_message in {conversation_id} from customer {customer.id}", extra={ "vendor_id": vendor.id, "customer_id": customer.id, "conversation_id": conversation_id, "attachment_count": len(attachments), }, ) # Verify conversation exists and customer has access conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) # Check if conversation is closed if conversation.is_closed: raise ConversationClosedException(conversation_id) # Process attachments attachment_data = [] for upload_file in attachments: if upload_file.filename: file_data = await message_attachment_service.validate_and_store( db=db, upload_file=upload_file, conversation_id=conversation_id, ) attachment_data.append(file_data) # Send message message = messaging_service.send_message( db=db, conversation_id=conversation_id, sender_type=ParticipantType.CUSTOMER, sender_id=customer.id, content=content, attachments=attachment_data, ) logger.info( f"[SHOP_API] Message sent in conversation {conversation_id}", extra={ "message_id": message.id, "customer_id": customer.id, "vendor_id": vendor.id, }, ) return SendMessageResponse( success=True, message=MessageResponse( id=message.id, content=message.content, sender_type=message.sender_type.value, sender_id=message.sender_id, sender_name=_get_sender_name(message), is_system_message=message.is_system_message, attachments=[ { "id": att.id, "filename": att.original_filename, "file_size": att.file_size, "mime_type": att.mime_type, "is_image": att.is_image, "download_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}", "thumbnail_url": f"/api/v1/shop/messages/{conversation_id}/attachments/{att.id}/thumbnail" if att.thumbnail_path else None, } for att in message.attachments ], created_at=message.created_at, ), ) @router.put("/messages/{conversation_id}/read", response_model=dict) def mark_as_read( request: Request, conversation_id: int = Path(..., description="Conversation ID", gt=0), customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """Mark conversation as read.""" vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") # Verify access conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) messaging_service.mark_conversation_read( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, ) return {"success": True} @router.get("/messages/{conversation_id}/attachments/{attachment_id}") async def download_attachment( request: Request, conversation_id: int = Path(..., description="Conversation ID", gt=0), attachment_id: int = Path(..., description="Attachment ID", gt=0), customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Download a message attachment. Validates that customer has access to the conversation. """ from fastapi.responses import FileResponse vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") # Verify access conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) # Find attachment attachment = message_attachment_service.get_attachment( db=db, attachment_id=attachment_id, conversation_id=conversation_id, ) if not attachment: raise AttachmentNotFoundException(attachment_id) return FileResponse( path=attachment.file_path, filename=attachment.original_filename, media_type=attachment.mime_type, ) @router.get("/messages/{conversation_id}/attachments/{attachment_id}/thumbnail") async def get_attachment_thumbnail( request: Request, conversation_id: int = Path(..., description="Conversation ID", gt=0), attachment_id: int = Path(..., description="Attachment ID", gt=0), customer: Customer = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Get thumbnail for an image attachment. Validates that customer has access to the conversation. """ from fastapi.responses import FileResponse vendor = getattr(request.state, "vendor", None) if not vendor: raise VendorNotFoundException("context", identifier_type="subdomain") # Verify access conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, vendor_id=vendor.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) # Find attachment attachment = message_attachment_service.get_attachment( db=db, attachment_id=attachment_id, conversation_id=conversation_id, ) if not attachment or not attachment.thumbnail_path: raise AttachmentNotFoundException(f"{attachment_id}/thumbnail") return FileResponse( path=attachment.thumbnail_path, media_type="image/jpeg", ) # ============================================================================ # Helper Functions # ============================================================================ def _get_other_participant_name(conversation, customer_id: int) -> str: """Get the name of the other participant (the vendor user).""" for participant in conversation.participants: if participant.participant_type == ParticipantType.VENDOR: # Get vendor user name from models.database.user import User user = ( User.query.filter_by(id=participant.participant_id).first() if hasattr(User, "query") else None ) if user: return f"{user.first_name} {user.last_name}" return "Shop Support" return "Shop Support" def _get_sender_name(message) -> str: """Get sender name for a message.""" if message.sender_type == ParticipantType.CUSTOMER: from models.database.customer import Customer customer = ( Customer.query.filter_by(id=message.sender_id).first() if hasattr(Customer, "query") else None ) if customer: return f"{customer.first_name} {customer.last_name}" return "Customer" elif message.sender_type == ParticipantType.VENDOR: from models.database.user import User user = ( User.query.filter_by(id=message.sender_id).first() if hasattr(User, "query") else None ) if user: return f"{user.first_name} {user.last_name}" return "Shop Support" elif message.sender_type == ParticipantType.ADMIN: return "Platform Support" return "Unknown"