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,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()