fix: Service Worker registration and API routing issues

- Move Service Worker to root (/sw.js) for proper scope registration
- Add route to serve sw.js from root with correct content-type
- Update push-notifications.js to register sw.js from root
- Fix all notification API URLs to use /api/v1/ prefix
- Update notificationsApi.js to use correct v1 endpoints
This commit is contained in:
Игорь Надеин 2026-01-05 14:38:06 +03:00
parent 5f75d9df50
commit bb95d744fb
4 changed files with 201 additions and 5 deletions

View File

@ -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)

View File

@ -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<void>}
*/
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<void>}
*/
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()

View File

@ -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: '/'
});

192
backend/sw.js Normal file
View File

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