diff --git a/app/api/v1/shop/__init__.py b/app/api/v1/shop/__init__.py index 05a36993..be4259b2 100644 --- a/app/api/v1/shop/__init__.py +++ b/app/api/v1/shop/__init__.py @@ -21,7 +21,7 @@ Authentication: from fastapi import APIRouter # Import shop routers -from . import auth, carts, content_pages, orders, products +from . import auth, carts, content_pages, messages, orders, products # Create shop router router = APIRouter() @@ -42,6 +42,9 @@ router.include_router(carts.router, tags=["shop-cart"]) # Orders (authenticated) router.include_router(orders.router, tags=["shop-orders"]) +# Messages (authenticated) +router.include_router(messages.router, tags=["shop-messages"]) + # Content pages (public) router.include_router( content_pages.router, prefix="/content-pages", tags=["shop-content-pages"] diff --git a/app/api/v1/shop/messages.py b/app/api/v1/shop/messages.py new file mode 100644 index 00000000..c91a5341 --- /dev/null +++ b/app/api/v1/shop/messages.py @@ -0,0 +1,543 @@ +# 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 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) +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: + from fastapi import HTTPException + + raise HTTPException( + status_code=400, + detail="Cannot send messages to a closed conversation", + ) + + # 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 import HTTPException + 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 HTTPException(status_code=404, detail="Attachment not found") + + 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 import HTTPException + 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 HTTPException(status_code=404, detail="Thumbnail not found") + + 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" diff --git a/app/routes/shop_pages.py b/app/routes/shop_pages.py index 364a31e8..ea5d58fe 100644 --- a/app/routes/shop_pages.py +++ b/app/routes/shop_pages.py @@ -581,6 +581,65 @@ async def shop_settings_page( ) +@router.get("/account/messages", response_class=HTMLResponse, include_in_schema=False) +async def shop_messages_page( + request: Request, + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render customer messages page. + View and reply to conversations with the vendor. + Requires customer authentication. + """ + logger.debug( + "[SHOP_HANDLER] shop_messages_page REACHED", + extra={ + "path": request.url.path, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "shop/account/messages.html", get_shop_context(request, db=db, user=current_customer) + ) + + +@router.get( + "/account/messages/{conversation_id}", + response_class=HTMLResponse, + include_in_schema=False, +) +async def shop_message_detail_page( + request: Request, + conversation_id: int = Path(..., description="Conversation ID"), + current_customer: Customer = Depends(get_current_customer_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render message conversation detail page. + Shows the full conversation thread. + Requires customer authentication. + """ + logger.debug( + "[SHOP_HANDLER] shop_message_detail_page REACHED", + extra={ + "path": request.url.path, + "conversation_id": conversation_id, + "vendor": getattr(request.state, "vendor", "NOT SET"), + "context": getattr(request.state, "context_type", "NOT SET"), + }, + ) + + return templates.TemplateResponse( + "shop/account/messages.html", + get_shop_context( + request, db=db, user=current_customer, conversation_id=conversation_id + ), + ) + + # ============================================================================ # DYNAMIC CONTENT PAGES (CMS) # ============================================================================ diff --git a/app/templates/shop/account/dashboard.html b/app/templates/shop/account/dashboard.html index 3cd760ae..d1363ef1 100644 --- a/app/templates/shop/account/dashboard.html +++ b/app/templates/shop/account/dashboard.html @@ -71,6 +71,30 @@ + + + +
+
+ + + + +
+
+

Messages

+

Contact support

+
+
+
+

+
+
diff --git a/app/templates/shop/account/messages.html b/app/templates/shop/account/messages.html new file mode 100644 index 00000000..2825a525 --- /dev/null +++ b/app/templates/shop/account/messages.html @@ -0,0 +1,495 @@ +{# app/templates/shop/account/messages.html #} +{% extends "shop/base.html" %} + +{% block title %}Messages - {{ vendor.name }}{% endblock %} + +{% block alpine_data %}shopMessages(){% endblock %} + +{% block content %} +
+ +
+
+ +

Messages

+

View your conversations with the shop

+
+
+ + + + + + +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %}