Eliminate all 103 errors and 96 warnings from the architecture validator: Phase 1 - Validator rules & YAML: - Add NAM-001/NAM-002 exceptions for module-scoped router/service files - Fix API-004 to detect # public comments on decorator lines - Add module-specific exception bases to EXC-004 valid_bases - Exclude storefront files from AUTH-004 store context check - Add SVC-006 exceptions for loyalty service atomic commits - Fix _get_rule() to search naming_rules and auth_rules categories - Use plain # CODE comments instead of # noqa: CODE for custom rules Phase 2 - Billing module (5 route files): - Move _resolve_store_to_merchant to subscription_service - Move tier/feature queries to feature_service, admin_subscription_service - Extract 22 inline Pydantic schemas to billing/schemas/billing.py - Replace all HTTPException with domain exceptions Phase 3 - Loyalty module (4 routes + points_service): - Add 7 domain exceptions (Apple auth, enrollment, device registration) - Add service methods to card_service, program_service, apple_wallet_service - Move all db.query() from routes to service layer - Fix SVC-001: replace HTTPException in points_service with domain exception Phase 4 - Remaining modules: - tenancy: move store stats queries to admin_service - cms: move platform resolution to content_page_service, add NoPlatformSubscriptionException - messaging: move user/customer lookups to messaging_service - Add ConfigDict(from_attributes=True) to ContentPageResponse Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
493 lines
15 KiB
Python
493 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 ConversationType, ParticipantType # API-007
|
|
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)
|