refactor: migrate modules from re-exports to canonical implementations

Move actual code implementations into module directories:
- orders: 5 services, 4 models, order/invoice schemas
- inventory: 3 services, 2 models, 30+ schemas
- customers: 3 services, 2 models, customer schemas
- messaging: 3 services, 2 models, message/notification schemas
- monitoring: background_tasks_service
- marketplace: 5+ services including letzshop submodule
- dev_tools: code_quality_service, test_runner_service
- billing: billing_service
- contracts: definition.py

Legacy files in app/services/, models/database/, models/schema/
now re-export from canonical module locations for backwards
compatibility. Architecture validator passes with 0 errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 21:28:56 +01:00
parent b5a803cde8
commit de83875d0a
99 changed files with 19413 additions and 15357 deletions

View File

@@ -2,10 +2,10 @@
"""
Messaging module database models.
Re-exports messaging-related models from their source locations.
This module contains the canonical implementations of messaging-related models.
"""
from models.database.message import (
from app.modules.messaging.models.message import (
Conversation,
ConversationParticipant,
ConversationType,
@@ -13,7 +13,7 @@ from models.database.message import (
MessageAttachment,
ParticipantType,
)
from models.database.admin import AdminNotification
from app.modules.messaging.models.admin_notification import AdminNotification
__all__ = [
"Conversation",

View File

@@ -0,0 +1,54 @@
# app/modules/messaging/models/admin_notification.py
"""
Admin notification database model.
This model handles admin-specific notifications for system alerts and warnings.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
JSON,
String,
Text,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class AdminNotification(Base, TimestampMixin):
"""
Admin-specific notifications for system alerts and warnings.
Different from vendor/customer notifications - these are for platform
administrators to track system health and issues requiring attention.
"""
__tablename__ = "admin_notifications"
id = Column(Integer, primary_key=True, index=True)
type = Column(
String(50), nullable=False, index=True
) # system_alert, vendor_issue, import_failure
priority = Column(
String(20), default="normal", index=True
) # low, normal, high, critical
title = Column(String(200), nullable=False)
message = Column(Text, nullable=False)
is_read = Column(Boolean, default=False, index=True)
read_at = Column(DateTime, nullable=True)
read_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
action_required = Column(Boolean, default=False, index=True)
action_url = Column(String(500)) # Link to relevant admin page
notification_metadata = Column(JSON) # Additional contextual data
# Relationships
read_by = relationship("User", foreign_keys=[read_by_user_id])
def __repr__(self):
return f"<AdminNotification(id={self.id}, type='{self.type}', priority='{self.priority}')>"

View File

@@ -0,0 +1,272 @@
# app/modules/messaging/models/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
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: 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, values_callable=_enum_values),
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, 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
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, values_callable=_enum_values), 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, 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"<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}')>"

View File

@@ -2,27 +2,106 @@
"""
Messaging module Pydantic schemas.
Re-exports messaging-related schemas from their source locations.
This module contains the canonical implementations of messaging-related schemas.
"""
from models.schema.message import (
ConversationResponse,
ConversationListResponse,
MessageResponse,
MessageCreate,
from app.modules.messaging.schemas.message import (
# Attachment schemas
AttachmentResponse,
# Message schemas
MessageCreate,
MessageResponse,
# Participant schemas
ParticipantInfo,
ParticipantResponse,
# Conversation schemas
ConversationCreate,
ConversationSummary,
ConversationDetailResponse,
ConversationListResponse,
ConversationResponse,
# Unread count
UnreadCountResponse,
# Notification preferences
NotificationPreferencesUpdate,
# Conversation actions
CloseConversationResponse,
ReopenConversationResponse,
MarkReadResponse,
# Recipient selection
RecipientOption,
RecipientListResponse,
# Admin schemas
AdminConversationSummary,
AdminConversationListResponse,
AdminMessageStats,
)
from models.schema.notification import (
from app.modules.messaging.schemas.notification import (
# Response schemas
MessageResponse as NotificationMessageResponse,
UnreadCountResponse as NotificationUnreadCountResponse,
# Notification schemas
NotificationResponse,
NotificationListResponse,
# Settings schemas
NotificationSettingsResponse,
NotificationSettingsUpdate,
# Template schemas
NotificationTemplateResponse,
NotificationTemplateListResponse,
NotificationTemplateUpdate,
# Test notification
TestNotificationRequest,
# Alert statistics
AlertStatisticsResponse,
)
__all__ = [
"ConversationResponse",
"ConversationListResponse",
"MessageResponse",
"MessageCreate",
# Attachment schemas
"AttachmentResponse",
# Message schemas
"MessageCreate",
"MessageResponse",
# Participant schemas
"ParticipantInfo",
"ParticipantResponse",
# Conversation schemas
"ConversationCreate",
"ConversationSummary",
"ConversationDetailResponse",
"ConversationListResponse",
"ConversationResponse",
# Unread count
"UnreadCountResponse",
# Notification preferences
"NotificationPreferencesUpdate",
# Conversation actions
"CloseConversationResponse",
"ReopenConversationResponse",
"MarkReadResponse",
# Recipient selection
"RecipientOption",
"RecipientListResponse",
# Admin schemas
"AdminConversationSummary",
"AdminConversationListResponse",
"AdminMessageStats",
# Notification response schemas
"NotificationMessageResponse",
"NotificationUnreadCountResponse",
# Notification schemas
"NotificationResponse",
"NotificationListResponse",
# Settings schemas
"NotificationSettingsResponse",
"NotificationSettingsUpdate",
# Template schemas
"NotificationTemplateResponse",
"NotificationTemplateListResponse",
"NotificationTemplateUpdate",
# Test notification
"TestNotificationRequest",
# Alert statistics
"AlertStatisticsResponse",
]

View File

@@ -0,0 +1,312 @@
# app/modules/messaging/schemas/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 app.modules.messaging.models.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
# Backward compatibility alias
ConversationResponse = ConversationDetailResponse
# ============================================================================
# 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

View File

@@ -0,0 +1,152 @@
# app/modules/messaging/schemas/notification.py
"""
Notification Pydantic schemas for API validation and responses.
This module provides schemas for:
- Vendor notifications (list, read, delete)
- Notification settings management
- Notification email templates
- Unread counts and statistics
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ============================================================================
# SHARED RESPONSE SCHEMAS
# ============================================================================
class MessageResponse(BaseModel):
"""Generic message response for simple operations."""
message: str
class UnreadCountResponse(BaseModel):
"""Response for unread notification count."""
unread_count: int
message: str | None = None
# ============================================================================
# NOTIFICATION SCHEMAS
# ============================================================================
class NotificationResponse(BaseModel):
"""Single notification response."""
id: int
type: str
title: str
message: str
is_read: bool
read_at: datetime | None = None
priority: str = "normal"
action_url: str | None = None
metadata: dict[str, Any] | None = None
created_at: datetime
model_config = {"from_attributes": True}
class NotificationListResponse(BaseModel):
"""Paginated list of notifications."""
notifications: list[NotificationResponse] = []
total: int = 0
unread_count: int = 0
message: str | None = None
# ============================================================================
# NOTIFICATION SETTINGS SCHEMAS
# ============================================================================
class NotificationSettingsResponse(BaseModel):
"""Notification preferences response."""
email_notifications: bool = True
in_app_notifications: bool = True
notification_types: dict[str, bool] = Field(default_factory=dict)
message: str | None = None
class NotificationSettingsUpdate(BaseModel):
"""Request model for updating notification settings."""
email_notifications: bool | None = None
in_app_notifications: bool | None = None
notification_types: dict[str, bool] | None = None
# ============================================================================
# NOTIFICATION TEMPLATE SCHEMAS
# ============================================================================
class NotificationTemplateResponse(BaseModel):
"""Single notification template response."""
id: int
name: str
type: str
subject: str
body_html: str | None = None
body_text: str | None = None
variables: list[str] = Field(default_factory=list)
is_active: bool = True
created_at: datetime
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class NotificationTemplateListResponse(BaseModel):
"""List of notification templates."""
templates: list[NotificationTemplateResponse] = []
message: str | None = None
class NotificationTemplateUpdate(BaseModel):
"""Request model for updating notification template."""
subject: str | None = Field(None, max_length=200)
body_html: str | None = None
body_text: str | None = None
is_active: bool | None = None
# ============================================================================
# TEST NOTIFICATION SCHEMA
# ============================================================================
class TestNotificationRequest(BaseModel):
"""Request model for sending test notification."""
template_id: int | None = Field(None, description="Template to use")
email: str | None = Field(None, description="Override recipient email")
notification_type: str = Field(
default="test", description="Type of notification to send"
)
# ============================================================================
# ADMIN ALERT STATISTICS SCHEMA
# ============================================================================
class AlertStatisticsResponse(BaseModel):
"""Response for alert statistics."""
total_alerts: int = 0
active_alerts: int = 0
critical_alerts: int = 0
resolved_today: int = 0

View File

@@ -2,24 +2,29 @@
"""
Messaging module services.
Re-exports messaging-related services from their source locations.
This module contains the canonical implementations of messaging-related services.
"""
from app.services.messaging_service import (
from app.modules.messaging.services.messaging_service import (
messaging_service,
MessagingService,
)
from app.services.message_attachment_service import (
from app.modules.messaging.services.message_attachment_service import (
message_attachment_service,
MessageAttachmentService,
)
from app.services.admin_notification_service import (
from app.modules.messaging.services.admin_notification_service import (
admin_notification_service,
AdminNotificationService,
platform_alert_service,
PlatformAlertService,
# Constants
NotificationType,
Priority,
AlertType,
Severity,
)
# Note: notification_service is a placeholder - not yet implemented
__all__ = [
"messaging_service",
"MessagingService",
@@ -27,4 +32,11 @@ __all__ = [
"MessageAttachmentService",
"admin_notification_service",
"AdminNotificationService",
"platform_alert_service",
"PlatformAlertService",
# Constants
"NotificationType",
"Priority",
"AlertType",
"Severity",
]

View File

@@ -0,0 +1,702 @@
# app/modules/messaging/services/admin_notification_service.py
"""
Admin notification service.
Provides functionality for:
- Creating and managing admin notifications
- Managing platform alerts
- Notification statistics and queries
"""
import logging
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import and_, case, func
from sqlalchemy.orm import Session
from app.modules.messaging.models.admin_notification import AdminNotification
from models.database.admin import PlatformAlert
from models.schema.admin import AdminNotificationCreate, PlatformAlertCreate
logger = logging.getLogger(__name__)
# ============================================================================
# NOTIFICATION TYPES
# ============================================================================
class NotificationType:
"""Notification type constants."""
SYSTEM_ALERT = "system_alert"
IMPORT_FAILURE = "import_failure"
EXPORT_FAILURE = "export_failure"
ORDER_SYNC_FAILURE = "order_sync_failure"
VENDOR_ISSUE = "vendor_issue"
CUSTOMER_MESSAGE = "customer_message"
VENDOR_MESSAGE = "vendor_message"
SECURITY_ALERT = "security_alert"
PERFORMANCE_ALERT = "performance_alert"
ORDER_EXCEPTION = "order_exception"
CRITICAL_ERROR = "critical_error"
class Priority:
"""Priority level constants."""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
CRITICAL = "critical"
class AlertType:
"""Platform alert type constants."""
SECURITY = "security"
PERFORMANCE = "performance"
CAPACITY = "capacity"
INTEGRATION = "integration"
DATABASE = "database"
SYSTEM = "system"
class Severity:
"""Alert severity constants."""
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
# ============================================================================
# ADMIN NOTIFICATION SERVICE
# ============================================================================
class AdminNotificationService:
"""Service for managing admin notifications."""
def create_notification(
self,
db: Session,
notification_type: str,
title: str,
message: str,
priority: str = Priority.NORMAL,
action_required: bool = False,
action_url: str | None = None,
metadata: dict[str, Any] | None = None,
) -> AdminNotification:
"""
Create a new admin notification.
Args:
db: Database session
notification_type: Type of notification
title: Notification title
message: Notification message
priority: Priority level (low, normal, high, critical)
action_required: Whether action is required
action_url: URL to relevant admin page
metadata: Additional contextual data
Returns:
Created AdminNotification
"""
notification = AdminNotification(
type=notification_type,
title=title,
message=message,
priority=priority,
action_required=action_required,
action_url=action_url,
notification_metadata=metadata,
)
db.add(notification)
db.flush()
logger.info(
f"Created notification: {notification_type} - {title} (priority: {priority})"
)
return notification
def create_from_schema(
self,
db: Session,
data: AdminNotificationCreate,
) -> AdminNotification:
"""Create notification from Pydantic schema."""
return self.create_notification(
db=db,
notification_type=data.type,
title=data.title,
message=data.message,
priority=data.priority,
action_required=data.action_required,
action_url=data.action_url,
metadata=data.metadata,
)
def get_notifications(
self,
db: Session,
priority: str | None = None,
is_read: bool | None = None,
notification_type: str | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[AdminNotification], int, int]:
"""
Get paginated admin notifications.
Returns:
Tuple of (notifications, total_count, unread_count)
"""
query = db.query(AdminNotification)
# Apply filters
if priority:
query = query.filter(AdminNotification.priority == priority)
if is_read is not None:
query = query.filter(AdminNotification.is_read == is_read)
if notification_type:
query = query.filter(AdminNotification.type == notification_type)
# Get counts
total = query.count()
unread_count = (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.count()
)
# Get paginated results ordered by priority and date
priority_order = case(
(AdminNotification.priority == "critical", 1),
(AdminNotification.priority == "high", 2),
(AdminNotification.priority == "normal", 3),
(AdminNotification.priority == "low", 4),
else_=5,
)
notifications = (
query.order_by(
AdminNotification.is_read, # Unread first
priority_order,
AdminNotification.created_at.desc(),
)
.offset(skip)
.limit(limit)
.all()
)
return notifications, total, unread_count
def get_unread_count(self, db: Session) -> int:
"""Get count of unread notifications."""
return (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.count()
)
def get_recent_notifications(
self,
db: Session,
limit: int = 5,
) -> list[AdminNotification]:
"""Get recent unread notifications for header dropdown."""
priority_order = case(
(AdminNotification.priority == "critical", 1),
(AdminNotification.priority == "high", 2),
(AdminNotification.priority == "normal", 3),
(AdminNotification.priority == "low", 4),
else_=5,
)
return (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.order_by(priority_order, AdminNotification.created_at.desc())
.limit(limit)
.all()
)
def mark_as_read(
self,
db: Session,
notification_id: int,
user_id: int,
) -> AdminNotification | None:
"""Mark a notification as read."""
notification = (
db.query(AdminNotification)
.filter(AdminNotification.id == notification_id)
.first()
)
if notification and not notification.is_read:
notification.is_read = True
notification.read_at = datetime.utcnow()
notification.read_by_user_id = user_id
db.flush()
return notification
def mark_all_as_read(
self,
db: Session,
user_id: int,
) -> int:
"""Mark all unread notifications as read. Returns count of updated."""
now = datetime.utcnow()
count = (
db.query(AdminNotification)
.filter(AdminNotification.is_read == False) # noqa: E712
.update(
{
AdminNotification.is_read: True,
AdminNotification.read_at: now,
AdminNotification.read_by_user_id: user_id,
}
)
)
db.flush()
return count
def delete_old_notifications(
self,
db: Session,
days: int = 30,
) -> int:
"""Delete notifications older than specified days."""
cutoff = datetime.utcnow() - timedelta(days=days)
count = (
db.query(AdminNotification)
.filter(
and_(
AdminNotification.is_read == True, # noqa: E712
AdminNotification.created_at < cutoff,
)
)
.delete()
)
db.flush()
return count
def delete_notification(
self,
db: Session,
notification_id: int,
) -> bool:
"""
Delete a notification by ID.
Returns:
True if notification was deleted, False if not found.
"""
notification = (
db.query(AdminNotification)
.filter(AdminNotification.id == notification_id)
.first()
)
if notification:
db.delete(notification)
db.flush()
logger.info(f"Deleted notification {notification_id}")
return True
return False
# =========================================================================
# CONVENIENCE METHODS FOR CREATING SPECIFIC NOTIFICATION TYPES
# =========================================================================
def notify_import_failure(
self,
db: Session,
vendor_name: str,
job_id: int,
error_message: str,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for import job failure."""
return self.create_notification(
db=db,
notification_type=NotificationType.IMPORT_FAILURE,
title=f"Import Failed: {vendor_name}",
message=error_message,
priority=Priority.HIGH,
action_required=True,
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs"
if vendor_id
else "/admin/marketplace",
metadata={"vendor_name": vendor_name, "job_id": job_id, "vendor_id": vendor_id},
)
def notify_order_sync_failure(
self,
db: Session,
vendor_name: str,
error_message: str,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for order sync failure."""
return self.create_notification(
db=db,
notification_type=NotificationType.ORDER_SYNC_FAILURE,
title=f"Order Sync Failed: {vendor_name}",
message=error_message,
priority=Priority.HIGH,
action_required=True,
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=jobs"
if vendor_id
else "/admin/marketplace/letzshop",
metadata={"vendor_name": vendor_name, "vendor_id": vendor_id},
)
def notify_order_exception(
self,
db: Session,
vendor_name: str,
order_number: str,
exception_count: int,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for order item exceptions."""
return self.create_notification(
db=db,
notification_type=NotificationType.ORDER_EXCEPTION,
title=f"Order Exception: {order_number}",
message=f"{exception_count} item(s) need attention for order {order_number} ({vendor_name})",
priority=Priority.NORMAL,
action_required=True,
action_url=f"/admin/marketplace/letzshop?vendor_id={vendor_id}&tab=exceptions"
if vendor_id
else "/admin/marketplace/letzshop",
metadata={
"vendor_name": vendor_name,
"order_number": order_number,
"exception_count": exception_count,
"vendor_id": vendor_id,
},
)
def notify_critical_error(
self,
db: Session,
error_type: str,
error_message: str,
details: dict[str, Any] | None = None,
) -> AdminNotification:
"""Create notification for critical application errors."""
return self.create_notification(
db=db,
notification_type=NotificationType.CRITICAL_ERROR,
title=f"Critical Error: {error_type}",
message=error_message,
priority=Priority.CRITICAL,
action_required=True,
action_url="/admin/logs",
metadata=details,
)
def notify_vendor_issue(
self,
db: Session,
vendor_name: str,
issue_type: str,
message: str,
vendor_id: int | None = None,
) -> AdminNotification:
"""Create notification for vendor-related issues."""
return self.create_notification(
db=db,
notification_type=NotificationType.VENDOR_ISSUE,
title=f"Vendor Issue: {vendor_name}",
message=message,
priority=Priority.HIGH,
action_required=True,
action_url=f"/admin/vendors/{vendor_id}" if vendor_id else "/admin/vendors",
metadata={
"vendor_name": vendor_name,
"issue_type": issue_type,
"vendor_id": vendor_id,
},
)
def notify_security_alert(
self,
db: Session,
title: str,
message: str,
details: dict[str, Any] | None = None,
) -> AdminNotification:
"""Create notification for security-related alerts."""
return self.create_notification(
db=db,
notification_type=NotificationType.SECURITY_ALERT,
title=title,
message=message,
priority=Priority.CRITICAL,
action_required=True,
action_url="/admin/audit",
metadata=details,
)
# ============================================================================
# PLATFORM ALERT SERVICE
# ============================================================================
class PlatformAlertService:
"""Service for managing platform-wide alerts."""
def create_alert(
self,
db: Session,
alert_type: str,
severity: str,
title: str,
description: str | None = None,
affected_vendors: list[int] | None = None,
affected_systems: list[str] | None = None,
auto_generated: bool = True,
) -> PlatformAlert:
"""Create a new platform alert."""
now = datetime.utcnow()
alert = PlatformAlert(
alert_type=alert_type,
severity=severity,
title=title,
description=description,
affected_vendors=affected_vendors,
affected_systems=affected_systems,
auto_generated=auto_generated,
first_occurred_at=now,
last_occurred_at=now,
)
db.add(alert)
db.flush()
logger.info(f"Created platform alert: {alert_type} - {title} ({severity})")
return alert
def create_from_schema(
self,
db: Session,
data: PlatformAlertCreate,
) -> PlatformAlert:
"""Create alert from Pydantic schema."""
return self.create_alert(
db=db,
alert_type=data.alert_type,
severity=data.severity,
title=data.title,
description=data.description,
affected_vendors=data.affected_vendors,
affected_systems=data.affected_systems,
auto_generated=data.auto_generated,
)
def get_alerts(
self,
db: Session,
severity: str | None = None,
alert_type: str | None = None,
is_resolved: bool | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[PlatformAlert], int, int, int]:
"""
Get paginated platform alerts.
Returns:
Tuple of (alerts, total_count, active_count, critical_count)
"""
query = db.query(PlatformAlert)
# Apply filters
if severity:
query = query.filter(PlatformAlert.severity == severity)
if alert_type:
query = query.filter(PlatformAlert.alert_type == alert_type)
if is_resolved is not None:
query = query.filter(PlatformAlert.is_resolved == is_resolved)
# Get counts
total = query.count()
active_count = (
db.query(PlatformAlert)
.filter(PlatformAlert.is_resolved == False) # noqa: E712
.count()
)
critical_count = (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.is_resolved == False, # noqa: E712
PlatformAlert.severity == Severity.CRITICAL,
)
)
.count()
)
# Get paginated results
severity_order = case(
(PlatformAlert.severity == "critical", 1),
(PlatformAlert.severity == "error", 2),
(PlatformAlert.severity == "warning", 3),
(PlatformAlert.severity == "info", 4),
else_=5,
)
alerts = (
query.order_by(
PlatformAlert.is_resolved, # Unresolved first
severity_order,
PlatformAlert.last_occurred_at.desc(),
)
.offset(skip)
.limit(limit)
.all()
)
return alerts, total, active_count, critical_count
def resolve_alert(
self,
db: Session,
alert_id: int,
user_id: int,
resolution_notes: str | None = None,
) -> PlatformAlert | None:
"""Resolve a platform alert."""
alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first()
if alert and not alert.is_resolved:
alert.is_resolved = True
alert.resolved_at = datetime.utcnow()
alert.resolved_by_user_id = user_id
alert.resolution_notes = resolution_notes
db.flush()
logger.info(f"Resolved platform alert {alert_id}")
return alert
def get_statistics(self, db: Session) -> dict[str, int]:
"""Get alert statistics."""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
total = db.query(PlatformAlert).count()
active = (
db.query(PlatformAlert)
.filter(PlatformAlert.is_resolved == False) # noqa: E712
.count()
)
critical = (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.is_resolved == False, # noqa: E712
PlatformAlert.severity == Severity.CRITICAL,
)
)
.count()
)
resolved_today = (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.is_resolved == True, # noqa: E712
PlatformAlert.resolved_at >= today_start,
)
)
.count()
)
return {
"total_alerts": total,
"active_alerts": active,
"critical_alerts": critical,
"resolved_today": resolved_today,
}
def increment_occurrence(
self,
db: Session,
alert_id: int,
) -> PlatformAlert | None:
"""Increment occurrence count for repeated alert."""
alert = db.query(PlatformAlert).filter(PlatformAlert.id == alert_id).first()
if alert:
alert.occurrence_count += 1
alert.last_occurred_at = datetime.utcnow()
db.flush()
return alert
def find_similar_active_alert(
self,
db: Session,
alert_type: str,
title: str,
) -> PlatformAlert | None:
"""Find an active alert with same type and title."""
return (
db.query(PlatformAlert)
.filter(
and_(
PlatformAlert.alert_type == alert_type,
PlatformAlert.title == title,
PlatformAlert.is_resolved == False, # noqa: E712
)
)
.first()
)
def create_or_increment_alert(
self,
db: Session,
alert_type: str,
severity: str,
title: str,
description: str | None = None,
affected_vendors: list[int] | None = None,
affected_systems: list[str] | None = None,
) -> PlatformAlert:
"""Create alert or increment occurrence if similar exists."""
existing = self.find_similar_active_alert(db, alert_type, title)
if existing:
self.increment_occurrence(db, existing.id)
return existing
return self.create_alert(
db=db,
alert_type=alert_type,
severity=severity,
title=title,
description=description,
affected_vendors=affected_vendors,
affected_systems=affected_systems,
)
# Singleton instances
admin_notification_service = AdminNotificationService()
platform_alert_service = PlatformAlertService()

View File

@@ -0,0 +1,225 @@
# app/modules/messaging/services/message_attachment_service.py
"""
Attachment handling service for messaging system.
Handles file upload, validation, storage, and retrieval.
"""
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy.orm import Session
from app.services.admin_settings_service import admin_settings_service
logger = logging.getLogger(__name__)
# Allowed MIME types for attachments
ALLOWED_MIME_TYPES = {
# Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
# Documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
# Archives
"application/zip",
# Text
"text/plain",
"text/csv",
}
IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
# Default max file size in MB
DEFAULT_MAX_FILE_SIZE_MB = 10
class MessageAttachmentService:
"""Service for handling message attachments."""
def __init__(self, storage_base: str = "uploads/messages"):
self.storage_base = storage_base
def get_max_file_size_bytes(self, db: Session) -> int:
"""Get maximum file size from platform settings."""
max_mb = admin_settings_service.get_setting_value(
db,
"message_attachment_max_size_mb",
default=DEFAULT_MAX_FILE_SIZE_MB,
)
try:
max_mb = int(max_mb)
except (TypeError, ValueError):
max_mb = DEFAULT_MAX_FILE_SIZE_MB
return max_mb * 1024 * 1024 # Convert to bytes
def validate_file_type(self, mime_type: str) -> bool:
"""Check if file type is allowed."""
return mime_type in ALLOWED_MIME_TYPES
def is_image(self, mime_type: str) -> bool:
"""Check if file is an image."""
return mime_type in IMAGE_MIME_TYPES
async def validate_and_store(
self,
db: Session,
file: UploadFile,
conversation_id: int,
) -> dict:
"""
Validate and store an uploaded file.
Returns dict with file metadata for MessageAttachment creation.
Raises:
ValueError: If file type or size is invalid
"""
# Validate MIME type
content_type = file.content_type or "application/octet-stream"
if not self.validate_file_type(content_type):
raise ValueError(
f"File type '{content_type}' not allowed. "
"Allowed types: images (JPEG, PNG, GIF, WebP), "
"PDF, Office documents, ZIP, text files."
)
# Read file content
content = await file.read()
file_size = len(content)
# Validate file size
max_size = self.get_max_file_size_bytes(db)
if file_size > max_size:
raise ValueError(
f"File size {file_size / 1024 / 1024:.1f}MB exceeds "
f"maximum allowed size of {max_size / 1024 / 1024:.1f}MB"
)
# Generate unique filename
original_filename = file.filename or "attachment"
ext = Path(original_filename).suffix.lower()
unique_filename = f"{uuid.uuid4().hex}{ext}"
# Create storage path: uploads/messages/YYYY/MM/conversation_id/filename
now = datetime.utcnow()
relative_path = os.path.join(
self.storage_base,
str(now.year),
f"{now.month:02d}",
str(conversation_id),
)
# Ensure directory exists
os.makedirs(relative_path, exist_ok=True)
# Full file path
file_path = os.path.join(relative_path, unique_filename)
# Write file
with open(file_path, "wb") as f:
f.write(content)
# Prepare metadata
is_image = self.is_image(content_type)
metadata = {
"filename": unique_filename,
"original_filename": original_filename,
"file_path": file_path,
"file_size": file_size,
"mime_type": content_type,
"is_image": is_image,
}
# Generate thumbnail for images
if is_image:
thumbnail_data = self._create_thumbnail(content, file_path)
metadata.update(thumbnail_data)
logger.info(
f"Stored attachment {unique_filename} for conversation {conversation_id} "
f"({file_size} bytes, type: {content_type})"
)
return metadata
def _create_thumbnail(self, content: bytes, original_path: str) -> dict:
"""Create thumbnail for image attachments."""
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(content))
width, height = img.size
# Create thumbnail
img.thumbnail((200, 200))
thumb_path = original_path.replace(".", "_thumb.")
img.save(thumb_path)
return {
"image_width": width,
"image_height": height,
"thumbnail_path": thumb_path,
}
except ImportError:
logger.warning("PIL not installed, skipping thumbnail generation")
return {}
except Exception as e:
logger.error(f"Failed to create thumbnail: {e}")
return {}
def delete_attachment(
self, file_path: str, thumbnail_path: str | None = None
) -> bool:
"""Delete attachment files from storage."""
try:
if os.path.exists(file_path):
os.remove(file_path)
logger.info(f"Deleted attachment file: {file_path}")
if thumbnail_path and os.path.exists(thumbnail_path):
os.remove(thumbnail_path)
logger.info(f"Deleted thumbnail: {thumbnail_path}")
return True
except Exception as e:
logger.error(f"Failed to delete attachment {file_path}: {e}")
return False
def get_download_url(self, file_path: str) -> str:
"""
Get download URL for an attachment.
For local storage, returns a relative path that can be served
by the static file handler or a dedicated download endpoint.
"""
# Convert local path to URL path
# Assumes files are served from /static/uploads or similar
return f"/static/{file_path}"
def get_file_content(self, file_path: str) -> bytes | None:
"""Read file content from storage."""
try:
if os.path.exists(file_path):
with open(file_path, "rb") as f:
return f.read()
return None
except Exception as e:
logger.error(f"Failed to read file {file_path}: {e}")
return None
# Singleton instance
message_attachment_service = MessageAttachmentService()

View File

@@ -0,0 +1,684 @@
# app/modules/messaging/services/messaging_service.py
"""
Messaging service for conversation and message management.
Provides functionality for:
- Creating conversations between different participant types
- Sending messages with attachments
- Managing read status and unread counts
- Conversation listing with filters
- Multi-tenant data isolation
"""
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, joinedload
from app.modules.messaging.models.message import (
Conversation,
ConversationParticipant,
ConversationType,
Message,
MessageAttachment,
ParticipantType,
)
from models.database.customer import Customer
from models.database.user import User
logger = logging.getLogger(__name__)
class MessagingService:
"""Service for managing conversations and messages."""
# =========================================================================
# CONVERSATION MANAGEMENT
# =========================================================================
def create_conversation(
self,
db: Session,
conversation_type: ConversationType,
subject: str,
initiator_type: ParticipantType,
initiator_id: int,
recipient_type: ParticipantType,
recipient_id: int,
vendor_id: int | None = None,
initial_message: str | None = None,
) -> Conversation:
"""
Create a new conversation between two participants.
Args:
db: Database session
conversation_type: Type of conversation channel
subject: Conversation subject line
initiator_type: Type of initiating participant
initiator_id: ID of initiating participant
recipient_type: Type of receiving participant
recipient_id: ID of receiving participant
vendor_id: Required for vendor_customer/admin_customer types
initial_message: Optional first message content
Returns:
Created Conversation object
"""
# Validate vendor_id requirement
if conversation_type in [
ConversationType.VENDOR_CUSTOMER,
ConversationType.ADMIN_CUSTOMER,
]:
if not vendor_id:
raise ValueError(
f"vendor_id required for {conversation_type.value} conversations"
)
# Create conversation
conversation = Conversation(
conversation_type=conversation_type,
subject=subject,
vendor_id=vendor_id,
)
db.add(conversation)
db.flush()
# Add participants
initiator_vendor_id = (
vendor_id if initiator_type == ParticipantType.VENDOR else None
)
recipient_vendor_id = (
vendor_id if recipient_type == ParticipantType.VENDOR else None
)
initiator = ConversationParticipant(
conversation_id=conversation.id,
participant_type=initiator_type,
participant_id=initiator_id,
vendor_id=initiator_vendor_id,
unread_count=0, # Initiator has read their own message
)
recipient = ConversationParticipant(
conversation_id=conversation.id,
participant_type=recipient_type,
participant_id=recipient_id,
vendor_id=recipient_vendor_id,
unread_count=1 if initial_message else 0,
)
db.add(initiator)
db.add(recipient)
db.flush()
# Add initial message if provided
if initial_message:
self.send_message(
db=db,
conversation_id=conversation.id,
sender_type=initiator_type,
sender_id=initiator_id,
content=initial_message,
_skip_unread_update=True, # Already set above
)
logger.info(
f"Created {conversation_type.value} conversation {conversation.id}: "
f"{initiator_type.value}:{initiator_id} -> {recipient_type.value}:{recipient_id}"
)
return conversation
def get_conversation(
self,
db: Session,
conversation_id: int,
participant_type: ParticipantType,
participant_id: int,
) -> Conversation | None:
"""
Get conversation if participant has access.
Validates that the requester is a participant.
"""
conversation = (
db.query(Conversation)
.options(
joinedload(Conversation.participants),
joinedload(Conversation.messages).joinedload(Message.attachments),
)
.filter(Conversation.id == conversation_id)
.first()
)
if not conversation:
return None
# Verify participant access
has_access = any(
p.participant_type == participant_type
and p.participant_id == participant_id
for p in conversation.participants
)
if not has_access:
logger.warning(
f"Access denied to conversation {conversation_id} for "
f"{participant_type.value}:{participant_id}"
)
return None
return conversation
def list_conversations(
self,
db: Session,
participant_type: ParticipantType,
participant_id: int,
vendor_id: int | None = None,
conversation_type: ConversationType | None = None,
is_closed: bool | None = None,
skip: int = 0,
limit: int = 20,
) -> tuple[list[Conversation], int, int]:
"""
List conversations for a participant with filters.
Returns:
Tuple of (conversations, total_count, total_unread)
"""
# Base query: conversations where user is a participant
query = (
db.query(Conversation)
.join(ConversationParticipant)
.filter(
and_(
ConversationParticipant.participant_type == participant_type,
ConversationParticipant.participant_id == participant_id,
)
)
)
# Multi-tenant filter for vendor users
if participant_type == ParticipantType.VENDOR and vendor_id:
query = query.filter(ConversationParticipant.vendor_id == vendor_id)
# Customer vendor isolation
if participant_type == ParticipantType.CUSTOMER and vendor_id:
query = query.filter(Conversation.vendor_id == vendor_id)
# Type filter
if conversation_type:
query = query.filter(Conversation.conversation_type == conversation_type)
# Status filter
if is_closed is not None:
query = query.filter(Conversation.is_closed == is_closed)
# Get total count
total = query.count()
# Get total unread across all conversations
unread_query = db.query(
func.sum(ConversationParticipant.unread_count)
).filter(
and_(
ConversationParticipant.participant_type == participant_type,
ConversationParticipant.participant_id == participant_id,
)
)
if participant_type == ParticipantType.VENDOR and vendor_id:
unread_query = unread_query.filter(
ConversationParticipant.vendor_id == vendor_id
)
total_unread = unread_query.scalar() or 0
# Get paginated results, ordered by last activity
conversations = (
query.options(joinedload(Conversation.participants))
.order_by(Conversation.last_message_at.desc().nullslast())
.offset(skip)
.limit(limit)
.all()
)
return conversations, total, total_unread
def close_conversation(
self,
db: Session,
conversation_id: int,
closer_type: ParticipantType,
closer_id: int,
) -> Conversation | None:
"""Close a conversation thread."""
conversation = self.get_conversation(
db, conversation_id, closer_type, closer_id
)
if not conversation:
return None
conversation.is_closed = True
conversation.closed_at = datetime.now(UTC)
conversation.closed_by_type = closer_type
conversation.closed_by_id = closer_id
# Add system message
self.send_message(
db=db,
conversation_id=conversation_id,
sender_type=closer_type,
sender_id=closer_id,
content="Conversation closed",
is_system_message=True,
)
db.flush()
return conversation
def reopen_conversation(
self,
db: Session,
conversation_id: int,
opener_type: ParticipantType,
opener_id: int,
) -> Conversation | None:
"""Reopen a closed conversation."""
conversation = self.get_conversation(
db, conversation_id, opener_type, opener_id
)
if not conversation:
return None
conversation.is_closed = False
conversation.closed_at = None
conversation.closed_by_type = None
conversation.closed_by_id = None
# Add system message
self.send_message(
db=db,
conversation_id=conversation_id,
sender_type=opener_type,
sender_id=opener_id,
content="Conversation reopened",
is_system_message=True,
)
db.flush()
return conversation
# =========================================================================
# MESSAGE MANAGEMENT
# =========================================================================
def send_message(
self,
db: Session,
conversation_id: int,
sender_type: ParticipantType,
sender_id: int,
content: str,
attachments: list[dict[str, Any]] | None = None,
is_system_message: bool = False,
_skip_unread_update: bool = False,
) -> Message:
"""
Send a message in a conversation.
Args:
db: Database session
conversation_id: Target conversation ID
sender_type: Type of sender
sender_id: ID of sender
content: Message text content
attachments: List of attachment dicts with file metadata
is_system_message: Whether this is a system-generated message
_skip_unread_update: Internal flag to skip unread increment
Returns:
Created Message object
"""
# Create message
message = Message(
conversation_id=conversation_id,
sender_type=sender_type,
sender_id=sender_id,
content=content,
is_system_message=is_system_message,
)
db.add(message)
db.flush()
# Add attachments if any
if attachments:
for att_data in attachments:
attachment = MessageAttachment(
message_id=message.id,
filename=att_data["filename"],
original_filename=att_data["original_filename"],
file_path=att_data["file_path"],
file_size=att_data["file_size"],
mime_type=att_data["mime_type"],
is_image=att_data.get("is_image", False),
image_width=att_data.get("image_width"),
image_height=att_data.get("image_height"),
thumbnail_path=att_data.get("thumbnail_path"),
)
db.add(attachment)
# Update conversation metadata
conversation = (
db.query(Conversation).filter(Conversation.id == conversation_id).first()
)
if conversation:
conversation.last_message_at = datetime.now(UTC)
conversation.message_count += 1
# Update unread counts for other participants
if not _skip_unread_update:
db.query(ConversationParticipant).filter(
and_(
ConversationParticipant.conversation_id == conversation_id,
or_(
ConversationParticipant.participant_type != sender_type,
ConversationParticipant.participant_id != sender_id,
),
)
).update(
{
ConversationParticipant.unread_count: ConversationParticipant.unread_count
+ 1
}
)
db.flush()
logger.info(
f"Message {message.id} sent in conversation {conversation_id} "
f"by {sender_type.value}:{sender_id}"
)
return message
def delete_message(
self,
db: Session,
message_id: int,
deleter_type: ParticipantType,
deleter_id: int,
) -> Message | None:
"""Soft delete a message (for moderation)."""
message = db.query(Message).filter(Message.id == message_id).first()
if not message:
return None
# Verify deleter has access to conversation
conversation = self.get_conversation(
db, message.conversation_id, deleter_type, deleter_id
)
if not conversation:
return None
message.is_deleted = True
message.deleted_at = datetime.now(UTC)
message.deleted_by_type = deleter_type
message.deleted_by_id = deleter_id
db.flush()
return message
def mark_conversation_read(
self,
db: Session,
conversation_id: int,
reader_type: ParticipantType,
reader_id: int,
) -> bool:
"""Mark all messages in conversation as read for participant."""
result = (
db.query(ConversationParticipant)
.filter(
and_(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.participant_type == reader_type,
ConversationParticipant.participant_id == reader_id,
)
)
.update(
{
ConversationParticipant.unread_count: 0,
ConversationParticipant.last_read_at: datetime.now(UTC),
}
)
)
db.flush()
return result > 0
def get_unread_count(
self,
db: Session,
participant_type: ParticipantType,
participant_id: int,
vendor_id: int | None = None,
) -> int:
"""Get total unread message count for a participant."""
query = db.query(func.sum(ConversationParticipant.unread_count)).filter(
and_(
ConversationParticipant.participant_type == participant_type,
ConversationParticipant.participant_id == participant_id,
)
)
if vendor_id:
query = query.filter(ConversationParticipant.vendor_id == vendor_id)
return query.scalar() or 0
# =========================================================================
# PARTICIPANT HELPERS
# =========================================================================
def get_participant_info(
self,
db: Session,
participant_type: ParticipantType,
participant_id: int,
) -> dict[str, Any] | None:
"""Get display info for a participant (name, email, avatar)."""
if participant_type in [ParticipantType.ADMIN, ParticipantType.VENDOR]:
user = db.query(User).filter(User.id == participant_id).first()
if user:
return {
"id": user.id,
"type": participant_type.value,
"name": f"{user.first_name or ''} {user.last_name or ''}".strip()
or user.username,
"email": user.email,
"avatar_url": None, # Could add avatar support later
}
elif participant_type == ParticipantType.CUSTOMER:
customer = db.query(Customer).filter(Customer.id == participant_id).first()
if customer:
return {
"id": customer.id,
"type": participant_type.value,
"name": f"{customer.first_name or ''} {customer.last_name or ''}".strip()
or customer.email,
"email": customer.email,
"avatar_url": None,
}
return None
def get_other_participant(
self,
conversation: Conversation,
my_type: ParticipantType,
my_id: int,
) -> ConversationParticipant | None:
"""Get the other participant in a conversation."""
for p in conversation.participants:
if p.participant_type != my_type or p.participant_id != my_id:
return p
return None
# =========================================================================
# NOTIFICATION PREFERENCES
# =========================================================================
def update_notification_preferences(
self,
db: Session,
conversation_id: int,
participant_type: ParticipantType,
participant_id: int,
email_notifications: bool | None = None,
muted: bool | None = None,
) -> bool:
"""Update notification preferences for a participant in a conversation."""
updates = {}
if email_notifications is not None:
updates[ConversationParticipant.email_notifications] = email_notifications
if muted is not None:
updates[ConversationParticipant.muted] = muted
if not updates:
return False
result = (
db.query(ConversationParticipant)
.filter(
and_(
ConversationParticipant.conversation_id == conversation_id,
ConversationParticipant.participant_type == participant_type,
ConversationParticipant.participant_id == participant_id,
)
)
.update(updates)
)
db.flush()
return result > 0
# =========================================================================
# RECIPIENT QUERIES
# =========================================================================
def get_vendor_recipients(
self,
db: Session,
vendor_id: int | None = None,
search: str | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[dict], int]:
"""
Get list of vendor users as potential recipients.
Args:
db: Database session
vendor_id: Optional vendor ID filter
search: Search term for name/email
skip: Pagination offset
limit: Max results
Returns:
Tuple of (recipients list, total count)
"""
from models.database.vendor import VendorUser
query = (
db.query(User, VendorUser)
.join(VendorUser, User.id == VendorUser.user_id)
.filter(User.is_active == True) # noqa: E712
)
if vendor_id:
query = query.filter(VendorUser.vendor_id == vendor_id)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(User.username.ilike(search_pattern))
| (User.email.ilike(search_pattern))
| (User.first_name.ilike(search_pattern))
| (User.last_name.ilike(search_pattern))
)
total = query.count()
results = query.offset(skip).limit(limit).all()
recipients = []
for user, vendor_user in results:
name = f"{user.first_name or ''} {user.last_name or ''}".strip() or user.username
recipients.append({
"id": user.id,
"type": ParticipantType.VENDOR,
"name": name,
"email": user.email,
"vendor_id": vendor_user.vendor_id,
"vendor_name": vendor_user.vendor.name if vendor_user.vendor else None,
})
return recipients, total
def get_customer_recipients(
self,
db: Session,
vendor_id: int | None = None,
search: str | None = None,
skip: int = 0,
limit: int = 50,
) -> tuple[list[dict], int]:
"""
Get list of customers as potential recipients.
Args:
db: Database session
vendor_id: Optional vendor ID filter (required for vendor users)
search: Search term for name/email
skip: Pagination offset
limit: Max results
Returns:
Tuple of (recipients list, total count)
"""
query = db.query(Customer).filter(Customer.is_active == True) # noqa: E712
if vendor_id:
query = query.filter(Customer.vendor_id == vendor_id)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(Customer.email.ilike(search_pattern))
| (Customer.first_name.ilike(search_pattern))
| (Customer.last_name.ilike(search_pattern))
)
total = query.count()
results = query.offset(skip).limit(limit).all()
recipients = []
for customer in results:
name = f"{customer.first_name or ''} {customer.last_name or ''}".strip()
recipients.append({
"id": customer.id,
"type": ParticipantType.CUSTOMER,
"name": name or customer.email,
"email": customer.email,
"vendor_id": customer.vendor_id,
})
return recipients, total
# Singleton instance
messaging_service = MessagingService()