БЛОК 4: КОММУНИКАЦИЯ И ВЗАИМОДЕЙСТВИЕ

4.1. Система внутренних сообщений

Раздел ТЗ: Коммуникация между пользователями

Суть проблемы:
Не описана архитектура чата, не определены правила общения, отсутствует модерация сообщений, не указаны ограничения для разных тарифов.

Предлагаемое решение:

4.1.1. Архитектура системы сообщений

Технический стек:

Real-time коммуникация:
- WebSocket (Socket.io) для мгновенной доставки сообщений
- Redis Pub/Sub для масштабирования на несколько серверов
- PostgreSQL для хранения истории сообщений
- S3/CloudStorage для файлов и медиа

Структура:
┌─────────────┐      WebSocket      ┌──────────────┐
│   Client    │ ←──────────────────→ │  WS Server   │
└─────────────┘                      └──────────────┘
                                            ↓
                                     ┌──────────────┐
                                     │ Redis Pub/Sub│
                                     └──────────────┘
                                            ↓
                              ┌─────────────┴─────────────┐
                              ↓                           ↓
                     ┌──────────────┐           ┌──────────────┐
                     │  WS Server 1 │           │  WS Server 2 │
                     └──────────────┘           └──────────────┘
                              ↓                           ↓
                         ┌────────────────────────────────┐
                         │        PostgreSQL              │
                         │  (история сообщений)           │
                         └────────────────────────────────┘

База данных:

-- Таблица диалогов (conversations)
CREATE TABLE conversations (
    id UUID PRIMARY KEY,
    listing_id UUID REFERENCES listings(id), -- объявление, по которому начался диалог
    
    -- Участники
    investor_id UUID REFERENCES users(id),
    entrepreneur_id UUID REFERENCES users(id),
    
    -- Статус
    status VARCHAR(20) DEFAULT 'active', -- 'active', 'archived', 'blocked'
    
    -- Метаданные
    last_message_id UUID,
    last_message_at TIMESTAMP,
    unread_count_investor INTEGER DEFAULT 0,
    unread_count_entrepreneur INTEGER DEFAULT 0,
    
    -- Настройки
    is_muted_by_investor BOOLEAN DEFAULT false,
    is_muted_by_entrepreneur BOOLEAN DEFAULT false,
    is_pinned_by_investor BOOLEAN DEFAULT false,
    is_pinned_by_entrepreneur BOOLEAN DEFAULT false,
    
    -- Модерация
    is_flagged BOOLEAN DEFAULT false,
    flagged_reason TEXT,
    flagged_by UUID REFERENCES users(id),
    flagged_at TIMESTAMP,
    
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    
    -- Ограничения
    UNIQUE(listing_id, investor_id, entrepreneur_id),
    CHECK(investor_id != entrepreneur_id)
);

-- Таблица сообщений
CREATE TABLE messages (
    id UUID PRIMARY KEY,
    conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
    
    -- Отправитель
    sender_id UUID REFERENCES users(id),
    sender_role VARCHAR(20), -- 'investor' или 'entrepreneur'
    
    -- Содержимое
    message_type VARCHAR(20) DEFAULT 'text', -- 'text', 'file', 'image', 'system'
    content TEXT, -- текст сообщения или JSON для системных сообщений
    
    -- Файлы
    attachments JSONB, -- [{url, name, size, type}, ...]
    
    -- Статус
    is_read BOOLEAN DEFAULT false,
    read_at TIMESTAMP,
    is_edited BOOLEAN DEFAULT false,
    edited_at TIMESTAMP,
    is_deleted BOOLEAN DEFAULT false,
    deleted_at TIMESTAMP,
    
    -- Модерация
    is_flagged BOOLEAN DEFAULT false,
    flagged_reason TEXT,
    moderation_status VARCHAR(20), -- 'pending', 'approved', 'rejected'
    
    -- Метаданные
    metadata JSONB, -- дополнительные данные (например, для системных сообщений)
    
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Индексы
CREATE INDEX idx_conversations_investor ON conversations(investor_id, status, last_message_at DESC);
CREATE INDEX idx_conversations_entrepreneur ON conversations(entrepreneur_id, status, last_message_at DESC);
CREATE INDEX idx_conversations_listing ON conversations(listing_id);
CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at DESC);
CREATE INDEX idx_messages_unread ON messages(conversation_id, is_read) WHERE is_read = false;
CREATE INDEX idx_messages_moderation ON messages(moderation_status) WHERE moderation_status = 'pending';

