feat: add messaging system database models and core services
- Add Conversation, ConversationParticipant, Message, MessageAttachment models - Add ConversationType enum (admin_vendor, vendor_customer, admin_customer) - Add ParticipantType enum (admin, vendor, customer) - Add Alembic migration for messaging tables - Add MessagingService for conversation/message operations - Add MessageAttachmentService for file upload handling - Add message-related exceptions (ConversationNotFoundException, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,14 @@ from .letzshop import (
|
||||
VendorLetzshopCredentials,
|
||||
)
|
||||
from .marketplace_import_job import MarketplaceImportError, MarketplaceImportJob
|
||||
from .message import (
|
||||
Conversation,
|
||||
ConversationParticipant,
|
||||
ConversationType,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
ParticipantType,
|
||||
)
|
||||
from .marketplace_product import (
|
||||
DigitalDeliveryMethod,
|
||||
MarketplaceProduct,
|
||||
@@ -96,4 +104,11 @@ __all__ = [
|
||||
"LetzshopFulfillmentQueue",
|
||||
"LetzshopSyncLog",
|
||||
"LetzshopHistoricalImportJob",
|
||||
# Messaging
|
||||
"Conversation",
|
||||
"ConversationParticipant",
|
||||
"ConversationType",
|
||||
"Message",
|
||||
"MessageAttachment",
|
||||
"ParticipantType",
|
||||
]
|
||||
|
||||
267
models/database/message.py
Normal file
267
models/database/message.py
Normal file
@@ -0,0 +1,267 @@
|
||||
# models/database/message.py
|
||||
"""
|
||||
Messaging system database models.
|
||||
|
||||
Supports three communication channels:
|
||||
- Admin <-> Vendor
|
||||
- Vendor <-> Customer
|
||||
- Admin <-> Customer
|
||||
|
||||
Multi-tenant isolation is enforced via vendor_id for conversations
|
||||
involving customers.
|
||||
"""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class ConversationType(str, enum.Enum):
|
||||
"""Defines the three supported conversation channels."""
|
||||
|
||||
ADMIN_VENDOR = "admin_vendor"
|
||||
VENDOR_CUSTOMER = "vendor_customer"
|
||||
ADMIN_CUSTOMER = "admin_customer"
|
||||
|
||||
|
||||
class ParticipantType(str, enum.Enum):
|
||||
"""Type of participant in a conversation."""
|
||||
|
||||
ADMIN = "admin" # User with role="admin"
|
||||
VENDOR = "vendor" # User with role="vendor" (via VendorUser)
|
||||
CUSTOMER = "customer" # Customer model
|
||||
|
||||
|
||||
class Conversation(Base, TimestampMixin):
|
||||
"""
|
||||
Represents a threaded conversation between participants.
|
||||
|
||||
Multi-tenancy: vendor_id is required for vendor_customer and admin_customer
|
||||
conversations to ensure customer data isolation.
|
||||
"""
|
||||
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Conversation type determines participant structure
|
||||
conversation_type = Column(
|
||||
Enum(ConversationType),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Subject line for the conversation thread
|
||||
subject = Column(String(500), nullable=False)
|
||||
|
||||
# For vendor_customer and admin_customer conversations
|
||||
# Required for multi-tenant data isolation
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Status flags
|
||||
is_closed = Column(Boolean, default=False, nullable=False)
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
closed_by_type = Column(Enum(ParticipantType), nullable=True)
|
||||
closed_by_id = Column(Integer, nullable=True)
|
||||
|
||||
# Last activity tracking for sorting
|
||||
last_message_at = Column(DateTime, nullable=True, index=True)
|
||||
message_count = Column(Integer, default=0, nullable=False)
|
||||
|
||||
# Relationships
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
participants = relationship(
|
||||
"ConversationParticipant",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
messages = relationship(
|
||||
"Message",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Message.created_at",
|
||||
)
|
||||
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("ix_conversations_type_vendor", "conversation_type", "vendor_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Conversation(id={self.id}, type='{self.conversation_type.value}', "
|
||||
f"subject='{self.subject[:30]}...')>"
|
||||
)
|
||||
|
||||
|
||||
class ConversationParticipant(Base, TimestampMixin):
|
||||
"""
|
||||
Links participants (users or customers) to conversations.
|
||||
|
||||
Polymorphic relationship:
|
||||
- participant_type="admin" or "vendor": references users.id
|
||||
- participant_type="customer": references customers.id
|
||||
"""
|
||||
|
||||
__tablename__ = "conversation_participants"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
conversation_id = Column(
|
||||
Integer,
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Polymorphic participant reference
|
||||
participant_type = Column(Enum(ParticipantType), nullable=False)
|
||||
participant_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# For vendor participants, track which vendor they represent
|
||||
vendor_id = Column(
|
||||
Integer,
|
||||
ForeignKey("vendors.id"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Unread tracking per participant
|
||||
unread_count = Column(Integer, default=0, nullable=False)
|
||||
last_read_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Notification preferences for this conversation
|
||||
email_notifications = Column(Boolean, default=True, nullable=False)
|
||||
muted = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
conversation = relationship("Conversation", back_populates="participants")
|
||||
vendor = relationship("Vendor", foreign_keys=[vendor_id])
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"conversation_id",
|
||||
"participant_type",
|
||||
"participant_id",
|
||||
name="uq_conversation_participant",
|
||||
),
|
||||
Index(
|
||||
"ix_participant_lookup",
|
||||
"participant_type",
|
||||
"participant_id",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ConversationParticipant(conversation_id={self.conversation_id}, "
|
||||
f"type='{self.participant_type.value}', id={self.participant_id})>"
|
||||
)
|
||||
|
||||
|
||||
class Message(Base, TimestampMixin):
|
||||
"""
|
||||
Individual message within a conversation thread.
|
||||
|
||||
Sender polymorphism follows same pattern as participant.
|
||||
"""
|
||||
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
conversation_id = Column(
|
||||
Integer,
|
||||
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Polymorphic sender reference
|
||||
sender_type = Column(Enum(ParticipantType), nullable=False)
|
||||
sender_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
# Message content
|
||||
content = Column(Text, nullable=False)
|
||||
|
||||
# System messages (e.g., "conversation closed")
|
||||
is_system_message = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Soft delete for moderation
|
||||
is_deleted = Column(Boolean, default=False, nullable=False)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
deleted_by_type = Column(Enum(ParticipantType), nullable=True)
|
||||
deleted_by_id = Column(Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
attachments = relationship(
|
||||
"MessageAttachment",
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_messages_conversation_created", "conversation_id", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<Message(id={self.id}, conversation_id={self.conversation_id}, "
|
||||
f"sender={self.sender_type.value}:{self.sender_id})>"
|
||||
)
|
||||
|
||||
|
||||
class MessageAttachment(Base, TimestampMixin):
|
||||
"""
|
||||
File attachments for messages.
|
||||
|
||||
Files are stored in platform storage (local/S3) with references here.
|
||||
"""
|
||||
|
||||
__tablename__ = "message_attachments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_id = Column(
|
||||
Integer,
|
||||
ForeignKey("messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# File metadata
|
||||
filename = Column(String(255), nullable=False)
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_path = Column(String(1000), nullable=False) # Storage path
|
||||
file_size = Column(Integer, nullable=False) # Size in bytes
|
||||
mime_type = Column(String(100), nullable=False)
|
||||
|
||||
# For image attachments
|
||||
is_image = Column(Boolean, default=False, nullable=False)
|
||||
image_width = Column(Integer, nullable=True)
|
||||
image_height = Column(Integer, nullable=True)
|
||||
thumbnail_path = Column(String(1000), nullable=True)
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="attachments")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MessageAttachment(id={self.id}, filename='{self.original_filename}')>"
|
||||
@@ -8,6 +8,7 @@ from . import (
|
||||
inventory,
|
||||
marketplace_import_job,
|
||||
marketplace_product,
|
||||
message,
|
||||
stats,
|
||||
vendor,
|
||||
)
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"base",
|
||||
"auth",
|
||||
"marketplace_product",
|
||||
"message",
|
||||
"inventory",
|
||||
"vendor",
|
||||
"marketplace_import_job",
|
||||
|
||||
308
models/schema/message.py
Normal file
308
models/schema/message.py
Normal file
@@ -0,0 +1,308 @@
|
||||
# models/schema/message.py
|
||||
"""
|
||||
Pydantic schemas for the messaging system.
|
||||
|
||||
Supports three communication channels:
|
||||
- Admin <-> Vendor
|
||||
- Vendor <-> Customer
|
||||
- Admin <-> Customer
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from models.database.message import ConversationType, ParticipantType
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Attachment Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
"""Schema for message attachment in responses."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
original_filename: str
|
||||
file_size: int
|
||||
mime_type: str
|
||||
is_image: bool
|
||||
image_width: int | None = None
|
||||
image_height: int | None = None
|
||||
download_url: str | None = None
|
||||
thumbnail_url: str | None = None
|
||||
|
||||
@property
|
||||
def file_size_display(self) -> str:
|
||||
"""Human-readable file size."""
|
||||
if self.file_size < 1024:
|
||||
return f"{self.file_size} B"
|
||||
elif self.file_size < 1024 * 1024:
|
||||
return f"{self.file_size / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{self.file_size / 1024 / 1024:.1f} MB"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Message Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
"""Schema for sending a new message."""
|
||||
|
||||
content: str = Field(..., min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
"""Schema for a single message in responses."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
conversation_id: int
|
||||
sender_type: ParticipantType
|
||||
sender_id: int
|
||||
content: str
|
||||
is_system_message: bool
|
||||
is_deleted: bool
|
||||
created_at: datetime
|
||||
|
||||
# Enriched sender info (populated by API)
|
||||
sender_name: str | None = None
|
||||
sender_email: str | None = None
|
||||
|
||||
# Attachments
|
||||
attachments: list[AttachmentResponse] = []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Participant Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ParticipantInfo(BaseModel):
|
||||
"""Schema for participant information."""
|
||||
|
||||
id: int
|
||||
type: ParticipantType
|
||||
name: str
|
||||
email: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
"""Schema for conversation participant in responses."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
participant_type: ParticipantType
|
||||
participant_id: int
|
||||
unread_count: int
|
||||
last_read_at: datetime | None
|
||||
email_notifications: bool
|
||||
muted: bool
|
||||
|
||||
# Enriched info (populated by API)
|
||||
participant_info: ParticipantInfo | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Conversation Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
"""Schema for creating a new conversation."""
|
||||
|
||||
conversation_type: ConversationType
|
||||
subject: str = Field(..., min_length=1, max_length=500)
|
||||
recipient_type: ParticipantType
|
||||
recipient_id: int
|
||||
vendor_id: int | None = None
|
||||
initial_message: str | None = Field(None, min_length=1, max_length=10000)
|
||||
|
||||
|
||||
class ConversationSummary(BaseModel):
|
||||
"""Schema for conversation in list views."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
conversation_type: ConversationType
|
||||
subject: str
|
||||
vendor_id: int | None = None
|
||||
is_closed: bool
|
||||
closed_at: datetime | None
|
||||
last_message_at: datetime | None
|
||||
message_count: int
|
||||
created_at: datetime
|
||||
|
||||
# Unread count for current user (from participant)
|
||||
unread_count: int = 0
|
||||
|
||||
# Other participant info (enriched by API)
|
||||
other_participant: ParticipantInfo | None = None
|
||||
|
||||
# Last message preview
|
||||
last_message_preview: str | None = None
|
||||
|
||||
|
||||
class ConversationDetailResponse(BaseModel):
|
||||
"""Schema for full conversation detail with messages."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
conversation_type: ConversationType
|
||||
subject: str
|
||||
vendor_id: int | None = None
|
||||
is_closed: bool
|
||||
closed_at: datetime | None
|
||||
closed_by_type: ParticipantType | None = None
|
||||
closed_by_id: int | None = None
|
||||
last_message_at: datetime | None
|
||||
message_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# Participants with enriched info
|
||||
participants: list[ParticipantResponse] = []
|
||||
|
||||
# Messages ordered by created_at
|
||||
messages: list[MessageResponse] = []
|
||||
|
||||
# Current user's unread count
|
||||
unread_count: int = 0
|
||||
|
||||
# Vendor info if applicable
|
||||
vendor_name: str | None = None
|
||||
|
||||
|
||||
class ConversationListResponse(BaseModel):
|
||||
"""Schema for paginated conversation list."""
|
||||
|
||||
conversations: list[ConversationSummary]
|
||||
total: int
|
||||
total_unread: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unread Count Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
"""Schema for unread message count (for header badge)."""
|
||||
|
||||
total_unread: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Notification Preferences Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class NotificationPreferencesUpdate(BaseModel):
|
||||
"""Schema for updating notification preferences."""
|
||||
|
||||
email_notifications: bool | None = None
|
||||
muted: bool | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Conversation Action Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class CloseConversationResponse(BaseModel):
|
||||
"""Response after closing a conversation."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
conversation_id: int
|
||||
|
||||
|
||||
class ReopenConversationResponse(BaseModel):
|
||||
"""Response after reopening a conversation."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
conversation_id: int
|
||||
|
||||
|
||||
class MarkReadResponse(BaseModel):
|
||||
"""Response after marking conversation as read."""
|
||||
|
||||
success: bool
|
||||
conversation_id: int
|
||||
unread_count: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Recipient Selection Schemas (for compose modal)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class RecipientOption(BaseModel):
|
||||
"""Schema for a selectable recipient in compose modal."""
|
||||
|
||||
id: int
|
||||
type: ParticipantType
|
||||
name: str
|
||||
email: str | None = None
|
||||
vendor_id: int | None = None # For vendor users
|
||||
vendor_name: str | None = None
|
||||
|
||||
|
||||
class RecipientListResponse(BaseModel):
|
||||
"""Schema for list of available recipients."""
|
||||
|
||||
recipients: list[RecipientOption]
|
||||
total: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Admin-specific Schemas
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AdminConversationSummary(ConversationSummary):
|
||||
"""Extended conversation summary with vendor info for admin views."""
|
||||
|
||||
vendor_name: str | None = None
|
||||
vendor_code: str | None = None
|
||||
|
||||
|
||||
class AdminConversationListResponse(BaseModel):
|
||||
"""Schema for admin conversation list with vendor info."""
|
||||
|
||||
conversations: list[AdminConversationSummary]
|
||||
total: int
|
||||
total_unread: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
|
||||
class AdminMessageStats(BaseModel):
|
||||
"""Messaging statistics for admin dashboard."""
|
||||
|
||||
total_conversations: int = 0
|
||||
open_conversations: int = 0
|
||||
closed_conversations: int = 0
|
||||
total_messages: int = 0
|
||||
|
||||
# By type
|
||||
admin_vendor_conversations: int = 0
|
||||
vendor_customer_conversations: int = 0
|
||||
admin_customer_conversations: int = 0
|
||||
|
||||
# Unread
|
||||
unread_admin: int = 0
|
||||
Reference in New Issue
Block a user