Files
orion/app/modules/messaging/routes/api/storefront.py
Samir Boulahtit d7a0ff8818 refactor: complete module-driven architecture migration
This commit completes the migration to a fully module-driven architecture:

## Models Migration
- Moved all domain models from models/database/ to their respective modules:
  - tenancy: User, Admin, Vendor, Company, Platform, VendorDomain, etc.
  - cms: MediaFile, VendorTheme
  - messaging: Email, VendorEmailSettings, VendorEmailTemplate
  - core: AdminMenuConfig
- models/database/ now only contains Base and TimestampMixin (infrastructure)

## Schemas Migration
- Moved all domain schemas from models/schema/ to their respective modules:
  - tenancy: company, vendor, admin, team, vendor_domain
  - cms: media, image, vendor_theme
  - messaging: email
- models/schema/ now only contains base.py and auth.py (infrastructure)

## Routes Migration
- Moved admin routes from app/api/v1/admin/ to modules:
  - menu_config.py -> core module
  - modules.py -> tenancy module
  - module_config.py -> tenancy module
- app/api/v1/admin/ now only aggregates auto-discovered module routes

## Menu System
- Implemented module-driven menu system with MenuDiscoveryService
- Extended FrontendType enum: PLATFORM, ADMIN, VENDOR, STOREFRONT
- Added MenuItemDefinition and MenuSectionDefinition dataclasses
- Each module now defines its own menu items in definition.py
- MenuService integrates with MenuDiscoveryService for template rendering

## Documentation
- Updated docs/architecture/models-structure.md
- Updated docs/architecture/menu-management.md
- Updated architecture validation rules for new exceptions

## Architecture Validation
- Updated MOD-019 rule to allow base.py in models/schema/
- Created core module exceptions.py and schemas/ directory
- All validation errors resolved (only warnings remain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:02:56 +01:00

530 lines
16 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 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.modules.messaging.exceptions import (
AttachmentNotFoundException,
ConversationClosedException,
ConversationNotFoundException,
)
from app.modules.tenancy.exceptions import VendorNotFoundException
from app.modules.customers.schemas import CustomerContext
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,
)
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: CustomerContext = 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: CustomerContext = 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: 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.
"""
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: 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.
"""
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: CustomerContext = 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: CustomerContext = 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: 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.
"""
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 app.modules.tenancy.models 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 app.modules.customers.models 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 app.modules.tenancy.models 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"