Some checks failed
- Pin ruff==0.8.4 in requirements-dev.txt (was >=0.8.4, CI got newer version with different import sorting rules) - Add ruff to .pre-commit-config.yaml with --fix to auto-sort imports on commit (prevents PyCharm import reordering from reaching CI) - Fix I001 import sorting in 6 files - Fix F401 unused import (sqlalchemy.Numeric in subscription.py) - Fix noqa false positive in validate_architecture.py comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
15 KiB
Python
496 lines
15 KiB
Python
# 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 store from middleware context (StoreContextMiddleware).
|
|
Requires customer authentication.
|
|
|
|
Customers can only:
|
|
- View their own store_customer conversations
|
|
- Reply to existing conversations
|
|
- Mark conversations as read
|
|
"""
|
|
|
|
import logging
|
|
|
|
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.modules.customers.schemas import CustomerContext
|
|
from app.modules.messaging.exceptions import (
|
|
AttachmentNotFoundException,
|
|
ConversationClosedException,
|
|
ConversationNotFoundException,
|
|
)
|
|
from app.modules.messaging.models.message import ( # API-007
|
|
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 app.modules.tenancy.exceptions import StoreNotFoundException
|
|
|
|
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: str | None = Query(None, pattern="^(open|closed)$"),
|
|
customer: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
List conversations for authenticated customer.
|
|
|
|
Customers only see their store_customer conversations.
|
|
|
|
Query Parameters:
|
|
- skip: Pagination offset
|
|
- limit: Max items to return
|
|
- status: Filter by open/closed
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[MESSAGING_STOREFRONT] list_conversations for customer {customer.id}",
|
|
extra={
|
|
"store_id": store.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,
|
|
store_id=store.id,
|
|
conversation_type=ConversationType.STORE_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(db, 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: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get total unread message count for header badge.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
count = messaging_service.get_unread_count(
|
|
db=db,
|
|
participant_type=ParticipantType.CUSTOMER,
|
|
participant_id=customer.id,
|
|
store_id=store.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: CustomerContext = 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.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[MESSAGING_STOREFRONT] get_conversation {conversation_id} for customer {customer.id}",
|
|
extra={
|
|
"store_id": store.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,
|
|
store_id=store.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(db, 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(db, 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: CustomerContext = 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.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
logger.debug(
|
|
f"[MESSAGING_STOREFRONT] send_message in {conversation_id} from customer {customer.id}",
|
|
extra={
|
|
"store_id": store.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,
|
|
store_id=store.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,
|
|
"store_id": store.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(db, 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: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Mark conversation as read."""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
conversation = messaging_service.get_conversation(
|
|
db=db,
|
|
conversation_id=conversation_id,
|
|
participant_type=ParticipantType.CUSTOMER,
|
|
participant_id=customer.id,
|
|
store_id=store.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: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Download a message attachment.
|
|
|
|
Validates that customer has access to the conversation.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
conversation = messaging_service.get_conversation(
|
|
db=db,
|
|
conversation_id=conversation_id,
|
|
participant_type=ParticipantType.CUSTOMER,
|
|
participant_id=customer.id,
|
|
store_id=store.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: CustomerContext = Depends(get_current_customer_api),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get thumbnail for an image attachment.
|
|
|
|
Validates that customer has access to the conversation.
|
|
"""
|
|
store = getattr(request.state, "store", None)
|
|
|
|
if not store:
|
|
raise StoreNotFoundException("context", identifier_type="subdomain")
|
|
|
|
conversation = messaging_service.get_conversation(
|
|
db=db,
|
|
conversation_id=conversation_id,
|
|
participant_type=ParticipantType.CUSTOMER,
|
|
participant_id=customer.id,
|
|
store_id=store.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(db: Session, conversation, customer_id: int) -> str:
|
|
"""Get the name of the other participant (the store user)."""
|
|
return messaging_service.get_other_participant_name(db, conversation, customer_id)
|
|
|
|
|
|
def _get_sender_name(db: Session, message) -> str:
|
|
"""Get sender name for a message."""
|
|
return messaging_service.get_sender_name(db, message)
|