Corp/backend/static/js/notifications/push-notifications.js
Игорь Надеин 73b082aae5 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
2026-01-05 14:38:06 +03:00

360 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Web Push Notifications Module
*
* Модуль для управления подпиской на Web Push уведомления.
* Регистрирует Service Worker и управляет push-подписками.
*
* @module notifications/push-notifications
*/
/**
* Класс для управления Web Push подписками
*/
class PushNotificationsManager {
/**
* Создает экземпляр менеджера push-уведомлений
*/
constructor() {
this.swRegistration = null;
this.vapidPublicKey = null;
this.isSubscribed = false;
this.isSupported = this._checkSupport();
}
/**
* Проверяет поддержку Push API браузером
* @returns {boolean} true если поддерживается
*/
_checkSupport() {
if (!('serviceWorker' in navigator)) {
console.warn('[PushNotifications] Service Worker не поддерживается');
return false;
}
if (!('PushManager' in window)) {
console.warn('[PushNotifications] Push API не поддерживается');
return false;
}
if (!('Notification' in window)) {
console.warn('[PushNotifications] Notification API не поддерживается');
return false;
}
return true;
}
/**
* Инициализирует менеджер push-уведомлений
* @returns {Promise<boolean>} Успешность инициализации
*/
async init() {
if (!this.isSupported) {
return false;
}
try {
// Получаем VAPID ключ с сервера
await this._fetchVapidKey();
// Регистрируем Service Worker
await this._registerServiceWorker();
// Проверяем текущий статус подписки
await this._checkSubscription();
console.log('[PushNotifications] Инициализация завершена, подписка:', this.isSubscribed);
return true;
} catch (error) {
console.error('[PushNotifications] Ошибка инициализации:', error);
return false;
}
}
/**
* Получает VAPID ключ с сервера
* @private
*/
async _fetchVapidKey() {
const response = await fetch('/api/v1/notifications/push/vapid-key/', {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Не удалось получить VAPID ключ');
}
const data = await response.json();
this.vapidPublicKey = data.vapid_public_key;
console.log('[PushNotifications] VAPID ключ получен');
}
/**
* Регистрирует Service Worker
* @private
*/
async _registerServiceWorker() {
// Service Worker находится в корне для правильного scope
this.swRegistration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('[PushNotifications] Service Worker зарегистрирован:', this.swRegistration.scope);
// Ждем активации
if (this.swRegistration.installing) {
await new Promise((resolve) => {
this.swRegistration.installing.addEventListener('statechange', (e) => {
if (e.target.state === 'activated') {
resolve();
}
});
});
}
}
/**
* Проверяет текущий статус подписки
* @private
*/
async _checkSubscription() {
const subscription = await this.swRegistration.pushManager.getSubscription();
this.isSubscribed = subscription !== null;
if (this.isSubscribed) {
console.log('[PushNotifications] Активная подписка найдена');
}
}
/**
* Конвертирует base64 строку в Uint8Array для applicationServerKey
* @param {string} base64String - Base64 encoded VAPID key
* @returns {Uint8Array}
* @private
*/
_urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Запрашивает разрешение на уведомления
* @returns {Promise<string>} Статус разрешения: 'granted', 'denied', 'default'
*/
async requestPermission() {
if (!this.isSupported) {
return 'unsupported';
}
const permission = await Notification.requestPermission();
console.log('[PushNotifications] Разрешение:', permission);
return permission;
}
/**
* Подписывается на push-уведомления
* @returns {Promise<boolean>} Успешность подписки
*/
async subscribe() {
if (!this.isSupported || !this.swRegistration || !this.vapidPublicKey) {
console.error('[PushNotifications] Менеджер не инициализирован');
return false;
}
// Проверяем разрешение
if (Notification.permission === 'denied') {
console.warn('[PushNotifications] Уведомления заблокированы пользователем');
return false;
}
if (Notification.permission === 'default') {
const permission = await this.requestPermission();
if (permission !== 'granted') {
return false;
}
}
try {
// Создаем push-подписку
const subscription = await this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this._urlBase64ToUint8Array(this.vapidPublicKey)
});
console.log('[PushNotifications] Push-подписка создана:', subscription.endpoint);
// Отправляем подписку на сервер
await this._sendSubscriptionToServer(subscription);
this.isSubscribed = true;
return true;
} catch (error) {
console.error('[PushNotifications] Ошибка подписки:', error);
return false;
}
}
/**
* Отправляет подписку на сервер
* @param {PushSubscription} subscription
* @private
*/
async _sendSubscriptionToServer(subscription) {
const subscriptionJson = subscription.toJSON();
// Получаем CSRF токен
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.match(/csrftoken=([^;]+)/)?.[1];
const response = await fetch('/api/v1/notifications/push/subscribe/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify({
endpoint: subscriptionJson.endpoint,
keys: subscriptionJson.keys,
user_agent: navigator.userAgent,
device_name: this._getDeviceName()
})
});
if (!response.ok) {
throw new Error('Не удалось сохранить подписку на сервере');
}
const data = await response.json();
console.log('[PushNotifications] Подписка сохранена на сервере:', data);
}
/**
* Определяет название устройства
* @returns {string}
* @private
*/
_getDeviceName() {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) {
return 'iOS Device';
}
if (/Android/.test(ua)) {
return 'Android Device';
}
if (/Windows/.test(ua)) {
if (/Edge/.test(ua)) return 'Windows (Edge)';
if (/Chrome/.test(ua)) return 'Windows (Chrome)';
if (/Firefox/.test(ua)) return 'Windows (Firefox)';
return 'Windows';
}
if (/Mac/.test(ua)) {
if (/Chrome/.test(ua)) return 'macOS (Chrome)';
if (/Safari/.test(ua)) return 'macOS (Safari)';
if (/Firefox/.test(ua)) return 'macOS (Firefox)';
return 'macOS';
}
if (/Linux/.test(ua)) {
return 'Linux';
}
return 'Unknown Device';
}
/**
* Отписывается от push-уведомлений
* @returns {Promise<boolean>} Успешность отписки
*/
async unsubscribe() {
if (!this.swRegistration) {
return true;
}
try {
const subscription = await this.swRegistration.pushManager.getSubscription();
if (!subscription) {
this.isSubscribed = false;
return true;
}
// Отписываемся в браузере
await subscription.unsubscribe();
// Удаляем подписку на сервере
await this._removeSubscriptionFromServer(subscription.endpoint);
this.isSubscribed = false;
console.log('[PushNotifications] Отписка выполнена');
return true;
} catch (error) {
console.error('[PushNotifications] Ошибка отписки:', error);
return false;
}
}
/**
* Удаляет подписку с сервера
* @param {string} endpoint
* @private
*/
async _removeSubscriptionFromServer(endpoint) {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value
|| document.cookie.match(/csrftoken=([^;]+)/)?.[1];
const response = await fetch('/api/v1/notifications/push/unsubscribe/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'include',
body: JSON.stringify({ endpoint })
});
if (!response.ok) {
console.warn('[PushNotifications] Не удалось удалить подписку с сервера');
}
}
/**
* Возвращает текущий статус разрешения
* @returns {string} 'granted', 'denied', 'default', или 'unsupported'
*/
getPermissionStatus() {
if (!this.isSupported) {
return 'unsupported';
}
return Notification.permission;
}
/**
* Проверяет, подписан ли пользователь
* @returns {boolean}
*/
getSubscriptionStatus() {
return this.isSubscribed;
}
}
// Создаем глобальный экземпляр
const pushNotifications = new PushNotificationsManager();
// Экспортируем для использования в других модулях
export { PushNotificationsManager, pushNotifications };
// Также делаем доступным глобально для совместимости
window.pushNotifications = pushNotifications;