# app/modules/messaging/routes/api/storefront.py """ Messaging Module - Storefront API Routes Authenticated endpoints for customer messaging: - View conversations - View/send messages - Download attachments - Mark as read Uses store from middleware context (StoreContextMiddleware). Requires customer authentication. Customers can only: - View their own store_customer conversations - Reply to existing conversations - Mark conversations as read """ import logging from fastapi import APIRouter, Depends, File, Form, Path, Query, Request, UploadFile from fastapi.responses import FileResponse 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.modules.customers.schemas import CustomerContext from app.modules.messaging.exceptions import ( AttachmentNotFoundException, ConversationClosedException, ConversationNotFoundException, ) from app.modules.messaging.models.message import ( # API-007 ConversationType, ParticipantType, ) from app.modules.messaging.schemas import ( ConversationDetailResponse, ConversationListResponse, ConversationSummary, MessageResponse, UnreadCountResponse, ) from app.modules.messaging.services import ( message_attachment_service, messaging_service, ) from app.modules.tenancy.exceptions import StoreNotFoundException 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: str | None = Query(None, pattern="^(open|closed)$"), customer: CustomerContext = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ List conversations for authenticated customer. Customers only see their store_customer conversations. Query Parameters: - skip: Pagination offset - limit: Max items to return - status: Filter by open/closed """ store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") logger.debug( f"[MESSAGING_STOREFRONT] list_conversations for customer {customer.id}", extra={ "store_id": store.id, "customer_id": customer.id, "skip": skip, "limit": limit, "status": status, }, ) is_closed = None if status == "open": is_closed = False elif status == "closed": is_closed = True conversations, total = messaging_service.list_conversations( db=db, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.id, conversation_type=ConversationType.STORE_CUSTOMER, is_closed=is_closed, skip=skip, limit=limit, ) 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(db, 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: CustomerContext = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Get total unread message count for header badge. """ store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") count = messaging_service.get_unread_count( db=db, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.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: CustomerContext = 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. """ store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") logger.debug( f"[MESSAGING_STOREFRONT] get_conversation {conversation_id} for customer {customer.id}", extra={ "store_id": store.id, "customer_id": customer.id, "conversation_id": conversation_id, }, ) conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.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, ) 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(db, 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/storefront/messages/{conversation_id}/attachments/{att.id}", "thumbnail_url": f"/api/v1/storefront/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(db, 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: CustomerContext = 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. """ store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") logger.debug( f"[MESSAGING_STOREFRONT] send_message in {conversation_id} from customer {customer.id}", extra={ "store_id": store.id, "customer_id": customer.id, "conversation_id": conversation_id, "attachment_count": len(attachments), }, ) conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) if conversation.is_closed: raise ConversationClosedException(conversation_id) 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) 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"[MESSAGING_STOREFRONT] Message sent in conversation {conversation_id}", extra={ "message_id": message.id, "customer_id": customer.id, "store_id": store.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(db, 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/storefront/messages/{conversation_id}/attachments/{att.id}", "thumbnail_url": f"/api/v1/storefront/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: CustomerContext = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """Mark conversation as read.""" store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.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: CustomerContext = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Download a message attachment. Validates that customer has access to the conversation. """ store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) 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: CustomerContext = Depends(get_current_customer_api), db: Session = Depends(get_db), ): """ Get thumbnail for an image attachment. Validates that customer has access to the conversation. """ store = getattr(request.state, "store", None) if not store: raise StoreNotFoundException("context", identifier_type="subdomain") conversation = messaging_service.get_conversation( db=db, conversation_id=conversation_id, participant_type=ParticipantType.CUSTOMER, participant_id=customer.id, store_id=store.id, ) if not conversation: raise ConversationNotFoundException(str(conversation_id)) 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(db: Session, conversation, customer_id: int) -> str: """Get the name of the other participant (the store user).""" return messaging_service.get_other_participant_name(db, conversation, customer_id) def _get_sender_name(db: Session, message) -> str: """Get sender name for a message.""" return messaging_service.get_sender_name(db, message)