From 9462f08825b25ac89957c0bad52830aff2ffda85 Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 12 Mar 2026 00:42:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=B5=D0=B1=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../communications/notifications/config.py | 1 + .../communications/notifications/handlers.py | 95 ++++++ backend/eusrr_backend/settings.py | 13 +- frontend/src/components/Providers.tsx | 11 +- .../src/contexts/NotificationsContext.tsx | 195 +++++++++++ frontend/src/hooks/useApi.ts | 322 +----------------- .../src/hooks/useNotificationWebSocket.ts | 122 ------- frontend/src/lib/websocketManager.ts | 151 ++++++++ 8 files changed, 458 insertions(+), 452 deletions(-) create mode 100644 frontend/src/contexts/NotificationsContext.tsx delete mode 100644 frontend/src/hooks/useNotificationWebSocket.ts create mode 100644 frontend/src/lib/websocketManager.ts diff --git a/backend/communications/notifications/config.py b/backend/communications/notifications/config.py index 88489384..6dee0e03 100644 --- a/backend/communications/notifications/config.py +++ b/backend/communications/notifications/config.py @@ -19,6 +19,7 @@ class NotificationVerbs: MENTION = 'chat_mention' REPLY = 'chat_reply' ADDED_TO_CHAT = 'chat_added_to_chat' + COMMENTED = 'commented' # Для комментариев к объектам class MessageTemplates: diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index 2da5b768..815ee3da 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -82,6 +82,10 @@ def notify_new_message(message): 2. Ответы на сообщения - средний приоритет 3. Обычные сообщения в чате - низкий приоритет + Специальная обработка для чатов комментариев (type='comments'): + - Уведомляет автора объекта (поста/документа/заявки) + - Уведомляет других участников дискуссии + Args: message: Экземпляр модели Message """ @@ -89,6 +93,11 @@ def notify_new_message(message): author = message.author content = message.content + # Специальная обработка для чатов комментариев + if chat.type == 'comments': + _notify_comment(message) + return + # Получаем всех участников чата кроме автора if chat.type in ['announcement', 'channel', 'global']: participants = chat.get_participants().exclude(id=author.id) @@ -202,6 +211,92 @@ def notify_new_message(message): ) +def _notify_comment(message): + """ + Отправляет уведомления о новом комментарии к объекту. + + Уведомляет: + 1. Автора объекта (Post/Document/Request) + 2. Других участников обсуждения (кто оставлял комментарии) + + Args: + message: Экземпляр модели Message (в чате type='comments') + """ + chat = message.chat + author = message.author + content = message.content + + if not chat.context_object: + # Нет привязанного объекта - обычная обработка + return + + recipients_set = set() + context_obj = chat.context_object + + # Определяем автора объекта в зависимости от типа + object_author = None + object_url = None + object_type = "объекта" + + # Проверяем тип объекта через модель + model_name = context_obj.__class__.__name__ + + if model_name == 'Post': + object_author = getattr(context_obj, 'author', None) + object_url = f"/feed/#{context_obj.id}" + object_type = "публикации" + elif model_name == 'Document': + object_author = getattr(context_obj, 'uploaded_by', None) + object_url = f"/documents/{context_obj.id}/" + object_type = "документа" + elif hasattr(context_obj, 'employee'): # Request + object_author = getattr(context_obj, 'employee', None) + object_url = f"/requests/{context_obj.id}/" + object_type = "заявки" + + # 1. Уведомляем автора объекта + if object_author and object_author.id != author.id: + recipients_set.add(object_author) + + # 2. Уведомляем всех, кто участвовал в обсуждении + from ..models import Message + previous_commenters = Message.objects.filter( + chat=chat, + is_deleted=False + ).exclude( + author_id=author.id + ).values_list('author_id', flat=True).distinct() + + if previous_commenters: + from django.contrib.auth import get_user_model + User = get_user_model() + recipients_set.update( + User.objects.filter(id__in=previous_commenters, is_active=True) + ) + + # Отправляем уведомления + author_name = author.get_full_name() or author.username + + for recipient in recipients_set: + _send_notification( + sender=author, + recipient=recipient, + verb='commented', + action_object=message, + target=context_obj, + description=truncate_message(content, 100), + action_url=object_url or f"/chat/{chat.id}/", + data={ + 'title': f'{author_name} оставил комментарий к {object_type}', + 'chat_id': chat.id, + 'message_id': message.id, + 'author_id': author.id, + 'object_type': model_name, + 'object_id': context_obj.id, + } + ) + + def notify_chat_added(chat, new_users, added_by=None): """ Отправляет уведомления пользователям о добавлении в чат. diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index e6d751d5..ad2f71f7 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -511,12 +511,13 @@ CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут максимум на задачу CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # Мягкий лимит - 25 минут -# Приоритеты очередей -CELERY_TASK_ROUTES = { - 'notifications.*': {'queue': 'notifications'}, - 'documents.tasks.*': {'queue': 'default'}, - 'employees.tasks.*': {'queue': 'default'}, -} +# Приоритеты очередей (закомментировано для упрощения в development) +# В production можно раскомментировать для масштабирования +# CELERY_TASK_ROUTES = { +# 'notifications.*': {'queue': 'notifications'}, +# 'documents.tasks.*': {'queue': 'default'}, +# 'employees.tasks.*': {'queue': 'default'}, +# } # Настройки для периодических задач (Celery Beat) CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx index 87d4c3fa..1f0a8dbb 100644 --- a/frontend/src/components/Providers.tsx +++ b/frontend/src/components/Providers.tsx @@ -2,16 +2,19 @@ import { UserProvider } from "@/contexts/UserContext"; import { CalendarProvider } from "@/contexts/CalendarContext"; +import { NotificationsProvider } from "@/contexts/NotificationsContext"; import { ReactNode } from "react"; import { Toaster } from "sonner"; export function Providers({ children }: { children: ReactNode }) { return ( - - - {children} - + + + + {children} + + ); } diff --git a/frontend/src/contexts/NotificationsContext.tsx b/frontend/src/contexts/NotificationsContext.tsx new file mode 100644 index 00000000..79e27bf5 --- /dev/null +++ b/frontend/src/contexts/NotificationsContext.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import apiClient from '@/lib/api'; +import wsManager from '@/lib/websocketManager'; + +interface NotificationsContextType { + notifications: any[]; + unreadCount: number; + loading: boolean; + error: Error | null; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; + markCategoryAsRead: (category: string) => Promise; + deleteNotification: (id: number) => Promise; + deleteAllRead: () => Promise; +} + +const NotificationsContext = createContext(undefined); + +export function NotificationsProvider({ children }: { children: React.ReactNode }) { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [unreadCount, setUnreadCount] = useState(0); + + // Загрузка начальных данных + useEffect(() => { + async function fetchNotifications() { + try { + const data = await apiClient.getNotifications({ + page_size: 50, + unread_only: true + }); + const notifs = data.notifications || data.results || data; + const notificationsArray = Array.isArray(notifs) ? notifs : []; + setNotifications(notificationsArray); + setUnreadCount(data.unread_count ?? notificationsArray.length); + } catch (err) { + setError(err as Error); + setNotifications([]); + } finally { + setLoading(false); + } + } + + fetchNotifications(); + }, []); + + // ЕДИНСТВЕННЫЙ WebSocket через singleton manager + useEffect(() => { + if (typeof window === 'undefined' || !localStorage.getItem('access_token')) { + console.log('[NotificationsContext] WebSocket disabled: not in browser or not authenticated'); + return; + } + + let unsubscribe: (() => void) | null = null; + + const setupWebSocket = () => { + // Подписываемся на сообщения через singleton + unsubscribe = wsManager.subscribe((data) => { + // Игнорируем служебные события + if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) { + return; + } + + // Новое уведомление + if (data.type === 'notification' && data.notification) { + console.log('[NotificationsContext] New notification:', data.notification); + + setNotifications(prev => { + if (prev.some(n => n.id === data.notification.id)) { + return prev; + } + return [data.notification, ...prev]; + }); + + if (!data.notification.is_read) { + setUnreadCount(prev => prev + 1); + } + } + + // Обновление счетчика + if (data.type === 'unread_count' && typeof data.count === 'number') { + console.log('[NotificationsContext] Unread count update:', data.count); + setUnreadCount(data.count); + } + }); + }; + + setupWebSocket(); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, []); + + // Синхронизация между вкладками через CustomEvent + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleMarkedRead = (e: Event) => { + const customEvent = e as CustomEvent; + const { id } = customEvent.detail; + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + }; + + const handleAllMarkedRead = () => { + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); + setUnreadCount(0); + }; + + window.addEventListener('notification-marked-read', handleMarkedRead); + window.addEventListener('all-notifications-marked-read', handleAllMarkedRead); + + return () => { + window.removeEventListener('notification-marked-read', handleMarkedRead); + window.removeEventListener('all-notifications-marked-read', handleAllMarkedRead); + }; + }, []); + + const markAsRead = useCallback(async (id: number) => { + await apiClient.markNotificationAsRead(id); + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('notification-marked-read', { detail: { id } })); + } + }, []); + + const markAllAsRead = useCallback(async () => { + await apiClient.markAllNotificationsAsRead(); + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); + setUnreadCount(0); + + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('all-notifications-marked-read')); + } + }, []); + + const markCategoryAsRead = useCallback(async (category: string) => { + await apiClient.markCategoryAsRead(category); + const updatedNotifications = notifications.map(n => + n.category === category ? { ...n, is_read: true } : n + ); + setNotifications(updatedNotifications); + const newUnreadCount = updatedNotifications.filter(n => !n.is_read).length; + setUnreadCount(newUnreadCount); + }, [notifications]); + + const deleteNotification = useCallback(async (id: number) => { + await apiClient.deleteNotification(id); + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + const deleteAllRead = useCallback(async (): Promise => { + const result = await apiClient.deleteAllReadNotifications(); + setNotifications(prev => prev.filter(n => !n.is_read)); + return result.count; + }, []); + + const value = { + notifications, + unreadCount, + loading, + error, + markAsRead, + markAllAsRead, + markCategoryAsRead, + deleteNotification, + deleteAllRead, + }; + + return ( + + {children} + + ); +} + +export function useNotifications() { + const context = useContext(NotificationsContext); + if (context === undefined) { + throw new Error('useNotifications must be used within NotificationsProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index a91bae52..a85ec691 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -169,323 +169,5 @@ export function useChats() { return { chats, loading, error }; } -export function useNotifications() { - const [notifications, setNotifications] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [unreadCount, setUnreadCount] = useState(0); - - // Загрузка начальных данных - useEffect(() => { - async function fetchNotifications() { - try { - // Для бейджей загружаем только непрочитанные (первые 50) - const data = await apiClient.getNotifications({ - page_size: 50, - unread_only: true - }); - // Бэкенд возвращает { notifications: [...], unread_count: N } - const notifs = data.notifications || data.results || data; - // Убеждаемся что notifs это массив - const notificationsArray = Array.isArray(notifs) ? notifs : []; - setNotifications(notificationsArray); - // Используем unread_count из API или считаем сами - setUnreadCount(data.unread_count ?? notificationsArray.length); - } catch (err) { - setError(err as Error); - setNotifications([]); // В случае ошибки устанавливаем пустой массив - } finally { - setLoading(false); - } - } - - fetchNotifications(); - }, []); - - // WebSocket для realtime обновлений - useEffect(() => { - // Проверяем что мы в браузере и пользователь авторизован - if (typeof window === 'undefined' || !localStorage.getItem('access_token')) { - console.log('[Notifications] WebSocket disabled: not in browser or not authenticated'); - return; - } - - let ws: WebSocket | null = null; - let reconnectTimeout: NodeJS.Timeout | null = null; - let reconnectAttempts = 0; - const maxReconnectAttempts = 5; - - const connect = () => { - try { - // Используем функцию getWebSocketUrl для определения URL - const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000'; - const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:'; - const host = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); - let wsUrl = `${protocol}//${host}/ws/`; - - // Добавляем токен - const token = localStorage.getItem('access_token'); - if (token) { - wsUrl += `?token=${token}`; - } - - console.log('[Notifications] WebSocket connecting to:', wsUrl); - console.log('[Notifications] Backend URL:', backendUrl); - ws = new WebSocket(wsUrl); - - ws.onopen = () => { - console.log('[Notifications] WebSocket connected'); - reconnectAttempts = 0; - }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - // Игнорируем служебные события - if (['ping', 'list_update', 'new_message', 'marked_read', 'message_edited', 'reaction_added', 'reaction_removed', 'poll_update'].includes(data.type)) { - return; - } - - // Новое уведомление - if (data.type === 'notification' && data.notification) { - console.log('[Notifications] New notification:', data.notification); - - // Добавляем в начало списка если это новое - setNotifications(prev => { - // Проверяем не дубликат ли - if (prev.some(n => n.id === data.notification.id)) { - return prev; - } - return [data.notification, ...prev]; - }); - - // Обновляем счетчик если непрочитанное - if (!data.notification.is_read) { - setUnreadCount(prev => prev + 1); - } - } - - // Обновление счетчика - if (data.type === 'unread_count' && typeof data.count === 'number') { - console.log('[Notifications] Unread count update:', data.count); - setUnreadCount(data.count); - } - } catch (error) { - console.error('[Notifications] Parse error:', error); - } - }; - - ws.onerror = (error) => { - console.error('[Notifications] WebSocket error:', error); - console.error('[Notifications] WebSocket URL was:', wsUrl); - console.error('[Notifications] WebSocket state:', ws?.readyState); - }; - - ws.onclose = () => { - console.log('[Notifications] WebSocket disconnected'); - ws = null; - - // Автоматический reconnect - if (reconnectAttempts < maxReconnectAttempts) { - reconnectAttempts++; - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); - console.log(`[Notifications] Reconnecting in ${delay}ms... (${reconnectAttempts}/${maxReconnectAttempts})`); - reconnectTimeout = setTimeout(connect, delay); - } - }; - } catch (error) { - console.error('[Notifications] Connection failed:', error); - } - }; - - connect(); - - // Cleanup - return () => { - if (reconnectTimeout) { - clearTimeout(reconnectTimeout); - } - if (ws) { - ws.close(); - } - }; - }, []); - - // Синхронизация между экземплярами хука через CustomEvent - useEffect(() => { - if (typeof window === 'undefined') return; - - const handleMarkedRead = (e: Event) => { - const customEvent = e as CustomEvent; - const { id } = customEvent.detail; - setNotifications(prev => - prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) - ); - setUnreadCount(prev => Math.max(0, prev - 1)); - }; - - const handleAllMarkedRead = () => { - setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); - setUnreadCount(0); - }; - - const handleCategoryMarkedRead = async (e: Event) => { - const customEvent = e as CustomEvent; - const { category, verbs } = customEvent.detail; - - // Используем переданные verb'ы для проверки - setNotifications(prev => { - const updated = prev.map(n => { - const isInCategory = verbs.includes(n.verb); - return isInCategory ? { ...n, is_read: true } : n; - }); - - // Пересчитываем счетчик - const newUnreadCount = updated.filter(n => !n.is_read).length; - setUnreadCount(newUnreadCount); - - return updated; - }); - }; - - const handleDeleted = (e: Event) => { - const customEvent = e as CustomEvent; - const { id, wasUnread } = customEvent.detail; - setNotifications(prev => prev.filter(n => n.id !== id)); - if (wasUnread) { - setUnreadCount(prev => Math.max(0, prev - 1)); - } - }; - - window.addEventListener('notification-marked-read', handleMarkedRead); - window.addEventListener('notifications-all-marked-read', handleAllMarkedRead); - window.addEventListener('category-marked-read', handleCategoryMarkedRead); - window.addEventListener('notification-deleted', handleDeleted); - - return () => { - window.removeEventListener('notification-marked-read', handleMarkedRead); - window.removeEventListener('notifications-all-marked-read', handleAllMarkedRead); - window.removeEventListener('category-marked-read', handleCategoryMarkedRead); - window.removeEventListener('notification-deleted', handleDeleted); - }; - }, []); - - const markAsRead = async (id: number) => { - try { - await apiClient.markNotificationAsRead(id); - setNotifications(prev => - prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) - ); - setUnreadCount(prev => Math.max(0, prev - 1)); - - // Синхронизация между экземплярами хука через CustomEvent - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('notification-marked-read', { - detail: { id } - })); - } - } catch (err) { - console.error('Failed to mark notification as read:', err); - } - }; - - const markAllAsRead = async () => { - try { - await apiClient.markAllNotificationsAsRead(); - setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); - setUnreadCount(0); - - // Синхронизация между экземплярами хука через CustomEvent - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('notifications-all-marked-read')); - } - } catch (err) { - console.error('Failed to mark all notifications as read:', err); - } - }; - - const deleteNotification = async (id: number) => { - try { - await apiClient.deleteNotification(id); - - // Проверяем было ли непрочитанным ДО удаления - const deletedNotif = notifications.find(n => n.id === id); - const wasUnread = deletedNotif && !deletedNotif.is_read; - - setNotifications(prev => prev.filter(n => n.id !== id)); - if (wasUnread) { - setUnreadCount(prev => Math.max(0, prev - 1)); - } - - // Синхронизация между экземплярами хука через CustomEvent - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('notification-deleted', { - detail: { id, wasUnread } - })); - } - } catch (err) { - console.error('Failed to delete notification:', err); - } - }; - - const deleteAllRead = async () => { - try { - const result = await apiClient.deleteAllReadNotifications(); - setNotifications(prev => prev.filter(n => !n.is_read)); - return result.count; - } catch (err) { - console.error('Failed to delete all read notifications:', err); - return 0; - } - }; - - const markCategoryAsRead = async (category: string) => { - try { - const result = await apiClient.markCategoryAsRead(category); - - // Импортируем getVerbCategory, getVerbsByCategory для обработки - const { getVerbCategory, getVerbsByCategory } = await import('@/lib/verbTranslations'); - const categoryVerbs = getVerbsByCategory(category); - - // Помечаем уведомления этой категории как прочитанные - setNotifications(prev => - prev.map(n => { - const isInCategory = categoryVerbs.includes(n.verb); - return isInCategory ? { ...n, is_read: true } : n; - }) - ); - - // Пересчитываем счетчик непрочитанных - setNotifications(prev => { - const newUnreadCount = prev.filter(n => !n.is_read).length; - setUnreadCount(newUnreadCount); - return prev; - }); - - // Синхронизация между экземплярами хука через CustomEvent - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent('category-marked-read', { - detail: { category, verbs: categoryVerbs } - })); - } - - return result.count; - } catch (err) { - console.error('Failed to mark category as read:', err); - return 0; - } - }; - - return { - notifications, - loading, - error, - unreadCount, - markAsRead, - markAllAsRead, - markCategoryAsRead, - deleteNotification, - deleteAllRead - }; -} +// Re-export useNotifications from context for backwards compatibility +export { useNotifications } from '@/contexts/NotificationsContext'; diff --git a/frontend/src/hooks/useNotificationWebSocket.ts b/frontend/src/hooks/useNotificationWebSocket.ts deleted file mode 100644 index f03aba9d..00000000 --- a/frontend/src/hooks/useNotificationWebSocket.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * WebSocket хук для realtime уведомлений - * Подключается к UserConsumer на /ws/ и слушает события уведомлений - */ -import { useEffect, useRef, useCallback } from 'react'; -import { getWebSocketUrl } from '@/lib/url'; - -interface NotificationMessage { - type: 'notification' | 'unread_count' | 'ping' | 'list_update' | 'new_message' | 'marked_read'; - notification?: any; - count?: number; -} - -interface UseNotificationWebSocketOptions { - onNotification: (notification: any) => void; - onUnreadCountUpdate: (count: number) => void; - enabled?: boolean; -} - -export function useNotificationWebSocket({ - onNotification, - onUnreadCountUpdate, - enabled = true -}: UseNotificationWebSocketOptions) { - const wsRef = useRef(null); - const reconnectTimeoutRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const maxReconnectAttempts = 5; - const reconnectDelay = 3000; - - const connect = useCallback(() => { - if (!enabled || wsRef.current?.readyState === WebSocket.OPEN) { - return; - } - - try { - let wsUrl = getWebSocketUrl(); - - // Добавляем токен - const token = localStorage.getItem('access_token'); - if (token) { - wsUrl += `${wsUrl.includes('?') ? '&' : '?'}token=${token}`; - } - - console.log('[NotificationWS] Connecting to:', wsUrl); - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - console.log('[NotificationWS] Connected'); - reconnectAttemptsRef.current = 0; - }; - - ws.onmessage = (event) => { - try { - const data: NotificationMessage = JSON.parse(event.data); - - // Игнорируем ping и другие события - if (data.type === 'ping' || - data.type === 'list_update' || - data.type === 'new_message' || - data.type === 'marked_read') { - return; - } - - console.log('[NotificationWS] Message:', data); - - // Обрабатываем уведомления - if (data.type === 'notification' && data.notification) { - onNotification(data.notification); - } - - // Обрабатываем счетчик - if (data.type === 'unread_count' && typeof data.count === 'number') { - onUnreadCountUpdate(data.count); - } - } catch (error) { - console.error('[NotificationWS] Parse error:', error); - } - }; - - ws.onerror = (error) => { - console.error('[NotificationWS] Error:', error); - }; - - ws.onclose = () => { - console.log('[NotificationWS] Disconnected'); - wsRef.current = null; - - // Автоматический reconnect - if (enabled && reconnectAttemptsRef.current < maxReconnectAttempts) { - reconnectAttemptsRef.current++; - console.log(`[NotificationWS] Reconnecting... (${reconnectAttemptsRef.current}/${maxReconnectAttempts})`); - reconnectTimeoutRef.current = setTimeout(connect, reconnectDelay); - } - }; - } catch (error) { - console.error('[NotificationWS] Connection failed:', error); - } - }, [enabled, onNotification, onUnreadCountUpdate]); - - useEffect(() => { - if (enabled) { - connect(); - } - - return () => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [enabled, connect]); - - return { - isConnected: wsRef.current?.readyState === WebSocket.OPEN, - reconnect: connect, - }; -} diff --git a/frontend/src/lib/websocketManager.ts b/frontend/src/lib/websocketManager.ts new file mode 100644 index 00000000..fdbdfcb0 --- /dev/null +++ b/frontend/src/lib/websocketManager.ts @@ -0,0 +1,151 @@ +/** + * Singleton WebSocket Manager + * Управляет единственным WebSocket соединением для всего приложения + */ + +type MessageHandler = (data: any) => void; + +class WebSocketManager { + private static instance: WebSocketManager | null = null; + private ws: WebSocket | null = null; + private reconnectTimeout: NodeJS.Timeout | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private messageHandlers: Set = new Set(); + private isConnecting = false; + + private constructor() {} + + static getInstance(): WebSocketManager { + if (!WebSocketManager.instance) { + WebSocketManager.instance = new WebSocketManager(); + } + return WebSocketManager.instance; + } + + connect() { + // Если уже подключены или подключаемся, не создаем новое соединение + if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { + console.log('[WS Manager] Already connected or connecting'); + return; + } + + // Проверяем что мы в браузере и пользователь авторизован + if (typeof window === 'undefined' || !localStorage.getItem('access_token')) { + console.log('[WS Manager] Not in browser or not authenticated'); + return; + } + + this.isConnecting = true; + + try { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:9000'; + const protocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:'; + const host = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); + let wsUrl = `${protocol}//${host}/ws/`; + + const token = localStorage.getItem('access_token'); + if (token) { + wsUrl += `?token=${token}`; + } + + console.log('[WS Manager] Backend URL:', backendUrl); + console.log('[WS Manager] Protocol:', protocol); + console.log('[WS Manager] Host:', host); + console.log('[WS Manager] Full WS URL:', wsUrl); + console.log('[WS Manager] Has token:', !!token); + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('[WS Manager] Connected'); + this.isConnecting = false; + this.reconnectAttempts = 0; + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Отправляем сообщение всем подписчикам + this.messageHandlers.forEach(handler => { + try { + handler(data); + } catch (err) { + console.error('[WS Manager] Handler error:', err); + } + }); + } catch (error) { + console.error('[WS Manager] Parse error:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('[WS Manager] Error event:', error); + console.error('[WS Manager] WebSocket state:', this.ws?.readyState); + console.error('[WS Manager] WebSocket URL was:', this.ws?.url); + this.isConnecting = false; + }; + + this.ws.onclose = (event) => { + console.log('[WS Manager] Disconnected'); + console.log('[WS Manager] Close event code:', event.code); + console.log('[WS Manager] Close event reason:', event.reason); + console.log('[WS Manager] Close event wasClean:', event.wasClean); + this.isConnecting = false; + this.ws = null; + + // Автоматический reconnect + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); + console.log(`[WS Manager] Reconnecting in ${delay}ms... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + this.reconnectTimeout = setTimeout(() => this.connect(), delay); + } + }; + } catch (error) { + console.error('[WS Manager] Connection failed:', error); + this.isConnecting = false; + } + } + + disconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.isConnecting = false; + } + + subscribe(handler: MessageHandler) { + this.messageHandlers.add(handler); + + // Если есть подписчик и нет соединения, подключаемся + if (this.messageHandlers.size > 0 && !this.ws && !this.isConnecting) { + this.connect(); + } + + // Возвращаем функцию отписки + return () => { + this.messageHandlers.delete(handler); + + // Если больше нет подписчиков, отключаемся + if (this.messageHandlers.size === 0) { + console.log('[WS Manager] No more subscribers, disconnecting'); + this.disconnect(); + } + }; + } + + getState(): number { + return this.ws?.readyState ?? WebSocket.CLOSED; + } +} + +export default WebSocketManager.getInstance();