# app/modules/messaging/models/message.py """ Messaging system database models. Supports three communication channels: - Admin <-> Store - Store <-> Customer - Admin <-> Customer Multi-tenant isolation is enforced via store_id for conversations involving customers. """ import enum 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_STORE = "admin_store" STORE_CUSTOMER = "store_customer" ADMIN_CUSTOMER = "admin_customer" class ParticipantType(str, enum.Enum): """Type of participant in a conversation.""" ADMIN = "admin" # Platform admin user (super_admin or platform_admin) STORE = "store" # Store team user (merchant_owner or store_member) CUSTOMER = "customer" # Customer model def _enum_values(enum_class): """Extract enum values for SQLAlchemy Enum column.""" return [e.value for e in enum_class] class Conversation(Base, TimestampMixin): """ Represents a threaded conversation between participants. Multi-tenancy: store_id is required for store_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, values_callable=_enum_values), nullable=False, index=True, ) # Subject line for the conversation thread subject = Column(String(500), nullable=False) # For store_customer and admin_customer conversations # Required for multi-tenant data isolation store_id = Column( Integer, ForeignKey("stores.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, values_callable=_enum_values), 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 store = relationship("Store", foreign_keys=[store_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_store", "conversation_type", "store_id"), ) def __repr__(self) -> str: return ( f"" ) class ConversationParticipant(Base, TimestampMixin): """ Links participants (users or customers) to conversations. Polymorphic relationship: - participant_type="admin" or "store": 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, values_callable=_enum_values), nullable=False) participant_id = Column(Integer, nullable=False, index=True) # For store participants, track which store they represent store_id = Column( Integer, ForeignKey("stores.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") store = relationship("Store", foreign_keys=[store_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"" ) 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, values_callable=_enum_values), 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, values_callable=_enum_values), 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"" ) 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""