-- Таблица для отслеживания онлайн-статуса
CREATE TABLE user_presence (
    user_id UUID PRIMARY KEY REFERENCES users(id),
    status VARCHAR(20) DEFAULT 'offline', -- 'online', 'away', 'offline'
    last_seen TIMESTAMP DEFAULT NOW(),
    last_activity TIMESTAMP DEFAULT NOW(),
    device_info JSONB, -- информация об устройстве
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Таблица для типовых ответов (шаблонов)
CREATE TABLE message_templates (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    title VARCHAR(100),
    content TEXT,
    category VARCHAR(50), -- 'greeting', 'follow_up', 'rejection', 'custom'
    usage_count INTEGER DEFAULT 0,
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

4.1.2. Правила и ограничения общения

Матрица ограничений по тарифам:

Функция Free Investor Premium Investor Free Entrepreneur Premium Entrepreneur
Инициация диалога Только через отклик Без ограничений Ответ на отклики Без ограничений
Количество активных диалогов 5 Безлимит 10 Безлимит
Сообщений в день 20 Безлимит 50 Безлимит
Вложения Только изображения (до 5 МБ) Все типы (до 50 МБ) Только изображения (до 5 МБ) Все типы (до 100 МБ)
Шаблоны ответов 5 шаблонов 20 шаблонов
История сообщений 30 дней Безлимит 90 дней Безлимит
Экспорт переписки ✓ (PDF/CSV) ✓ (PDF/CSV)
Приоритет доставки Стандартный Высокий Стандартный Высокий
Уведомления Email Email + SMS + Push Email Email + SMS + Push

Правила инициации диалога:

def can_start_conversation(sender, recipient, listing):
    """Проверяет, может ли пользователь начать диалог"""
    
    # 1. Проверка ролей
    if sender.role == recipient.role:
        return False, "Нельзя начать диалог с пользователем той же роли"
    
    # 2. Проверка блокировок
    if is_blocked(sender.id, recipient.id):
        return False, "Пользователь заблокирован"
    
    # 3. Проверка существующего диалога
    existing_conversation = get_conversation(sender.id, recipient.id, listing.id)
    if existing_conversation:
        return True, existing_conversation.id  # возвращаем существующий диалог
    
    # 4. Проверка лимитов для Free-пользователей
    if sender.tier == "free":
        active_conversations = count_active_conversations(sender.id)
        max_conversations = 5 if sender.role == "investor" else 10
        
        if active_conversations >= max_conversations:
            return False, f"Достигнут лимит активных диалогов ({max_conversations}). Перейдите на Premium для безлимитного общения."
    
    # 5. Проверка прав инициации для инвесторов
    if sender.role == "investor":
        # Free-инвесторы могут писать только через отклик
        if sender.tier == "free":
            response = get_response(sender.id, listing.id)
            if not response or response.status != "approved":
                return False, "Free-инвесторы могут начать диалог только после одобрения отклика предпринимателем"
        
        # Проверка лимита откликов
        if sender.tier == "free":
            monthly_responses = count_monthly_responses(sender.id)
            if monthly_responses >= 3:
                return False, "Достигнут месячный лимит откликов (3). Перейдите на Premium для безлимитных откликов."
    
    # 6. Проверка настроек приватности получателя
    if recipient.privacy_settings.get("only_verified_users") and not sender.is_verified:
        return False, "Пользователь принимает сообщения только от верифицированных пользователей"
    
    # 7. Все проверки пройдены
    return True, None

def can_send_message(sender, conversation):
    """Проверяет, может ли пользователь отправить сообщение"""
    
    # 1. Проверка статуса диалога
    if conversation.status == "blocked":
        return False, "Диалог заблокирован"
    
    if conversation.status == "archived":
        return False, "Диалог архивирован. Разархивируйте для продолжения общения."
    
    # 2. Проверка дневного лимита для Free-пользователей
    if sender.tier == "free":
        daily_messages = count_daily_messages(sender.id)
        max_messages = 20 if sender.role == "investor" else 50
        
        if daily_messages >= max_messages:
            return False, f"Достигнут дневной лимит сообщений ({max_messages}). Лимит обновится через {get_time_until_reset()}."
    
    # 3. Проверка rate limiting (защита от спама)
    recent_messages = get_recent_messages(sender.id, conversation.id, minutes=1)
    if len(recent_messages) >= 10:
        return False, "Слишком много сообщений за короткий период. Подождите минуту."
    
    # 4. Все проверки пройдены
    return True, None

4.1.3. Модерация сообщений

Автоматическая модерация:

class MessageModerator:
    
    def __init__(self):
        # Загружаем списки запрещенных слов
        self.profanity_list = load_profanity_list()
        self.spam_patterns = load_spam_patterns()
        self.scam_keywords = load_scam_keywords()
        
        # ML-модель для классификации сообщений
        self.classifier = load_ml_classifier()
    
    def moderate_message(self, message):
        """Модерирует сообщение перед отправкой"""
        
        flags = []
        risk_score = 0
        
        # 1. Проверка на мат и оскорбления
        profanity_check = self.check_profanity(message.content)
        if profanity_check.found:
            flags.append("profanity")
            risk_score += 50
        
        # 2. Проверка на спам
        spam_check = self.check_spam(message.content)
        if spam_check.is_spam:
            flags.append("spam")
            risk_score += 40
        
        # 3. Проверка на мошенничество
        scam_check = self.check_scam(message.content)
        if scam_check.is_scam:
            flags.append("scam")
            risk_score += 80
        
        # 4. Проверка контактных данных (запрещено в первых сообщениях)
        if self.contains_contact_info(message.content):
            message_position = get_message_position(message.conversation_id)
            if message_position <= 3:  # первые 3 сообщения
                flags.append("early_contact_sharing")
                risk_score += 30
        
        # 5. Проверка внешних ссылок
        links = extract_links(message.content)
        if links:
            for link in links:
                if not is_whitelisted_domain(link):
                    flags.append("external_link")
                    risk_score += 20
        
        # 6. ML-классификация
        ml_prediction = self.classifier.predict(message.content)
        if ml_prediction.category == "inappropriate":
            flags.append("ml_flagged")
            risk_score += ml_prediction.confidence * 50
        
        # 7. Проверка на массовую рассылку
        if self.is_mass_message(message.sender_id, message.content):
            flags.append("mass_message")
            risk_score += 60
        
        # Принятие решения
        if risk_score >= 100:
            return {
                "action": "block",
                "reason": "Сообщение заблокировано автоматически",
                "flags": flags,
                "risk_score": risk_score
            }
        elif risk_score >= 60:
            return {
                "action": "manual_review",
                "reason": "Сообщение отправлено на ручную модерацию",
                "flags": flags,
                "risk_score": risk_score
            }
        else:
            return {
                "action": "approve",
                "flags": flags,
                "risk_score": risk_score
            }
    
    def check_profanity(self, text):
        """Проверяет текст на мат и оскорбления"""
        text_lower = text.lower()
        found_words = []
        
        for word in self.profanity_list:
            if word in text_lower:
                found_words.append(word)
        
        # Проверка на обход фильтра (замена букв)
        normalized_text = normalize_text(text_lower)
        for word in self.profanity_list:
            if word in normalized_text:
                found_words.append(word)
        
        return {
            "found": len(found_words) > 0,
            "words": found_words,
            "count": len(found_words)
        }
    
    def check_spam(self, text):
        """Проверяет текст на спам"""
        spam_indicators = 0
        
        # Паттерны спама
        spam_patterns = [
            r'ЗАРАБОТОК БЕЗ ВЛОЖЕНИЙ',
            r'ГАРАНТИРОВАННЫЙ ДОХОД',
            r'БЫСТРЫЕ ДЕНЬГИ',
            r'ПАССИВНЫЙ ДОХОД',
            r'ФИНАНСОВАЯ ПИРАМИДА',
            r'MLM',
            r'СЕТЕВОЙ МАРКЕТИНГ',
            # ... другие паттерны
        ]
        
        for pattern in spam_patterns:
            if re.search(pattern, text, re.IGNORECASE):
                spam_indicators += 1
        
        # Проверка на чрезмерное использование CAPS
        caps_ratio = sum(1 for c in text if c.isupper()) / len(text) if len(text) > 0 else 0
        if caps_ratio > 0.5:
            spam_indicators += 1
        
        # Проверка на чрезмерное использование эмодзи
        emoji_count = count_emojis(text)
        if emoji_count > 10:
            spam_indicators += 1
        
        # Проверка на повторяющиеся символы
        if re.search(r'(.)\1{5,}', text):
            spam_indicators += 1
        
        return {
            "is_spam": spam_indicators >= 2,
            "indicators": spam_indicators
        }
    
    def check_scam(self, text):
        """Проверяет текст на признаки мошенничества"""
        scam_score = 0
        
        # Ключевые слова мошенничества
        scam_keywords = [
            'предоплата',
            'аванс',
            'переведите деньги',
            'отправьте средства',
            'комиссия за рассмотрение',
            'гарантийный взнос',
            'страховой депозит',
            # ... другие ключевые слова
        ]
        
        text_lower = text.lower()
        for keyword in scam_keywords:
            if keyword in text_lower:
                scam_score += 20
        
        # Проверка на просьбу денег в первых сообщениях
        money_request_patterns = [
            r'переведите?\s+\d+',
            r'отправьте?\s+\d+',
            r'заплатите?\s+\d+',
            r'\d+\s*(рублей|руб|₽|долларов|usd|\$)'
        ]
        
        for pattern in money_request_patterns:
            if re.search(pattern, text_lower):
                scam_score += 30
        
        return {
            "is_scam": scam_score >= 40,
            "score": scam_score
        }
    
    def contains_contact_info(self, text):
        """Проверяет наличие контактной информации"""
        patterns = [
            r'\+?\d{1,3}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}',  # телефон
            r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',  # email
            r'@[A-Za-z0-9_]{3,}',  # Telegram/социальные сети
            r'whatsapp',
            r'viber',
            r'skype',
        ]
        
        for pattern in patterns:
            if re.search(pattern, text, re.IGNORECASE):
                return True
        
        return False
    
    def is_mass_message(self, sender_id, content):
        """Проверяет, является ли сообщение массовой рассылкой"""
        
        # Получаем последние сообщения пользователя за час
        recent_messages = get_recent_messages(sender_id, hours=1)
        
        # Проверяем на идентичные или очень похожие сообщения
        similar_count = 0
        for msg in recent_messages:
            similarity = calculate_similarity(content, msg.content)
            if similarity > 0.9:  # 90% схожести
                similar_count += 1
        
        # Если отправлено более 5 похожих сообщений за час
        return similar_count >= 5

Ручная модерация:

# Интерфейс для модераторов

class MessageModerationQueue:
    
    def get_pending_messages(self, moderator_id):
        """Получает очередь сообщений на модерацию"""
        
        messages = db.query("""
            SELECT 
                m.*,
                c.investor_id,
                c.entrepreneur_id,
                u1.name as sender_name,
                u2.name as recipient_name
            FROM messages m
            JOIN conversations c ON m.conversation_id = c.id
            JOIN users u1 ON m.sender_id = u1.id
            JOIN users u2 ON (
                CASE 
                    WHEN m.sender_id = c.investor_id THEN c.entrepreneur_id
                    ELSE c.investor_id
                END
            ) = u2.id
            WHERE m.moderation_status = 'pending'
            ORDER BY 
                -- Приоритет по risk_score
                (m.metadata->>'risk_score')::int DESC,
                m.created_at ASC
            LIMIT 50
        """)
        
        return messages
    
    def moderate_message(self, message_id, moderator_id, action, reason=None):
        """Модерирует сообщение"""
        
        if action == "approve":
            # Одобряем сообщение
            db.execute("""
                UPDATE messages
                SET 
                    moderation_status = 'approved',
                    is_flagged = false,
                    updated_at = NOW()
                WHERE id = :message_id
            """, message_id=message_id)
            
            # Отправляем сообщение получателю
            send_message_to_recipient(message_id)
            
        elif action == "reject":
            # Отклоняем сообщение
            db.execute("""
                UPDATE messages
                SET 
                    moderation_status = 'rejected',
                    is_flagged = true,
                    flagged_reason = :reason,
                    updated_at = NOW()
                WHERE id = :message_id
            """, message_id=message_id, reason=reason)
            
            # Уведомляем отправителя
            notify_sender_message_rejected(message_id, reason)
            
            # Если это повторное нарушение, применяем санкции
            sender_id = get_message_sender(message_id)
            violation_count = count_violations(sender_id)
            
            if violation_count >= 3:
                apply_sanctions(sender_id, "repeated_violations")
        
        elif action == "block_user":
            # Блокируем пользователя
            sender_id = get_message_sender(message_id)
            block_user(sender_id, reason, moderator_id)
            
            # Отклоняем сообщение
            self.moderate_message(message_id, moderator_id, "reject", reason)
        
        # Логируем действие модератора
        log_moderation_action(moderator_id, message_id, action, reason)

4.1.4. Функциональность чата

Основные возможности:

// WebSocket клиент

class ChatClient {
  constructor(userId, token) {
    this.userId = userId;
    this.token = token;
    this.socket = null;
    this.conversations = new Map();
  }
  
  connect() {
    this.socket = io('wss://api.platform.com/chat', {
      auth: { token: this.token },
      transports: ['websocket']
    });
    
    // Обработчики событий
    this.socket.on('connect', () => {
      console.log('Connected to chat server');
      this.updatePresence('online');
    });
    
    this.socket.on('disconnect', () => {
      console.log('Disconnected from chat server');
    });
    
    // Получение нового сообщения
    this.socket.on('message:new', (message) => {
      this.handleNewMessage(message);
    });
    
    // Сообщение прочитано
    this.socket.on('message:read', (data) => {
      this.handleMessageRead(data);
    });
    
    // Пользователь печатает
    this.socket.on('user:typing', (data) => {
      this.handleUserTyping(data);
    });
    
    // Обновление онлайн-статуса
    this.socket.on('user:presence', (data) => {
      this.handlePresenceUpdate(data);
    });
  }
  
  // Отправка сообщения
  async sendMessage(conversationId, content, attachments = []) {
    // Проверка лимитов на клиенте
    const canSend = await this.checkSendLimits(conversationId);
    if (!canSend.allowed) {
      throw new Error(canSend.reason);
    }
    
    // Оптимистичное обновление UI
    const tempMessage = {
      id: generateTempId(),
      conversationId,
      senderId: this.userId,
      content,
      attachments,
      status: 'sending',
      createdAt: new Date().toISOString()
    };
    
    this.addMessageToConversation(conversationId, tempMessage);
    
    // Отправка на сервер
    try {
      const response = await this.socket.emitWithAck('message:send', {
        conversationId,
        content,
        attachments
      });
      
      // Обновляем временное сообщение реальным
      this.updateMessage(tempMessage.id, {
        id: response.messageId,
        status: 'sent'
      });
      
      return response;
    } catch (error) {
      // Помечаем сообщение как ошибочное
      this.updateMessage(tempMessage.id, {
        status: 'error',
        error: error.message
      });
      
      throw error;
    }
  }
  
  // Отправка "печатает..."
  sendTypingIndicator(conversationId) {
    this.socket.emit('user:typing', {
      conversationId,
      userId: this.userId
    });
  }
  
  // Отметка сообщений как прочитанных
  markAsRead(conversationId, messageIds) {
    this.socket.emit('messages:read', {
      conversationId,
      messageIds
    });
  }
  
  // Обновление онлайн-статуса
  updatePresence(status) {
    this.socket.emit('user:presence', {
      userId: this.userId,
      status // 'online', 'away', 'offline'
    });
  }
  
  // Загрузка истории сообщений
  async loadMessages(conversationId, before = null, limit = 50) {
    const response = await fetch(
      `/api/v1/conversations/${conversationId}/messages?` +
      `before=${before || ''}&limit=${limit}`,
      {
        headers: {
          'Authorization': `Bearer ${this.token}`
        }
      }
    );
    
    const data = await response.json();
    
    // Добавляем сообщения в локальное хранилище
    data.messages.forEach(msg => {
      this.addMessageToConversation(conversationId, msg);
    });
    
    return data;
  }
  
  // Обработка нового сообщения
  handleNewMessage(message) {
    // Добавляем в локальное хранилище
    this.addMessageToConversation(message.conversationId, message);
    
    // Обновляем UI
    this.emit('message:received', message);
    
    // Показываем уведомление, если чат не открыт
    if (!this.isConversationActive(message.conversationId)) {
      this.showNotification(message);
    }
    
    // Автоматически отмечаем как прочитанное, если чат открыт
    if (this.isConversationActive(message.conversationId)) {
      this.markAsRead(message.conversationId, [message.id]);
    }
  }
  
  // Проверка лимитов отправки
  async checkSendLimits(conversationId) {
    const response = await fetch(
      `/api/v1/conversations/${conversationId}/check-limits`,
      {
        headers: {
          'Authorization': `Bearer ${this.token}`
        }
      }
    );
    
    return await response.json();
  }
  
  // Отправка файла
  async uploadFile(file, conversationId) {
    // Проверка размера файла
    const maxSize = this.userTier === 'premium' ? 50 * 1024 * 1024 : 5 * 1024 * 1024;
    if (file.size > maxSize) {
      throw new Error(`Файл слишком большой. Максимум: ${maxSize / 1024 / 1024} МБ`);
    }
    
    // Проверка типа файла
    const allowedTypes = this.userTier === 'premium' 
      ? ['image/*', 'application/pdf', 'application/msword', 'application/vnd.ms-excel', 'video/*']
      : ['image/*'];
    
    if (!this.isFileTypeAllowed(file.type, allowedTypes)) {
      throw new Error('Тип файла не поддерживается');
    }
    
    // Загрузка файла
    const formData = new FormData();
    formData.append('file', file);
    formData.append('conversationId', conversationId);
    
    const response = await fetch('/api/v1/files/upload', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.token}`
      },
      body: formData
    });
    
    const data = await response.json();
    return data.fileUrl;
  }
}

UI компоненты:

// Компонент списка диалогов

function ConversationsList() {
  const [conversations, setConversations] = useState([]);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState('all'); // 'all', 'unread', 'archived'
  
  useEffect(() => {
    loadConversations();
  }, [filter]);
  
  const loadConversations = async () => {
    try {
      const response = await api.get('/api/v1/conversations', {
        params: { filter }
      });
      setConversations(response.data);
    } catch (error) {
      console.error('Error loading conversations:', error);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="conversations-list">
      <div className="list-header">
        <h2>Сообщения</h2>
        <div className="filters">
          <button 
            className={filter === 'all' ? 'active' : ''}
            onClick={() => setFilter('all')}
          >
            Все
          </button>
          <button 
            className={filter === 'unread' ? 'active' : ''}
            onClick={() => setFilter('unread')}
          >
            Непрочитанные
            {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
          </button>
          <button 
            className={filter === 'archived' ? 'active' : ''}
            onClick={() => setFilter('archived')}
          >
            Архив
          </button>
        </div>
      </div>
      
      <div className="conversations">
        {loading ? (
          <SkeletonLoader />
        ) : conversations.length === 0 ? (
          <EmptyState 
            icon="💬"
            title="Нет сообщений"
            description="Начните диалог с инвестором или предпринимателем"
          />
        ) : (
          conversations.map(conv => (
            <ConversationItem
              key={conv.id}
              conversation={conv}
              onClick={() => openConversation(conv.id)}
            />
          ))
        )}
      </div>
    </div>
  );
}

// Компонент элемента диалога
function ConversationItem({ conversation, onClick }) {
  const { otherUser, lastMessage, unreadCount, isPinned } = conversation;
  
  return (
    <div 
      className={`conversation-item ${unreadCount > 0 ? 'unread' : ''}`}
      onClick={onClick}
    >
      <div className="avatar">
        <img src={otherUser.avatar} alt={otherUser.name} />
        {otherUser.isOnline && <span className="online-indicator" />}
      </div>
      
      <div className="content">
        <div className="header">
          <span className="name">
            {otherUser.name}
            {otherUser.isVerified && <VerifiedBadge />}
          </span>
          <span className="time">{formatTime(lastMessage.createdAt)}</span>
        </div>
        
        <div className="message">
          <p className="text">
            {lastMessage.type === 'text' 
              ? truncate(lastMessage.content, 50)
              : getMessageTypeLabel(lastMessage.type)
            }
          </p>
          {unreadCount > 0 && (
            <span className="unread-badge">{unreadCount}</span>
          )}
        </div>
      </div>
      
      {isPinned && <PinIcon />}
    </div>
  );
}

// Компонент окна чата
function ChatWindow({ conversationId }) {
  const [messages, setMessages] = useState([]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);
  const [otherUserTyping, setOtherUserTyping] = useState(false);
  const [attachments, setAttachments] = useState([]);
  const messagesEndRef = useRef(null);
  
  useEffect(() => {
    loadMessages();
    
    // Подписываемся на новые сообщения
    chatClient.on('message:received', handleNewMessage);
    chatClient.on('user:typing', handleUserTyping);
    
    return () => {
      chatClient.off('message:received', handleNewMessage);
      chatClient.off('user:typing', handleUserTyping);
    };
  }, [conversationId]);
  
  const loadMessages = async () => {
    const data = await chatClient.loadMessages(conversationId);
    setMessages(data.messages);
    scrollToBottom();
  };
  
  const handleSendMessage = async () => {
    if (!inputValue.trim() && attachments.length === 0) return;
    
    try {
      await chatClient.sendMessage(conversationId, inputValue, attachments);
      setInputValue('');
      setAttachments([]);
      scrollToBottom();
    } catch (error) {
      showError(error.message);
    }
  };
  
  const handleInputChange = (e) => {
    setInputValue(e.target.value);
    
    // Отправляем индикатор "печатает..."
    if (!isTyping) {
      setIsTyping(true);
      chatClient.sendTypingIndicator(conversationId);
      
      // Сбрасываем через 3 секунды
      setTimeout(() => setIsTyping(false), 3000);
    }
  };
  
  const handleFileUpload = async (files) => {
    for (const file of files) {
      try {
        const url = await chatClient.uploadFile(file, conversationId);
        setAttachments(prev => [...prev, { url, name: file.name, type: file.type }]);
      } catch (error) {
        showError(error.message);
      }
    }
  };
  
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };
  
  return (
    <div className="chat-window">
      <ChatHeader conversation={conversation} />
      
      <div className="messages-container">
        {messages.map(message => (
          <MessageBubble
            key={message.id}
            message={message}
            isMine={message.senderId === currentUserId}
          />
        ))}
        
        {otherUserTyping && (
          <TypingIndicator userName={otherUser.name} />
        )}
        
        <div ref={messagesEndRef} />
      </div>
      
      <ChatInput
        value={inputValue}
        onChange={handleInputChange}
        onSend={handleSendMessage}
        onFileUpload={handleFileUpload}
        attachments={attachments}
        onRemoveAttachment={(index) => {
          setAttachments(prev => prev.filter((_, i) => i !== index));
        }}
      />
    </div>
  );
}

// Компонент сообщения
function MessageBubble({ message, isMine }) {
  return (
    <div className={`message-bubble ${isMine ? 'mine' : 'theirs'}`}>
      <div className="bubble-content">
        {message.content && (
          <p className="text">{message.content}</p>
        )}
        
        {message.attachments?.length > 0 && (
          <div className="attachments">
            {message.attachments.map((attachment, index) => (
              <AttachmentPreview
                key={index}
                attachment={attachment}
              />
            ))}
          </div>
        )}
        
        <div className="message-footer">
          <span className="time">{formatTime(message.createdAt)}</span>
          {isMine && (
            <MessageStatus status={message.status} />
          )}
        </div>
      </div>
    </div>
  );
}

// Компонент ввода сообщения
function ChatInput({ value, onChange, onSend, onFileUpload, attachments, onRemoveAttachment }) {
  const fileInputRef = useRef(null);
  
  const handleKeyPress = (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      onSend();
    }
  };
  
  return (
    <div className="chat-input">
      {attachments.length > 0 && (
        <div className="attachments-preview">
          {attachments.map((attachment, index) => (
            <div key={index} className="attachment-item">
              <AttachmentPreview attachment={attachment} />
              <button
                className="remove-btn"
                onClick={() => onRemoveAttachment(index)}
              >
                ×
              </button>
            </div>
          ))}
        </div>
      )}
      
      <div className="input-container">
        <button
          className="attach-btn"
          onClick={() => fileInputRef.current?.click()}
          title="Прикрепить файл"
        >
          📎
        </button>
        
        <input
          type="file"
          ref={fileInputRef}
          style={{ display: 'none' }}
          multiple
          onChange={(e) => onFileUpload(Array.from(e.target.files))}
        />
        
        <textarea
          className="message-input"
          placeholder="Введите сообщение..."
          value={value}
          onChange={onChange}
          onKeyPress={handleKeyPress}
          rows={1}
        />
        
        <button
          className="send-btn"
          onClick={onSend}
          disabled={!value.trim() && attachments.length === 0}
        >
          Отправить
        </button>
      </div>
      
      {userTier === 'free' && (
        <div className="limits-info">
          Отправлено сегодня: {dailyMessageCount} из {maxDailyMessages}
        </div>
      )}
    </div>
  );
}

Примеры use-cases:

UC-4.1: Инвестор начинает диалог с предпринимателем

1. Инвестор просматривает объявление предпринимателя

2. Нажимает кнопку "Откликнуться" или "Написать сообщение"

3. Система проверяет:
   - Есть ли уже диалог по этому объявлению?
   - Не достигнут ли лимит активных диалогов? (для Free)
   - Не заблокирован ли пользователь?

4. Если Free-инвестор:
   - Показывается форма отклика:
     "Расскажите, почему вас заинтересовало это предложение"
     [Текстовое поле]
     [Отправить отклик]
   
   - После отправки:
     "Ваш отклик отправлен предпринимателю.
     Вы сможете начать диалог после одобрения отклика."

5. Если Premium-инвестор:
   - Сразу открывается окно чата
   - Можно написать первое сообщение
   - Предприниматель получает уведомление

6. Предприниматель видит отклик/сообщение:
   - Может просмотреть профиль инвестора
   - Может одобрить отклик (для Free-инвесторов)
   - Может ответить на сообщение (для Premium-инвесторов)

7. После одобрения/ответа открывается полноценный диалог

UC-4.2: Пользователь отправляет сообщение с вложением

1. Пользователь открывает диалог

2. Нажимает кнопку "Прикрепить файл" (📎)

3. Выбирает файл с компьютера

4. Система проверяет:
   - Размер файла (5 МБ для Free, 50 МБ для Premium)
   - Тип файла (только изображения для Free, все типы для Premium)

5. Если проверка не пройдена:
   - Показывается ошибка:
     "Файл слишком большой. Максимум: 5 МБ
     [Перейти на Premium] для загрузки файлов до 50 МБ"

6. Если проверка пройдена:
   - Файл загружается на сервер
   - Показывается прогресс-бар загрузки
   - После загрузки появляется превью файла

7. Пользователь может:
   - Добавить текст к сообщению
   - Прикрепить еще файлы
   - Удалить прикрепленный файл

8. Нажимает "Отправить"

9. Сообщение с вложением отправляется:
   - Получатель видит превью файла
   - Может скачать файл
   - Может открыть в полном размере (для изображений)

UC-4.3: Модератор проверяет подозрительное сообщение

1. Пользователь отправляет сообщение с подозрительным содержимым:
   "Переведите 50,000 руб на карту для рассмотрения заявки"

2. Система автоматически модерирует:
   - Обнаруживает ключевые слова мошенничества
   - Обнаруживает просьбу денег
   - Рассчитывает risk_score = 110 (высокий риск)

3. Сообщение блокируется автоматически:
   - Не доставляется получателю
   - Отправитель видит:
     "Ваше сообщение заблокировано модерацией.
     Причина: Подозрение на мошенничество
     Если вы считаете это ошибкой, обратитесь в поддержку."

4. Модератор получает уведомление:
   - В админ-панели появляется алерт
   - Видит заблокированное сообщение и контекст диалога

5. Модератор проверяет:
   - Читает предыдущие сообщения в диалоге
   - Проверяет профиль отправителя
   - Проверяет историю нарушений

6. Принимает решение:
   - Если это действительно мошенничество:
     * Подтверждает блокировку
     * Блокирует аккаунт отправителя
     * Уведомляет получателя о попытке мошенничества
   
   - Если это ложное срабатывание:
     * Одобряет сообщение
     * Оно доставляется получателю
     * Корректирует алгоритм модерации

7. Отправитель получает уведомление о решении:
   - Если заблокирован: причина блокировки
   - Если одобрено: сообщение доставлено

UC-4.4: Free-пользователь достигает дневного лимита сообщений

1. Free-инвестор отправил 20 сообщений за день (лимит)

2. Пытается отправить 21-е сообщение

3. Система блокирует отправку:
   - Кнопка "Отправить" становится неактивной
   - Показывается сообщение:
     
     "Вы достигли дневного лимита сообщений (20/20)
     
     Лимит обновится через 8 часов 23 минуты
     
     [Перейти на Premium] - безлимитные сообщения за ₽4,990/мес
     [Напомнить позже]"

4. Если пользователь нажимает "Перейти на Premium":
   - Переход на страницу оплаты
   - После оплаты мгновенное снятие ограничений
   - Возврат в чат с возможностью отправки

5. Если нажимает "Напомнить позже":
   - Push-уведомление в момент сброса лимита
   - Email с предложением Premium (скидка 20%)

6. В момент сброса лимита (00:00 по МСК):
   - Счетчик обнуляется
   - Пользователь может снова отправлять сообщения
   - Приходит уведомление: "Ваш дневной лимит сообщений обновлен"

Приоритет: [CRITICAL] — MVP

Обоснование решения:

  • Коммуникация: Чат — основной канал взаимодействия между инвесторами и предпринимателями
  • Real-time: WebSocket обеспечивает мгновенную доставку сообщений
  • Безопасность: Многоуровневая модерация защищает от мошенничества и спама
  • Монетизация: Ограничения для Free-пользователей стимулируют переход на Premium

Риски, которые закрывает:

  • Мошенничество через сообщения
  • Спам и массовые рассылки
  • Утечка контактов за пределы платформы
  • Низкое качество коммуникации

Влияние на метрики:

  • Конверсия отклик → диалог: 60-70%
  • Конверсия диалог → сделка: 15-20%
  • Среднее время ответа: <2 часа для Premium, <4 часа для Free
  • Engagement: +50% (пользователи возвращаются для проверки сообщений)
  • Конверсия Free → Premium: +15% из-за лимитов сообщений

Связанные изменения:

  • Раздел «Уведомления» — добавить алерты о новых сообщениях
  • Раздел «Профиль» — добавить настройки приватности чата
  • Раздел «Админ-панель» — добавить интерфейс модерации сообщений
  • Раздел «Инфраструктура» — добавить WebSocket серверы и Redis

4.2. Система уведомлений

Раздел ТЗ: Уведомления

Суть проблемы:
Не описаны типы уведомлений, каналы доставки, настройки пользователя, приоритизация уведомлений.

Предлагаемое решение:

4.2.1. Типы и каналы уведомлений

Матрица уведомлений:

Событие Email Push SMS In-App Приоритет Настраивается
Сообщения
Новое сообщение Premium Высокий
Непрочитанные сообщения (дайджест) Средний
Отклики и объявления
Новый отклик на объявление Premium Высокий
Отклик одобрен Высокий
Отклик отклонен Средний
Объявление опубликовано Средний
Объявление на модерации Низкий
Объявление одобрено Высокий
Объявление отклонено Высокий
Объявление истекает через 7 дней Средний
Рекомендации
Новое релевантное объявление Средний
Дайджест рекомендаций Низкий
Верификация
Верификация одобрена Высокий
Верификация отклонена Высокий
Требуются дополнительные документы Высокий
Верификация истекает Средний
Подписка и оплата
Подписка продлена Средний
Подписка истекает через 7 дней Premium Высокий
Подписка истекла Высокий
Платеж не прошел Premium Критический
Счет выставлен Средний
Безопасность
Вход с нового устройства Premium Высокий
Изменение пароля Premium Высокий
Подозрительная активность Premium Критический
Отзывы и рейтинг
Новый отзыв Средний
Запрос оставить отзыв Низкий
Система
Технические работы Высокий
Новые функции Низкий
Обновления политики Средний

База данных:

-- Таблица уведомлений
CREATE TABLE notifications (
    id UUID PRIMARY KEY,
    user_id UUID REFERENCES users(id),
    
    -- Тип и категория
    type VARCHAR(50), -- 'message_new', 'response_new', 'listing_approved', etc.
    category VARCHAR(50), -- 'messages', 'responses', 'listings', 'verification', etc.
    priority VARCHAR(20) DEFAULT 'medium', -- 'critical', 'high', 'medium', 'low'
    
    -- Содержимое
    title VARCHAR(200),
    body TEXT,
    data JSONB, -- дополнительные данные (ссылки, ID объектов, etc.)
    
    -- Действие
    action_url VARCHAR(500), -- URL для перехода при клике
    action_label VARCHAR(50), -- текст кнопки действия
    
    -- Статус
    is_read BOOLEAN DEFAULT false,
    read_at TIMESTAMP,
    is_clicked BOOLEAN DEFAULT false,
    clicked_at TIMESTAMP,
    
    -- Каналы доставки
    channels JSONB, -- ['email', 'push', 'sms', 'in_app']
    delivery_status JSONB, -- {email: 'sent', push: 'delivered', ...}
    
    -- Группировка (для объединения похожих уведомлений)
    group_key VARCHAR(100), -- например, 'messages:conversation_id'
    
    -- Временные метки
    created_at TIMESTAMP DEFAULT NOW(),
    scheduled_at TIMESTAMP, -- для отложенных уведомлений
    sent_at TIMESTAMP,
    expires_at TIMESTAMP -- для временных уведомлений
);

-- Индексы
CREATE INDEX idx_notifications_user ON notifications(user_id, is_read, created_at DESC);
CREATE INDEX idx_notifications_scheduled ON notifications(scheduled_at) WHERE scheduled_at IS NOT NULL AND sent_at IS NULL;
CREATE INDEX idx_notifications_group ON notifications(user_id, group_key, created_at DESC);

-- Таблица настроек уведомлений пользователя
CREATE TABLE notification_preferences (
    user_id UUID PRIMARY KEY REFERENCES users(id),
    
    -- Настройки по категориям
    preferences JSONB,
    /* Структура:
    {
      "messages": {
        "email": true,
        "push": true,
        "sms": false,
        "in_app": true,
        "frequency": "instant" // 'instant', 'digest_daily', 'digest_weekly', 'disabled'
      },
      "responses": { ... },
      ...
    }
    */
    
    -- Режим "Не беспокоить"
    do_not_disturb BOOLEAN DEFAULT false,
    dnd_start_time TIME, -- например, 22:00
    dnd_end_time TIME, -- например, 08:00
    dnd_days INTEGER[], -- дни недели (1-7)
    
    -- Дайджесты
    digest_email_time TIME DEFAULT '09:00', -- время отправки дайджеста
    digest_email_days INTEGER[] DEFAULT ARRAY[1,2,3,4,5], -- рабочие дни
    
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Таблица для отслеживания доставки
CREATE TABLE notification_delivery_log (
    id UUID PRIMARY KEY,
    notification_id UUID REFERENCES notifications(id),
    channel VARCHAR(20), -- 'email', 'push', 'sms'
    
    -- Статус доставки
    status VARCHAR(20), -- 'pending', 'sent', 'delivered', 'failed', 'bounced'
    provider VARCHAR(50), -- 'sendgrid', 'firebase', 'twilio'
    provider_message_id VARCHAR(200),
    
    -- Ошибки
    error_code VARCHAR(50),
    error_message TEXT,
    
    -- Метрики
    sent_at TIMESTAMP,
    delivered_at TIMESTAMP,
    opened_at TIMESTAMP,
    clicked_at TIMESTAMP,
    
    created_at TIMESTAMP DEFAULT NOW()
);

4.2.2. Логика отправки уведомлений

Сервис уведомлений:

class NotificationService:
    
    def __init__(self):
        self.email_provider = SendGridProvider()
        self.push_provider = FirebaseProvider()
        self.sms_provider = TwilioProvider()
    
    async def send_notification(self, user_id, notification_type, data):
        """Отправляет уведомление пользователю"""
        
        # 1. Получаем настройки пользователя
        preferences = await self.get_user_preferences(user_id)
        
        # 2. Проверяем, включены ли уведомления этого типа
        if not self.is_notification_enabled(notification_type, preferences):
            logger.info(f"Notification {notification_type} disabled for user {user_id}")
            return
        
        # 3. Проверяем режим "Не беспокоить"
        if self.is_do_not_disturb_active(preferences):
            # Откладываем уведомление
            await self.schedule_notification(user_id, notification_type, data, 
                                            scheduled_at=preferences.dnd_end_time)
            return
        
        # 4. Создаем уведомление в БД
        notification = await self.create_notification(user_id, notification_type, data)
        
        # 5. Определяем каналы доставки
        channels = self.get_delivery_channels(notification_type, preferences)
        
        # 6. Проверяем группировку (объединение похожих уведомлений)
        if self.should_group_notification(notification):
            await self.group_notification(notification)
            return
        
        # 7. Отправляем по каждому каналу
        delivery_tasks = []
        
        if 'email' in channels:
            delivery_tasks.append(self.send_email(notification))
        
        if 'push' in channels:
            delivery_tasks.append(self.send_push(notification))
        
        if 'sms' in channels:
            # SMS только для Premium и только для критических уведомлений
            user = await get_user(user_id)
            if user.tier == 'premium' or notification.priority == 'critical':
                delivery_tasks.append(self.send_sms(notification))
        
        # In-app уведомление всегда создается
        await self.create_in_app_notification(notification)
        
        # Отправляем параллельно
        results = await asyncio.gather(*delivery_tasks, return_exceptions=True)
        
        # 8. Логируем результаты доставки
        await self.log_delivery_results(notification.id, results)
        
        return notification
    
    def is_notification_enabled(self, notification_type, preferences):
        """Проверяет, включен ли тип уведомления"""
        
        # Получаем категорию уведомления
        category = self.get_notification_category(notification_type)
        
        # Проверяем настройки категории
        category_prefs = preferences.get(category, {})
        
        # Проверяем частоту
        frequency = category_prefs.get('frequency', 'instant')
        if frequency == 'disabled':
            return False
        
        # Для дайджестов уведомление будет отправлено позже
        if frequency in ['digest_daily', 'digest_weekly']:
            # Добавляем в очередь дайджеста
            self.add_to_digest_queue(user_id, notification_type, data)
            return False
        
        return True
    
    def is_do_not_disturb_active(self, preferences):
        """Проверяет, активен ли режим 'Не беспокоить'"""
        
        if not preferences.get('do_not_disturb', False):
            return False
        
        now = datetime.now()
        current_time = now.time()
        current_day = now.isoweekday()
        
        # Проверяем день недели
        dnd_days = preferences.get('dnd_days', [])
        if dnd_days and current_day not in dnd_days:
            return False
        
        # Проверяем время
        dnd_start = preferences.get('dnd_start_time')
        dnd_end = preferences.get('dnd_end_time')
        
        if dnd_start and dnd_end:
            if dnd_start < dnd_end:
                # Обычный диапазон (например, 22:00 - 08:00 следующего дня)
                return dnd_start <= current_time <= dnd_end
            else:
                # Диапазон через полночь
                return current_time >= dnd_start or current_time <= dnd_end
        
        return False
    
    def should_group_notification(self, notification):
        """Определяет, нужно ли группировать уведомление"""
        
        # Группируем только определенные типы уведомлений
        groupable_types = [
            'message_new',
            'response_new',
            'listing_view'
        ]
        
        if notification.type not in groupable_types:
            return False
        
        # Проверяем, есть ли недавние похожие уведомления
        recent_similar = self.get_recent_similar_notifications(
            notification.user_id,
            notification.group_key,
            minutes=15
        )
        
        return len(recent_similar) > 0
    
    async def group_notification(self, notification):
        """Группирует уведомление с похожими"""
        
        # Находим существующее групповое уведомление
        group_notification = await self.get_or_create_group_notification(
            notification.user_id,
            notification.group_key
        )
        
        # Обновляем счетчик
        group_notification.data['count'] = group_notification.data.get('count', 0) + 1
        
        # Обновляем заголовок
        if notification.type == 'message_new':
            count = group_notification.data['count']
            group_notification.title = f"У вас {count} новых сообщений"
        
        await group_notification.save()
        
        # Отправляем обновленное уведомление
        await self.update_push_notification(group_notification)
    
    async def send_email(self, notification):
        """Отправляет email-уведомление"""
        
        try:
            user = await get_user(notification.user_id)
            
            # Генерируем HTML из шаблона
            html = await self.render_email_template(notification)
            
            # Отправляем через провайдера
            result = await self.email_provider.send(
                to=user.email,
                subject=notification.title,
                html=html,
                metadata={
                    'notification_id': notification.id,
                    'user_id': user.id,
                    'type': notification.type
                }
            )
            
            return {
                'channel': 'email',
                'status': 'sent',
                'provider_id': result.message_id
            }
            
        except Exception as e:
            logger.error(f"Failed to send email: {e}")
            return {
                'channel': 'email',
                'status': 'failed',
                'error': str(e)
            }
    
    async def send_push(self, notification):
        """Отправляет push-уведомление"""
        
        try:
            # Получаем токены устройств пользователя
            devices = await self.get_user_devices(notification.user_id)
            
            if not devices:
                return {
                    'channel': 'push',
                    'status': 'skipped',
                    'reason': 'no_devices'
                }
            
            # Формируем payload
            payload = {
                'title': notification.title,
                'body': notification.body,
                'data': notification.data,
                'click_action': notification.action_url,
                'icon': self.get_notification_icon(notification.type),
                'badge': await self.get_unread_count(notification.user_id)
            }
            
            # Отправляем на все устройства
            results = await self.push_provider.send_multicast(
                tokens=[d.token for d in devices],
                notification=payload
            )
            
            return {
                'channel': 'push',
                'status': 'sent',
                'success_count': results.success_count,
                'failure_count': results.failure_count
            }
            
        except Exception as e:
            logger.error(f"Failed to send push: {e}")
            return {
                'channel': 'push',
                'status': 'failed',
                'error': str(e)
            }
    
    async def send_sms(self, notification):
        """Отправляет SMS-уведомление"""
        
        try:
            user = await get_user(notification.user_id)
            
            if not user.phone or not user.phone_verified:
                return {
                    'channel': 'sms',
                    'status': 'skipped',
                    'reason': 'no_verified_phone'
                }
            
            # Формируем текст SMS (максимум 160 символов)
            sms_text = self.format_sms_text(notification)
            
            # Отправляем через провайдера
            result = await self.sms_provider.send(
                to=user.phone,
                body=sms_text
            )
            
            return {
                'channel': 'sms',
                'status': 'sent',
                'provider_id': result.sid
            }
            
        except Exception as e:
            logger.error(f"Failed to send SMS: {e}")
            return {
                'channel': 'sms',
                'status': 'failed',
                'error': str(e)
            }
    
    async def create_in_app_notification(self, notification):
        """Создает in-app уведомление"""
        
        # Сохраняем в БД
        await notification.save()
        
        # Отправляем через WebSocket, если пользователь онлайн
        if await self.is_user_online(notification.user_id):
            await self.send_websocket_notification(notification)
        
        return {
            'channel': 'in_app',
            'status': 'created'
        }
    
    async def send_digest(self, user_id, frequency='daily'):
        """Отправляет дайджест уведомлений"""
        
        # Получаем накопленные уведомления
        notifications = await self.get_digest_notifications(user_id, frequency)
        
        if not notifications:
            return
        
        # Группируем по категориям
        grouped = self.group_by_category(notifications)
        
        # Генерируем HTML дайджеста
        html = await self.render_digest_template(grouped)
        
        # Отправляем email
        user = await get_user(user_id)
        await self.email_provider.send(
            to=user.email,
            subject=f"Ваш {'ежедневный' if frequency == 'daily' else 'еженедельный'} дайджест",
            html=html
        )
        
        # Помечаем уведомления как отправленные
        await self.mark_notifications_sent(notifications)

Шаблоны уведомлений:

# Конфигурация уведомлений

NOTIFICATION_TEMPLATES = {
    'message_new': {
        'title': lambda data: f"Новое сообщение от {data['sender_name']}",
        'body': lambda data: truncate(data['message_text'], 100),
        'action_url': lambda data: f"/messages/{data['conversation_id']}",
        'action_label': "Ответить",
        'icon': "💬",
        'category': "messages",
        'priority': "high",
        'group_key': lambda data: f"messages:{data['conversation_id']}"
    },
    
    'response_new': {
        'title': lambda data: f"Новый отклик на объявление '{data['listing_title']}'",
        'body': lambda data: f"{data['investor_name']} откликнулся на ваше объявление",
        'action_url': lambda data: f"/listings/{data['listing_id']}/responses/{data['response_id']}",
        'action_label': "Посмотреть",
        'icon': "📬",
        'category': "responses",
        'priority': "high"
    },
    
    'listing_approved': {
        'title': "Ваше объявление опубликовано",
        'body': lambda data: f"Объявление '{data['listing_title']}' прошло модерацию и опубликовано",
        'action_url': lambda data: f"/listings/{data['listing_id']}",
        'action_label': "Посмотреть",
        'icon': "✅",
        'category': "listings",
        'priority': "high"
    },
    
    'listing_rejected': {
        'title': "Ваше объявление отклонено",
        'body': lambda data: f"Объявление '{data['listing_title']}' не прошло модерацию. Причина: {data['reason']}",
        'action_url': lambda data: f"/listings/{data['listing_id']}/edit",
        'action_label': "Исправить",
        'icon': "❌",
        'category': "listings",
        'priority': "high"
    },
    
    'recommendation_new': {
        'title': "Новое предложение для вас",
        'body': lambda data: f"Объявление '{data['listing_title']}' может вас заинтересовать",
        'action_url': lambda data: f"/listings/{data['listing_id']}",
        'action_label': "Посмотреть",
        'icon': "✨",
        'category': "recommendations",
        'priority': "medium"
    },
    
    'verification_approved': {
        'title': "Верификация пройдена",
        'body': "Ваш профиль верифицирован. Теперь вы можете пользоваться всеми возможностями платформы.",
        'action_url': "/profile",
        'action_label': "Посмотреть профиль",
        'icon': "✓",
        'category': "verification",
        'priority': "high"
    },
    
    'subscription_expiring': {
        'title': "Ваша подписка истекает",
        'body': lambda data: f"Подписка Premium истекает через {data['days_left']} дней",
        'action_url': "/billing",
        'action_label': "Продлить",
        'icon': "⚠️",
        'category': "billing",
        'priority': "high"
    },
    
    'payment_failed': {
        'title': "Не удалось списать оплату",
        'body': lambda data: f"Платеж на сумму {data['amount']} ₽ не прошел. Проверьте данные карты.",
        'action_url': "/billing/payment-methods",
        'action_label': "Обновить",
        'icon': "💳",
        'category': "billing",
        'priority': "critical"
    },
    
    'review_new': {
        'title': lambda data: f"{data['author_name']} оставил отзыв",
        'body': lambda data: f"Рейтинг: {'⭐' * int(data['rating'])} ({data['rating']}/5)",
        'action_url': lambda data: f"/profile/reviews#{data['review_id']}",
        'action_label': "Посмотреть",
        'icon': "⭐",
        'category': "reviews",
        'priority': "medium"
    },
    
    'security_login_new_device': {
        'title': "Вход с нового устройства",
        'body': lambda data: f"Обнаружен вход с {data['device']} из {data['location']}",
        'action_url': "/settings/security",
        'action_label': "Проверить",
        'icon': "🔐",
        'category': "security",
        'priority': "high"
    }
}

API endpoints:

// API для работы с уведомлениями

// Получить список уведомлений
GET /api/v1/notifications
Query params:
  - page: номер страницы
  - limit: количество на странице
  - filter: 'all', 'unread', 'read'
  - category: фильтр по категории

Response:
{
  "data": [
    {
      "id": "uuid",
      "type": "message_new",
      "category": "messages",
      "priority": "high",
      "title": "Новое сообщение от Иван Иванов",
      "body": "Здравствуйте, меня заинтересовало ваше предложение...",
      "icon": "💬",
      "actionUrl": "/messages/conversation-id",
      "actionLabel": "Ответить",
      "isRead": false,
      "createdAt": "2024-01-15T10:30:00Z"
    }
  ],
  "meta": {
    "total": 45,
    "unreadCount": 12,
    "page": 1,
    "totalPages": 3
  }
}

// Отметить уведомление как прочитанное
POST /api/v1/notifications/:id/read

// Отметить все уведомления как прочитанные
POST /api/v1/notifications/read-all

// Удалить уведомление
DELETE /api/v1/notifications/:id

// Получить настройки уведомлений
GET /api/v1/notifications/preferences

Response:
{
  "messages": {
    "email": true,
    "push": true,
    "sms": false,
    "inApp": true,
    "frequency": "instant"
  },
  "responses": { ... },
  "doNotDisturb": {
    "enabled": false,
    "startTime": "22:00",
    "endTime": "08:00",
    "days": [1, 2, 3, 4, 5]
  },
  "digest": {
    "enabled": true,
    "time": "09:00",
    "days": [1, 2, 3, 4, 5]
  }
}

// Обновить настройки уведомлений
PUT /api/v1/notifications/preferences
Body:
{
  "messages": {
    "email": true,
    "push": true,
    "frequency": "instant"
  }
}

// Получить количество непрочитанных
GET /api/v1/notifications/unread-count

Response:
{
  "total": 12,
  "byCategory": {
    "messages": 5,
    "responses": 3,
    "recommendations": 4
  }
}

UI компоненты:

// Компонент центра уведомлений

function NotificationCenter() {
  const [notifications, setNotifications] = useState([]);
  const [unreadCount, setUnreadCount] = useState(0);
  const [isOpen, setIsOpen] = useState(false);
  const [filter, setFilter] = useState('all');
  
  useEffect(() => {
    loadNotifications();
    
    // Подписываемся на новые уведомления через WebSocket
    socket.on('notification:new', handleNewNotification);
    
    // Обновляем счетчик непрочитанных каждые 30 секунд
    const interval = setInterval(updateUnreadCount, 30000);
    
    return () => {
      socket.off('notification:new', handleNewNotification);
      clearInterval(interval);
    };
  }, [filter]);
  
  const loadNotifications = async () => {
    const response = await api.get('/api/v1/notifications', {
      params: { filter }
    });
    setNotifications(response.data.data);
    setUnreadCount(response.data.meta.unreadCount);
  };
  
  const handleNewNotification = (notification) => {
    // Добавляем в начало списка
    setNotifications(prev => [notification, ...prev]);
    setUnreadCount(prev => prev + 1);
    
    // Показываем toast-уведомление
    showToast(notification);
    
    // Воспроизводим звук (если включено)
    if (settings.soundEnabled) {
      playNotificationSound();
    }
  };
  
  const markAsRead = async (notificationId) => {
    await api.post(`/api/v1/notifications/${notificationId}/read`);
    
    setNotifications(prev =>
      prev.map(n =>
        n.id === notificationId ? { ...n, isRead: true } : n
      )
    );
    
    setUnreadCount(prev => Math.max(0, prev - 1));
  };
  
  const markAllAsRead = async () => {
    await api.post('/api/v1/notifications/read-all');
    
    setNotifications(prev =>
      prev.map(n => ({ ...n, isRead: true }))
    );
    
    setUnreadCount(0);
  };
  
  return (
    <div className="notification-center">
      {/* Кнопка открытия */}
      <button
        className="notification-bell"
        onClick={() => setIsOpen(!isOpen)}
      >
        🔔
        {unreadCount > 0 && (
          <span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
        )}
      </button>
      
      {/* Выпадающая панель */}
      {isOpen && (
        <div className="notification-dropdown">
          <div className="dropdown-header">
            <h3>Уведомления</h3>
            <div className="actions">
              <button onClick={markAllAsRead}>
                Отметить все как прочитанные
              </button>
              <Link to="/notifications/settings">
                ⚙️ Настройки
              </Link>
            </div>
          </div>
          
          <div className="filters">
            <button
              className={filter === 'all' ? 'active' : ''}
              onClick={() => setFilter('all')}
            >
              Все
            </button>
            <button
              className={filter === 'unread' ? 'active' : ''}
              onClick={() => setFilter('unread')}
            >
              Непрочитанные ({unreadCount})
            </button>
          </div>
          
          <div className="notifications-list">
            {notifications.length === 0 ? (
              <EmptyState
                icon="🔔"
                title="Нет уведомлений"
                description="Здесь будут отображаться ваши уведомления"
              />
            ) : (
              notifications.map(notification => (
                <NotificationItem
                  key={notification.id}
                  notification={notification}
                  onRead={() => markAsRead(notification.id)}
                  onClick={() => {
                    markAsRead(notification.id);
                    navigate(notification.actionUrl);
                    setIsOpen(false);
                  }}
                />
              ))
            )}
          </div>
          
          <div className="dropdown-footer">
            <Link to="/notifications">Посмотреть все</Link>
          </div>
        </div>
      )}
    </div>
  );
}

// Компонент элемента уведомления
function NotificationItem({ notification, onRead, onClick }) {
  return (
    <div
      className={`notification-item ${notification.isRead ? 'read' : 'unread'}`}
      onClick={onClick}
    >
      <div className="icon">{notification.icon}</div>
      
      <div className="content">
        <div className="header">
          <h4>{notification.title}</h4>
          <span className="time">{formatTimeAgo(notification.createdAt)}</span>
        </div>
        
        <p className="body">{notification.body}</p>
        
        {notification.actionLabel && (
          <button className="action-btn">
            {notification.actionLabel}
          </button>
        )}
      </div>
      
      {!notification.isRead && (
        <button
          className="mark-read-btn"
          onClick={(e) => {
            e.stopPropagation();
            onRead();
          }}
          title="Отметить как прочитанное"
        >
          ✓
        </button>
      )}
    </div>
  );
}

// Компонент настроек уведомлений
function NotificationSettings() {
  const [preferences, setPreferences] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    loadPreferences();
  }, []);
  
  const loadPreferences = async () => {
    const response = await api.get('/api/v1/notifications/preferences');
    setPreferences(response.data);
    setLoading(false);
  };
  
  const updatePreference = async (category, channel, value) => {
    const updated = {
      ...preferences,
      [category]: {
        ...preferences[category],
        [channel]: value
      }
    };
    
    setPreferences(updated);
    
    await api.put('/api/v1/notifications/preferences', updated);
  };
  
  if (loading) return <Loader />;
  
  return (
    <div className="notification-settings">
      <h2>Настройки уведомлений</h2>
      
      {Object.entries(CATEGORIES).map(([key, category]) => (
        <div key={key} className="category-section">
          <h3>{category.title}</h3>
          <p className="description">{category.description}</p>
          
          <div className="channels">
            <label>
              <input
                type="checkbox"
                checked={preferences[key]?.email}
                onChange={(e) => updatePreference(key, 'email', e.target.checked)}
              />
              Email
            </label>
            
            <label>
              <input
                type="checkbox"
                checked={preferences[key]?.push}
                onChange={(e) => updatePreference(key, 'push', e.target.checked)}
              />
              Push-уведомления
            </label>
            
            {userTier === 'premium' && (
              <label>
                <input
                  type="checkbox"
                  checked={preferences[key]?.sms}
                  onChange={(e) => updatePreference(key, 'sms', e.target.checked)}
                />
                SMS
              </label>
            )}
            
            <label>
              <input
                type="checkbox"
                checked={preferences[key]?.inApp}
                onChange={(e) => updatePreference(key, 'inApp', e.target.checked)}
              />
              В приложении
            </label>
          </div>
          
          <div className="frequency">
            <label>Частота:</label>
            <select
              value={preferences[key]?.frequency}
              onChange={(e) => updatePreference(key, 'frequency', e.target.value)}
            >
              <option value="instant">Мгновенно</option>
              <option value="digest_daily">Ежедневный дайджест</option>
              <option value="digest_weekly">Еженедельный дайджест</option>
              <option value="disabled">Отключено</option>
            </select>
          </div>
        </div>
      ))}
      
      <div className="dnd-section">
        <h3>Режим "Не беспокоить"</h3>
        
        <label>
          <input
            type="checkbox"
            checked={preferences.doNotDisturb?.enabled}
            onChange={(e) => updateDNDSetting('enabled', e.target.checked)}
          />
          Включить режим "Не беспокоить"
        </label>
        
        {preferences.doNotDisturb?.enabled && (
          <>
            <div className="time-range">
              <label>
                С:
                <input
                  type="time"
                  value={preferences.doNotDisturb.startTime}
                  onChange={(e) => updateDNDSetting('startTime', e.target.value)}
                />
              </label>
              
              <label>
                До:
                <input
                  type="time"
                  value={preferences.doNotDisturb.endTime}
                  onChange={(e) => updateDNDSetting('endTime', e.target.value)}
                />
              </label>
            </div>
            
            <div className="days">
              <label>Дни недели:</label>
              {WEEKDAYS.map(day => (
                <label key={day.value}>
                  <input
                    type="checkbox"
                    checked={preferences.doNotDisturb.days?.includes(day.value)}
                    onChange={(e) => toggleDNDDay(day.value, e.target.checked)}
                  />
                  {day.label}
                </label>
              ))}
            </div>
          </>
        )}
      </div>
      
      <div className="digest-section">
        <h3>Дайджест уведомлений</h3>
        
        <label>
          <input
            type="checkbox"
            checked={preferences.digest?.enabled}
            onChange={(e) => updateDigestSetting('enabled', e.target.checked)}
          />
          Получать дайджест по email
        </label>
        
        {preferences.digest?.enabled && (
          <>
            <label>
              Время отправки:
              <input
                type="time"
                value={preferences.digest.time}
                onChange={(e) => updateDigestSetting('time', e.target.value)}
              />
            </label>
            
            <div className="days">
              <label>Дни недели:</label>
              {WEEKDAYS.map(day => (
                <label key={day.value}>
                  <input
                    type="checkbox"
                    checked={preferences.digest.days?.includes(day.value)}
                    onChange={(e) => toggleDigestDay(day.value, e.target.checked)}
                  />
                  {day.label}
                </label>
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}

const CATEGORIES = {
  messages: {
    title: "Сообщения",
    description: "Уведомления о новых сообщениях в чате"
  },
  responses: {
    title: "Отклики",
    description: "Уведомления об откликах на ваши объявления"
  },
  listings: {
    title: "Объявления",
    description: "Уведомления о статусе ваших объявлений"
  },
  recommendations: {
    title: "Рекомендации",
    description: "Уведомления о новых релевантных объявлениях"
  },
  verification: {
    title: "Верификация",
    description: "Уведомления о статусе верификации"
  },
  billing: {
    title: "Подписка и оплата",
    description: "Уведомления о платежах и подписке"
  },
  reviews: {
    title: "Отзывы",
    description: "Уведомления о новых отзывах"
  },
  security: {
    title: "Безопасность",
    description: "Уведомления о безопасности аккаунта"
  }
};

const WEEKDAYS = [
  { value: 1, label: 'Пн' },
  { value: 2, label: 'Вт' },
  { value: 3, label: 'Ср' },
  { value: 4, label: 'Чт' },
  { value: 5, label: 'Пт' },
  { value: 6, label: 'Сб' },
  { value: 7, label: 'Вс' }
];

Примеры use-cases:

UC-4.5: Инвестор получает уведомление о новом сообщении

1. Предприниматель отправляет сообщение инвестору

2. Система создает уведомление:
   - Тип: message_new
   - Приоритет: high
   - Каналы: email, push, in-app

3. Проверяет настройки инвестора:
   - Email: включен, частота: мгновенно
   - Push: включен
   - SMS: выключен (не Premium)
   - In-app: включен

4. Проверяет режим "Не беспокоить":
   - Текущее время: 23:00
   - DND активен с 22:00 до 08:00
   - Push и SMS откладываются до 08:00
   - Email и in-app отправляются сразу (низкий приоритет DND)

5. Отправляет уведомления:
   
   Email:
   - Тема: "Новое сообщение от Иван Петров"
   - Тело: превью сообщения + кнопка "Ответить"
   - Отправлено через SendGrid
   
   In-app:
   - Появляется в центре уведомлений
   - Счетчик непрочитанных увеличивается
   - Если пользователь онлайн, показывается toast
   
   Push:
   - Отложен до 08:00 (DND)

6. В 08:00 отправляется отложенное push-уведомление

7. Инвестор кликает на уведомление:
   - Открывается чат
   - Уведомление отмечается как прочитанное
   - Счетчик непрочитанных уменьшается

UC-4.6: Пользователь настраивает дайджест уведомлений

1. Пользователь заходит в "Настройки" → "Уведомления"

2. Видит настройки по категориям:
   
   Сообщения:
   [✓] Email  [✓] Push  [ ] SMS  [✓] В приложении
   Частота: [Мгновенно ▼]
   
   Рекомендации:
   [✓] Email  [ ] Push  [ ] SMS  [✓] В приложении
   Частота: [Ежедневный дайджест ▼]

3. Меняет настройки для рекомендаций:
   - Частота: "Ежедневный дайджест"
   - Время: 09:00
   - Дни: Пн, Вт, Ср, Чт, Пт

4. Сохраняет настройки

5. Теперь уведомления о рекомендациях:
   - Не отправляются мгновенно
   - Накапливаются в очереди дайджеста
   - Отправляются один раз в день в 09:00 по рабочим дням

6. На следующий день в 09:00 пользователь получает email:
   
   "Ваш ежедневный дайджест"
   
   Новые рекомендации (5):
   - Объявление 1: "Инвестиции в IT-стартап"
   - Объявление 2: "Партнерство в e-commerce"
   - ...
   
   [Посмотреть все рекомендации]

UC-4.7: Система отправляет критическое уведомление

1. Система обнаруживает неудачную попытку списания оплаты

2. Создает критическое уведомление:
   - Тип: payment_failed
   - Приоритет: critical
   - Каналы: email, push, SMS, in-app

3. Проверяет настройки пользователя:
   - Все каналы включены
   - DND активен

4. Критические уведомления игнорируют DND:
   - Отправляются немедленно по всем каналам
   - Даже если пользователь отключил категорию

5. Отправляет уведомления:
   
   Email:
   "Не удалось списать оплату"
   "Ваша подписка Premium будет приостановлена через 3 дня"
   [Обновить способ оплаты]
   
   Push:
   "💳 Платеж не прошел"
   "Проверьте данные карты"
   
   SMS (для Premium):
   "Платформа: Не удалось списать 4990р. Обновите карту: platform.com/billing"
   
   In-app:
   Красный баннер в верхней части всех страниц

6. Пользователь видит уведомления на всех устройствах

7. Кликает на уведомление и переходит к обновлению способа оплаты

8. После успешного обновления:
   - Критическое уведомление скрывается
   - Отправляется подтверждение: "Способ оплаты обновлен"

Приоритет: [CRITICAL] — MVP

Обоснование решения:

  • Engagement: Уведомления возвращают пользователей на платформу
  • Конверсия: Своевременные уведомления увеличивают скорость ответа
  • Персонализация: Настраиваемые уведомления улучшают UX
  • Retention: Дайджесты поддерживают связь с неактивными пользователями

Риски, которые закрывает:

  • Пропущенные важные события (сообщения, отклики)
  • Спам-уведомления (гибкие настройки)
  • Низкая вовлеченность (push-уведомления)
  • Отток пользователей (напоминания о платформе)

Влияние на метрики:

  • Retention (D7): +30% за счет push-уведомлений
  • Время ответа на сообщения: -40%
  • Конверсия отклик → диалог: +25%
  • Engagement: +45% (пользователи возвращаются по уведомлениям)
  • Churn rate: -20% (напоминания о подписке)

Связанные изменения:

  • Раздел «Профиль» — добавить настройки уведомлений
  • Раздел «Инфраструктура» — добавить сервисы отправки (SendGrid, Firebase, Twilio)
  • Раздел «Аналитика» — добавить метрики по уведомлениям (open rate, click rate)
  • Раздел «Мобильное приложение» — интеграция push-уведомлений