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