feat: add customer messaging interface in shop
- Add shop messages API endpoints (/api/v1/shop/messages) - Add shop messages page routes (/shop/account/messages) - Add messages.html template for customer account - Add Messages card to account dashboard with unread badge - Customers can view/reply to vendor_customer conversations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
|
||||
543
app/api/v1/shop/messages.py
Normal file
543
app/api/v1/shop/messages.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user