From abceffb7b85c520ef2ba0058176d5411d26fff22 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 21 Dec 2025 14:10:22 +0100 Subject: [PATCH] feat: add vendor messaging interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vendor messages API endpoints (/api/v1/vendor/messages) - Add vendor messages page route (/vendor/{code}/messages) - Add messages.html template for vendor portal - Add messages.js Alpine component - Add Messages link to vendor sidebar under Sales section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/api/v1/vendor/__init__.py | 2 + app/api/v1/vendor/messages.py | 639 +++++++++++++++++++++ app/routes/vendor_pages.py | 53 ++ app/templates/vendor/messages.html | 293 ++++++++++ app/templates/vendor/partials/sidebar.html | 11 + static/vendor/js/messages.js | 402 +++++++++++++ 6 files changed, 1400 insertions(+) create mode 100644 app/api/v1/vendor/messages.py create mode 100644 app/templates/vendor/messages.html create mode 100644 static/vendor/js/messages.js 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') }} + + +
+ +
+ +
+
+ + +
+
+ + +
+ + + + +
    + +
+
+
+ + +
+ + + + + +
+
+ + +
+
+
+
+

New Conversation

+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+{% endblock %} + +{% block page_scripts %} + +{% endblock %} diff --git a/app/templates/vendor/partials/sidebar.html b/app/templates/vendor/partials/sidebar.html index 9d5361c8..a0b5a531 100644 --- a/app/templates/vendor/partials/sidebar.html +++ b/app/templates/vendor/partials/sidebar.html @@ -117,6 +117,17 @@ Follows same pattern as admin sidebar Customers +
  • + + + + Messages + +
  • diff --git a/static/vendor/js/messages.js b/static/vendor/js/messages.js new file mode 100644 index 00000000..af9789bf --- /dev/null +++ b/static/vendor/js/messages.js @@ -0,0 +1,402 @@ +/** + * Vendor Messages Page + * + * Handles the messaging interface for vendors including: + * - Conversation list with filtering + * - Message thread display + * - Sending messages + * - Creating new conversations with customers + */ + +const messagesLog = window.LogConfig?.createLogger('VENDOR-MESSAGES') || console; + +/** + * Vendor Messages Component + */ +function vendorMessages(initialConversationId = null) { + return { + // Loading states + loading: true, + loadingConversations: false, + loadingMessages: false, + sendingMessage: false, + creatingConversation: false, + + // Conversations state + conversations: [], + page: 1, + skip: 0, + limit: 20, + totalConversations: 0, + totalUnread: 0, + + // Filters + filters: { + conversation_type: '', + is_closed: '' + }, + + // Selected conversation + selectedConversationId: initialConversationId, + selectedConversation: null, + + // Reply form + replyContent: '', + + // Compose modal + showComposeModal: false, + compose: { + recipientId: null, + subject: '', + message: '' + }, + recipients: [], + + // Polling + pollInterval: null, + + /** + * Initialize component + */ + async init() { + messagesLog.debug('Initializing vendor messages page'); + await Promise.all([ + this.loadConversations(), + this.loadRecipients() + ]); + + if (this.selectedConversationId) { + await this.loadConversation(this.selectedConversationId); + } + + this.loading = false; + + // Start polling for new messages + this.startPolling(); + }, + + /** + * Start polling for updates + */ + startPolling() { + this.pollInterval = setInterval(async () => { + if (this.selectedConversationId && !document.hidden) { + await this.refreshCurrentConversation(); + } + await this.updateUnreadCount(); + }, 30000); + }, + + /** + * Stop polling + */ + destroy() { + if (this.pollInterval) { + clearInterval(this.pollInterval); + } + }, + + // ============================================================================ + // CONVERSATIONS LIST + // ============================================================================ + + /** + * Load conversations with current filters + */ + async loadConversations() { + this.loadingConversations = true; + try { + this.skip = (this.page - 1) * this.limit; + const params = new URLSearchParams(); + params.append('skip', this.skip); + params.append('limit', this.limit); + + if (this.filters.conversation_type) { + params.append('conversation_type', this.filters.conversation_type); + } + if (this.filters.is_closed !== '') { + params.append('is_closed', this.filters.is_closed); + } + + const response = await apiClient.get(`/vendor/messages?${params}`); + this.conversations = response.conversations || []; + this.totalConversations = response.total || 0; + this.totalUnread = response.total_unread || 0; + + messagesLog.debug(`Loaded ${this.conversations.length} conversations`); + } catch (error) { + messagesLog.error('Failed to load conversations:', error); + window.showToast?.('Failed to load conversations', 'error'); + } finally { + this.loadingConversations = false; + } + }, + + /** + * Update unread count + */ + async updateUnreadCount() { + try { + const response = await apiClient.get('/vendor/messages/unread-count'); + this.totalUnread = response.total_unread || 0; + } catch (error) { + messagesLog.error('Failed to update unread count:', error); + } + }, + + /** + * Select a conversation + */ + async selectConversation(conversationId) { + if (this.selectedConversationId === conversationId) return; + + this.selectedConversationId = conversationId; + await this.loadConversation(conversationId); + }, + + /** + * Load conversation detail + */ + async loadConversation(conversationId) { + this.loadingMessages = true; + try { + const response = await apiClient.get(`/vendor/messages/${conversationId}?mark_read=true`); + this.selectedConversation = response; + + // Update unread count in list + const conv = this.conversations.find(c => c.id === conversationId); + if (conv) { + this.totalUnread = Math.max(0, this.totalUnread - conv.unread_count); + conv.unread_count = 0; + } + + // Scroll to bottom + await this.$nextTick(); + this.scrollToBottom(); + } catch (error) { + messagesLog.error('Failed to load conversation:', error); + window.showToast?.('Failed to load conversation', 'error'); + } finally { + this.loadingMessages = false; + } + }, + + /** + * Refresh current conversation + */ + async refreshCurrentConversation() { + if (!this.selectedConversationId) return; + + try { + const response = await apiClient.get(`/vendor/messages/${this.selectedConversationId}?mark_read=true`); + const oldCount = this.selectedConversation?.messages?.length || 0; + const newCount = response.messages?.length || 0; + + this.selectedConversation = response; + + if (newCount > oldCount) { + await this.$nextTick(); + this.scrollToBottom(); + } + } catch (error) { + messagesLog.error('Failed to refresh conversation:', error); + } + }, + + /** + * Scroll messages to bottom + */ + scrollToBottom() { + const container = this.$refs.messagesContainer; + if (container) { + container.scrollTop = container.scrollHeight; + } + }, + + // ============================================================================ + // SENDING MESSAGES + // ============================================================================ + + /** + * Send a message + */ + async sendMessage() { + if (!this.replyContent.trim()) return; + if (!this.selectedConversationId) return; + + this.sendingMessage = true; + try { + const formData = new FormData(); + formData.append('content', this.replyContent); + + const response = await fetch(`/api/v1/vendor/messages/${this.selectedConversationId}/messages`, { + method: 'POST', + body: formData, + headers: { + 'Authorization': `Bearer ${window.getAuthToken?.() || ''}` + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to send message'); + } + + const message = await response.json(); + + // Add to messages + if (this.selectedConversation) { + this.selectedConversation.messages.push(message); + this.selectedConversation.message_count++; + } + + // Clear form + this.replyContent = ''; + + // Scroll to bottom + await this.$nextTick(); + this.scrollToBottom(); + } catch (error) { + messagesLog.error('Failed to send message:', error); + window.showToast?.(error.message || 'Failed to send message', 'error'); + } finally { + this.sendingMessage = false; + } + }, + + // ============================================================================ + // CONVERSATION ACTIONS + // ============================================================================ + + /** + * Close conversation + */ + async closeConversation() { + if (!confirm('Close this conversation?')) return; + + try { + await apiClient.post(`/vendor/messages/${this.selectedConversationId}/close`); + + if (this.selectedConversation) { + this.selectedConversation.is_closed = true; + } + + const conv = this.conversations.find(c => c.id === this.selectedConversationId); + if (conv) conv.is_closed = true; + + window.showToast?.('Conversation closed', 'success'); + } catch (error) { + messagesLog.error('Failed to close conversation:', error); + window.showToast?.('Failed to close conversation', 'error'); + } + }, + + /** + * Reopen conversation + */ + async reopenConversation() { + try { + await apiClient.post(`/vendor/messages/${this.selectedConversationId}/reopen`); + + if (this.selectedConversation) { + this.selectedConversation.is_closed = false; + } + + const conv = this.conversations.find(c => c.id === this.selectedConversationId); + if (conv) conv.is_closed = false; + + window.showToast?.('Conversation reopened', 'success'); + } catch (error) { + messagesLog.error('Failed to reopen conversation:', error); + window.showToast?.('Failed to reopen conversation', 'error'); + } + }, + + // ============================================================================ + // CREATE CONVERSATION + // ============================================================================ + + /** + * Load recipients (customers) + */ + async loadRecipients() { + try { + const response = await apiClient.get('/vendor/messages/recipients?recipient_type=customer&limit=100'); + this.recipients = response.recipients || []; + } catch (error) { + messagesLog.error('Failed to load recipients:', error); + } + }, + + /** + * Create new conversation + */ + async createConversation() { + if (!this.compose.recipientId || !this.compose.subject.trim()) return; + + this.creatingConversation = true; + try { + const response = await apiClient.post('/vendor/messages', { + conversation_type: 'vendor_customer', + subject: this.compose.subject, + recipient_type: 'customer', + recipient_id: parseInt(this.compose.recipientId), + initial_message: this.compose.message || null + }); + + // Close modal and reset + this.showComposeModal = false; + this.compose = { recipientId: null, subject: '', message: '' }; + + // Reload and select + await this.loadConversations(); + await this.selectConversation(response.id); + + window.showToast?.('Conversation created', 'success'); + } catch (error) { + messagesLog.error('Failed to create conversation:', error); + window.showToast?.(error.message || 'Failed to create conversation', 'error'); + } finally { + this.creatingConversation = false; + } + }, + + // ============================================================================ + // HELPERS + // ============================================================================ + + getOtherParticipantName() { + if (!this.selectedConversation?.participants) return 'Unknown'; + const other = this.selectedConversation.participants.find(p => p.participant_type !== 'vendor'); + return other?.participant_info?.name || 'Unknown'; + }, + + formatRelativeTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return 'Now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h`; + if (diff < 172800) return 'Yesterday'; + return date.toLocaleDateString(); + }, + + formatTime(dateString) { + if (!dateString) return ''; + const date = new Date(dateString); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + } + }; +} + +// Make available globally +window.vendorMessages = vendorMessages;