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 @@
+
+
+
+ Contact supportMessages
+
View your conversations with the shop
++ You don't have any conversations yet. +
+