diff --git a/app/api/v1/vendor/__init__.py b/app/api/v1/vendor/__init__.py index c4389df7..cde7dd00 100644 --- a/app/api/v1/vendor/__init__.py +++ b/app/api/v1/vendor/__init__.py @@ -24,6 +24,7 @@ from . import ( letzshop, marketplace, media, + messages, notifications, order_item_exceptions, orders, @@ -68,6 +69,7 @@ router.include_router(letzshop.router, tags=["vendor-letzshop"]) router.include_router(payments.router, tags=["vendor-payments"]) router.include_router(media.router, tags=["vendor-media"]) router.include_router(notifications.router, tags=["vendor-notifications"]) +router.include_router(messages.router, tags=["vendor-messages"]) router.include_router(analytics.router, tags=["vendor-analytics"]) # Content pages management diff --git a/app/api/v1/vendor/messages.py b/app/api/v1/vendor/messages.py new file mode 100644 index 00000000..d9843fad --- /dev/null +++ b/app/api/v1/vendor/messages.py @@ -0,0 +1,639 @@ +# app/api/v1/vendor/messages.py +""" +Vendor messaging endpoints. + +Provides endpoints for: +- Viewing conversations (vendor_customer and admin_vendor channels) +- Sending and receiving messages +- Managing conversation status +- File attachments + +Uses get_current_vendor_api dependency which guarantees token_vendor_id is present. +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_vendor_api +from app.core.database import get_db +from app.services.message_attachment_service import message_attachment_service +from app.services.messaging_service import messaging_service +from models.database.message import ConversationType, ParticipantType +from models.database.user import User +from models.schema.message import ( + CloseConversationResponse, + ConversationCreate, + ConversationDetailResponse, + ConversationListResponse, + ConversationSummary, + MarkReadResponse, + MessageResponse, + NotificationPreferencesUpdate, + ParticipantInfo, + ParticipantResponse, + RecipientListResponse, + RecipientOption, + ReopenConversationResponse, + UnreadCountResponse, +) +from models.schema.message import AttachmentResponse + +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, vendor_id: int +) -> ConversationSummary: + """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.VENDOR + and p.participant_id == current_user_id + and p.vendor_id == vendor_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.VENDOR, 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 + + return ConversationSummary( + id=conversation.id, + conversation_type=conversation.conversation_type, + subject=conversation.subject, + vendor_id=conversation.vendor_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, + ) + + +# ============================================================================ +# CONVERSATION LIST +# ============================================================================ + + +@router.get("", response_model=ConversationListResponse) +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_user: User = Depends(get_current_vendor_api), +) -> ConversationListResponse: + """List conversations for vendor (vendor_customer and admin_vendor channels).""" + vendor_id = current_user.token_vendor_id + + conversations, total, total_unread = messaging_service.list_conversations( + db=db, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + vendor_id=vendor_id, + conversation_type=conversation_type, + is_closed=is_closed, + skip=skip, + limit=limit, + ) + + return ConversationListResponse( + conversations=[ + _enrich_conversation_summary(db, c, current_user.id, vendor_id) + for c in conversations + ], + total=total, + total_unread=total_unread, + skip=skip, + limit=limit, + ) + + +@router.get("/unread-count", response_model=UnreadCountResponse) +def get_unread_count( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> UnreadCountResponse: + """Get total unread message count for header badge.""" + vendor_id = current_user.token_vendor_id + + count = messaging_service.get_unread_count( + db=db, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + vendor_id=vendor_id, + ) + return UnreadCountResponse(total_unread=count) + + +# ============================================================================ +# RECIPIENTS +# ============================================================================ + + +@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"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> RecipientListResponse: + """Get list of available recipients for compose modal.""" + from models.database.customer import Customer + + vendor_id = current_user.token_vendor_id + recipients = [] + + if recipient_type == ParticipantType.CUSTOMER: + # List customers for this vendor (for vendor_customer conversations) + query = db.query(Customer).filter( + Customer.vendor_id == vendor_id, + Customer.is_active == True, # noqa: E712 + ) + if search: + search_pattern = f"%{search}%" + query = query.filter( + (Customer.email.ilike(search_pattern)) + | (Customer.first_name.ilike(search_pattern)) + | (Customer.last_name.ilike(search_pattern)) + ) + + total = query.count() + results = query.offset(skip).limit(limit).all() + + for customer in results: + name = f"{customer.first_name or ''} {customer.last_name or ''}".strip() + recipients.append( + RecipientOption( + id=customer.id, + type=ParticipantType.CUSTOMER, + name=name or customer.email, + email=customer.email, + vendor_id=customer.vendor_id, + ) + ) + else: + # Vendors can't start conversations with admins - admins initiate those + total = 0 + + return RecipientListResponse(recipients=recipients, total=total) + + +# ============================================================================ +# CREATE CONVERSATION +# ============================================================================ + + +@router.post("", response_model=ConversationDetailResponse) +def create_conversation( + data: ConversationCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> ConversationDetailResponse: + """Create a new conversation with a customer.""" + vendor_id = current_user.token_vendor_id + + # Vendors can only create vendor_customer conversations + if data.conversation_type != ConversationType.VENDOR_CUSTOMER: + raise HTTPException( + status_code=400, + detail="Vendors can only create vendor_customer conversations", + ) + + if data.recipient_type != ParticipantType.CUSTOMER: + raise HTTPException( + status_code=400, + detail="vendor_customer conversations require a customer recipient", + ) + + # Create conversation + conversation = messaging_service.create_conversation( + db=db, + conversation_type=ConversationType.VENDOR_CUSTOMER, + subject=data.subject, + initiator_type=ParticipantType.VENDOR, + initiator_id=current_user.id, + recipient_type=ParticipantType.CUSTOMER, + recipient_id=data.recipient_id, + vendor_id=vendor_id, + initial_message=data.initial_message, + ) + db.commit() + db.refresh(conversation) + + logger.info( + f"Vendor {current_user.username} created conversation {conversation.id} " + f"with customer:{data.recipient_id}" + ) + + # Return full detail response + return _build_conversation_detail(db, conversation, current_user.id, vendor_id) + + +# ============================================================================ +# CONVERSATION DETAIL +# ============================================================================ + + +def _build_conversation_detail( + db: Session, conversation: Any, current_user_id: int, vendor_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.VENDOR + 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 vendor name if applicable + vendor_name = None + if conversation.vendor: + vendor_name = conversation.vendor.name + + return ConversationDetailResponse( + id=conversation.id, + conversation_type=conversation.conversation_type, + subject=conversation.subject, + vendor_id=conversation.vendor_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, + vendor_name=vendor_name, + ) + + +@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_user: User = Depends(get_current_vendor_api), +) -> ConversationDetailResponse: + """Get conversation detail with messages.""" + vendor_id = current_user.token_vendor_id + + conversation = messaging_service.get_conversation( + db=db, + conversation_id=conversation_id, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + ) + + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Verify vendor context + if conversation.vendor_id and conversation.vendor_id != vendor_id: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Mark as read if requested + if mark_read: + messaging_service.mark_conversation_read( + db=db, + conversation_id=conversation_id, + reader_type=ParticipantType.VENDOR, + reader_id=current_user.id, + ) + db.commit() + + return _build_conversation_detail(db, conversation, current_user.id, vendor_id) + + +# ============================================================================ +# SEND MESSAGE +# ============================================================================ + + +@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_user: User = Depends(get_current_vendor_api), +) -> MessageResponse: + """Send a message in a conversation, optionally with attachments.""" + vendor_id = current_user.token_vendor_id + + # Verify access + conversation = messaging_service.get_conversation( + db=db, + conversation_id=conversation_id, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + ) + + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Verify vendor context + if conversation.vendor_id and conversation.vendor_id != vendor_id: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.is_closed: + raise HTTPException( + status_code=400, detail="Cannot send messages to a closed conversation" + ) + + # 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 HTTPException(status_code=400, detail=str(e)) + + # Send message + message = messaging_service.send_message( + db=db, + conversation_id=conversation_id, + sender_type=ParticipantType.VENDOR, + sender_id=current_user.id, + content=content, + attachments=attachments if attachments else None, + ) + db.commit() + db.refresh(message) + + logger.info( + f"Vendor {current_user.username} sent message {message.id} " + f"in conversation {conversation_id}" + ) + + return _enrich_message(db, message) + + +# ============================================================================ +# CONVERSATION ACTIONS +# ============================================================================ + + +@router.post("/{conversation_id}/close", response_model=CloseConversationResponse) +def close_conversation( + conversation_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> CloseConversationResponse: + """Close a conversation.""" + vendor_id = current_user.token_vendor_id + + # Verify access first + conversation = messaging_service.get_conversation( + db=db, + conversation_id=conversation_id, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + ) + + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.vendor_id and conversation.vendor_id != vendor_id: + raise HTTPException(status_code=404, detail="Conversation not found") + + conversation = messaging_service.close_conversation( + db=db, + conversation_id=conversation_id, + closer_type=ParticipantType.VENDOR, + closer_id=current_user.id, + ) + + db.commit() + logger.info( + f"Vendor {current_user.username} closed conversation {conversation_id}" + ) + + return CloseConversationResponse( + success=True, + message="Conversation closed", + conversation_id=conversation_id, + ) + + +@router.post("/{conversation_id}/reopen", response_model=ReopenConversationResponse) +def reopen_conversation( + conversation_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> ReopenConversationResponse: + """Reopen a closed conversation.""" + vendor_id = current_user.token_vendor_id + + # Verify access first + conversation = messaging_service.get_conversation( + db=db, + conversation_id=conversation_id, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + ) + + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + + if conversation.vendor_id and conversation.vendor_id != vendor_id: + raise HTTPException(status_code=404, detail="Conversation not found") + + conversation = messaging_service.reopen_conversation( + db=db, + conversation_id=conversation_id, + opener_type=ParticipantType.VENDOR, + opener_id=current_user.id, + ) + + db.commit() + logger.info( + f"Vendor {current_user.username} reopened conversation {conversation_id}" + ) + + return ReopenConversationResponse( + success=True, + message="Conversation reopened", + conversation_id=conversation_id, + ) + + +@router.put("/{conversation_id}/read", response_model=MarkReadResponse) +def mark_read( + conversation_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> MarkReadResponse: + """Mark conversation as read.""" + success = messaging_service.mark_conversation_read( + db=db, + conversation_id=conversation_id, + reader_type=ParticipantType.VENDOR, + reader_id=current_user.id, + ) + db.commit() + + return MarkReadResponse( + success=success, + conversation_id=conversation_id, + unread_count=0, + ) + + +class PreferencesUpdateResponse(BaseModel): + """Response for preferences update.""" + success: bool + + +@router.put("/{conversation_id}/preferences", response_model=PreferencesUpdateResponse) +def update_preferences( + conversation_id: int, + preferences: NotificationPreferencesUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_vendor_api), +) -> PreferencesUpdateResponse: + """Update notification preferences for a conversation.""" + success = messaging_service.update_notification_preferences( + db=db, + conversation_id=conversation_id, + participant_type=ParticipantType.VENDOR, + participant_id=current_user.id, + email_notifications=preferences.email_notifications, + muted=preferences.muted, + ) + db.commit() + + return PreferencesUpdateResponse(success=success) diff --git a/app/routes/vendor_pages.py b/app/routes/vendor_pages.py index b9a53e14..c4390797 100644 --- a/app/routes/vendor_pages.py +++ b/app/routes/vendor_pages.py @@ -219,6 +219,59 @@ async def vendor_customers_page( ) +# ============================================================================ +# MESSAGING +# ============================================================================ + + +@router.get( + "/{vendor_code}/messages", response_class=HTMLResponse, include_in_schema=False +) +async def vendor_messages_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), +): + """ + Render messages page. + JavaScript loads conversations and messages via API. + """ + return templates.TemplateResponse( + "vendor/messages.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + }, + ) + + +@router.get( + "/{vendor_code}/messages/{conversation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def vendor_message_detail_page( + request: Request, + vendor_code: str = Path(..., description="Vendor code"), + conversation_id: int = Path(..., description="Conversation ID"), + current_user: User = Depends(get_current_vendor_from_cookie_or_header), +): + """ + Render message detail page. + Shows the full conversation thread. + """ + return templates.TemplateResponse( + "vendor/messages.html", + { + "request": request, + "user": current_user, + "vendor_code": vendor_code, + "conversation_id": conversation_id, + }, + ) + + # ============================================================================ # INVENTORY MANAGEMENT # ============================================================================ diff --git a/app/templates/vendor/messages.html b/app/templates/vendor/messages.html new file mode 100644 index 00000000..5a82baa7 --- /dev/null +++ b/app/templates/vendor/messages.html @@ -0,0 +1,293 @@ +{# app/templates/vendor/messages.html #} +{% extends "vendor/base.html" %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}Messages{% endblock %} + +{% block alpine_data %}vendorMessages({{ conversation_id or 'null' }}){% endblock %} + +{% block content %} +{{ page_header('Messages', buttons=[ + {'text': 'New Conversation', 'icon': 'plus', 'click': 'showComposeModal = true', 'primary': True} +]) }} + +{{ loading_state('Loading conversations...') }} + +{{ error_state('Error loading conversations') }} + + +
Loading...
+No conversations
+Start a new conversation with a customer
+Select a conversation
+Or start a new one with a customer
++ with +
+