refactor(routes): move storefront routes to their modules
Phase 4 of storefront restructure plan - move API routes from legacy app/api/v1/storefront/ to their respective modules: - customers: auth, profile, addresses routes combined into storefront.py - orders: order history viewing routes - checkout: order placement (place_order endpoint) - messaging: customer messaging routes Updated app/api/v1/storefront/__init__.py to import from modules: - cart_router from app.modules.cart - catalog_router from app.modules.catalog - checkout_router from app.modules.checkout - customers_router from app.modules.customers - orders_router from app.modules.orders - messaging_router from app.modules.messaging Legacy route files in app/api/v1/storefront/ can now be deleted in Phase 6. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
9
app/modules/messaging/routes/api/__init__.py
Normal file
9
app/modules/messaging/routes/api/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# app/modules/messaging/routes/api/__init__.py
|
||||
"""Messaging module API routes."""
|
||||
|
||||
from app.modules.messaging.routes.api.storefront import router as storefront_router
|
||||
|
||||
# Tag for OpenAPI documentation
|
||||
STOREFRONT_TAG = "Messages (Storefront)"
|
||||
|
||||
__all__ = ["storefront_router", "STOREFRONT_TAG"]
|
||||
529
app/modules/messaging/routes/api/storefront.py
Normal file
529
app/modules/messaging/routes/api/storefront.py
Normal file
@@ -0,0 +1,529 @@
|
||||
# app/modules/messaging/routes/api/storefront.py
|
||||
"""
|
||||
Messaging Module - Storefront API Routes
|
||||
|
||||
Authenticated endpoints for customer messaging:
|
||||
- View conversations
|
||||
- View/send messages
|
||||
- Download attachments
|
||||
- Mark as read
|
||||
|
||||
Uses vendor from middleware context (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 fastapi.responses import FileResponse
|
||||
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 (
|
||||
AttachmentNotFoundException,
|
||||
ConversationClosedException,
|
||||
ConversationNotFoundException,
|
||||
VendorNotFoundException,
|
||||
)
|
||||
from app.modules.messaging.models.message import ConversationType, ParticipantType
|
||||
from app.modules.messaging.schemas import (
|
||||
ConversationDetailResponse,
|
||||
ConversationListResponse,
|
||||
ConversationSummary,
|
||||
MessageResponse,
|
||||
UnreadCountResponse,
|
||||
)
|
||||
from app.modules.messaging.services import (
|
||||
message_attachment_service,
|
||||
messaging_service,
|
||||
)
|
||||
from models.database.customer import Customer
|
||||
|
||||
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) # authenticated
|
||||
def list_conversations(
|
||||
request: Request,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
status: Optional[str] = Query(None, pattern="^(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"[MESSAGING_STOREFRONT] list_conversations for customer {customer.id}",
|
||||
extra={
|
||||
"vendor_id": vendor.id,
|
||||
"customer_id": customer.id,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"status": status,
|
||||
},
|
||||
)
|
||||
|
||||
is_closed = None
|
||||
if status == "open":
|
||||
is_closed = False
|
||||
elif status == "closed":
|
||||
is_closed = True
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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"[MESSAGING_STOREFRONT] get_conversation {conversation_id} for customer {customer.id}",
|
||||
extra={
|
||||
"vendor_id": vendor.id,
|
||||
"customer_id": customer.id,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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/storefront/messages/{conversation_id}/attachments/{att.id}",
|
||||
"thumbnail_url": f"/api/v1/storefront/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"[MESSAGING_STOREFRONT] 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),
|
||||
},
|
||||
)
|
||||
|
||||
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))
|
||||
|
||||
if conversation.is_closed:
|
||||
raise ConversationClosedException(conversation_id)
|
||||
|
||||
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)
|
||||
|
||||
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"[MESSAGING_STOREFRONT] 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/storefront/messages/{conversation_id}/attachments/{att.id}",
|
||||
"thumbnail_url": f"/api/v1/storefront/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")
|
||||
|
||||
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.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
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))
|
||||
|
||||
attachment = message_attachment_service.get_attachment(
|
||||
db=db,
|
||||
attachment_id=attachment_id,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
if not attachment:
|
||||
raise AttachmentNotFoundException(attachment_id)
|
||||
|
||||
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.
|
||||
"""
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
|
||||
if not vendor:
|
||||
raise VendorNotFoundException("context", identifier_type="subdomain")
|
||||
|
||||
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))
|
||||
|
||||
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 AttachmentNotFoundException(f"{attachment_id}/thumbnail")
|
||||
|
||||
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:
|
||||
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