diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 4322258d..6425103d 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -2,6 +2,8 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path +from django.views.static import serve +import os urlpatterns = [ path("admin/", admin.site.urls), @@ -14,5 +16,7 @@ urlpatterns = [ path("search/", include("search.urls", namespace="search")), path("finance/", include("finance.urls", namespace="finance")), path("api/", include(("api.urls", "api"), namespace="api")), + # Service Worker должен быть в корне для правильного scope + path("sw.js", serve, {"document_root": settings.BASE_DIR, "path": "sw.js", "content_type": "application/javascript"}, name="sw"), path("", include("feed.urls_front", namespace="feed")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/static/js/api/notificationsApi.js b/backend/static/js/api/notificationsApi.js index 4c12ff17..0f1cc8f6 100644 --- a/backend/static/js/api/notificationsApi.js +++ b/backend/static/js/api/notificationsApi.js @@ -63,7 +63,7 @@ export async function getNotifications(params = {}, ttl = 10000) { return dataManager.fetch( key, async () => { - const url = new URL('/api/notifications/', window.location.origin); + const url = new URL('/api/v1/notifications/', window.location.origin); Object.keys(defaultParams).forEach(k => { if (defaultParams[k] != null) { url.searchParams.set(k, String(defaultParams[k])); @@ -113,7 +113,7 @@ export async function getUnreadCount(ttl = 5000) { * @returns {Promise} */ export async function markAsRead(notificationId) { - const url = `/api/notifications/${notificationId}/mark-read/`; + const url = `/api/v1/notifications/${notificationId}/read/`; const response = await fetch(url, { method: 'POST', headers: { @@ -137,7 +137,7 @@ export async function markAsRead(notificationId) { * @returns {Promise} */ export async function markAllAsRead() { - const url = '/api/notifications/mark-all-read/'; + const url = '/api/v1/notifications/read-all/'; const response = await fetch(url, { method: 'POST', headers: authHeaders() diff --git a/backend/static/js/notifications/push-notifications.js b/backend/static/js/notifications/push-notifications.js index fd44a78a..f8e36aa8 100644 --- a/backend/static/js/notifications/push-notifications.js +++ b/backend/static/js/notifications/push-notifications.js @@ -92,8 +92,8 @@ class PushNotificationsManager { * @private */ async _registerServiceWorker() { - // Service Worker должен быть в корне для максимального scope - this.swRegistration = await navigator.serviceWorker.register('/static/sw.js', { + // Service Worker находится в корне для правильного scope + this.swRegistration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); diff --git a/backend/sw.js b/backend/sw.js new file mode 100644 index 00000000..154c122a --- /dev/null +++ b/backend/sw.js @@ -0,0 +1,192 @@ +/** + * Service Worker для обработки Web Push уведомлений + * + * Этот файл должен находиться в корне static/ для правильного scope. + * Service Worker перехватывает push-события от браузера и показывает + * нативные уведомления даже когда страница закрыта. + */ + +// Версия Service Worker (для обновления кэша при изменениях) +const SW_VERSION = '1.0.0'; + +// Настройки по умолчанию для уведомлений +const DEFAULT_NOTIFICATION_OPTIONS = { + icon: '/static/img/logo-192.png', + badge: '/static/img/badge-72.png', + vibrate: [100, 50, 100], + requireInteraction: false, + renotify: true, +}; + +/** + * Событие установки Service Worker + */ +self.addEventListener('install', (event) => { + console.log('[SW] Service Worker installing, version:', SW_VERSION); + // Немедленно активировать новую версию + self.skipWaiting(); +}); + +/** + * Событие активации Service Worker + */ +self.addEventListener('activate', (event) => { + console.log('[SW] Service Worker activated, version:', SW_VERSION); + // Захватить все клиенты без перезагрузки + event.waitUntil(self.clients.claim()); +}); + +/** + * Обработка входящих push-уведомлений + * + * Формат данных с сервера: + * { + * "title": "Заголовок уведомления", + * "body": "Текст уведомления", + * "icon": "/static/img/...", // опционально + * "badge": "/static/img/...", // опционально + * "tag": "notification-id", // для группировки + * "data": { + * "url": "/path/to/page", // куда перейти при клике + * "notification_id": 123, + * "category": "requests", + * ... + * } + * } + */ +self.addEventListener('push', (event) => { + console.log('[SW] Push received:', event); + + let notificationData = { + title: 'Новое уведомление', + body: '', + ...DEFAULT_NOTIFICATION_OPTIONS, + data: {} + }; + + if (event.data) { + try { + const payload = event.data.json(); + console.log('[SW] Push payload:', payload); + + notificationData = { + ...notificationData, + title: payload.title || notificationData.title, + body: payload.body || payload.message || '', + icon: payload.icon || notificationData.icon, + badge: payload.badge || notificationData.badge, + tag: payload.tag || `notification-${Date.now()}`, + data: payload.data || {} + }; + + // Дополнительные опции + if (payload.image) { + notificationData.image = payload.image; + } + if (payload.actions) { + notificationData.actions = payload.actions; + } + if (payload.requireInteraction !== undefined) { + notificationData.requireInteraction = payload.requireInteraction; + } + + } catch (e) { + // Если данные не JSON, используем как текст + console.log('[SW] Push data is not JSON, using as text'); + notificationData.body = event.data.text(); + } + } + + // Показываем уведомление + const promiseChain = self.registration.showNotification( + notificationData.title, + { + body: notificationData.body, + icon: notificationData.icon, + badge: notificationData.badge, + tag: notificationData.tag, + data: notificationData.data, + vibrate: notificationData.vibrate, + requireInteraction: notificationData.requireInteraction, + renotify: notificationData.renotify, + image: notificationData.image, + actions: notificationData.actions, + } + ); + + event.waitUntil(promiseChain); +}); + +/** + * Обработка клика по уведомлению + */ +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification click:', event); + + event.notification.close(); + + // Получаем URL для перехода + const notificationData = event.notification.data || {}; + let urlToOpen = notificationData.url || '/'; + + // Обработка действий (action buttons) + if (event.action) { + console.log('[SW] Action clicked:', event.action); + // Можно добавить обработку разных действий + if (notificationData.actions && notificationData.actions[event.action]) { + urlToOpen = notificationData.actions[event.action].url || urlToOpen; + } + } + + // Открываем или фокусируем окно + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Ищем уже открытое окно с нашим сайтом + for (const client of clientList) { + const clientUrl = new URL(client.url); + const targetUrl = new URL(urlToOpen, self.location.origin); + + // Если найдено окно с тем же origin - фокусируем и переходим + if (clientUrl.origin === targetUrl.origin && 'focus' in client) { + return client.navigate(urlToOpen).then(() => client.focus()); + } + } + + // Если нет открытого окна - открываем новое + if (self.clients.openWindow) { + return self.clients.openWindow(urlToOpen); + } + }) + ); +}); + +/** + * Обработка закрытия уведомления без клика + */ +self.addEventListener('notificationclose', (event) => { + console.log('[SW] Notification closed:', event.notification.tag); + + // Можно отправить на сервер информацию о закрытии + // Например, для аналитики + const notificationData = event.notification.data || {}; + if (notificationData.notification_id) { + // fetch('/api/v1/notifications/' + notificationData.notification_id + '/dismissed/', { + // method: 'POST', + // credentials: 'include' + // }).catch(() => {}); + } +}); + +/** + * Получение сообщений от основной страницы + */ +self.addEventListener('message', (event) => { + console.log('[SW] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +console.log('[SW] Service Worker loaded, version:', SW_VERSION);