diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py
index bea16177..b78dd5a5 100644
--- a/backend/notifications/admin.py
+++ b/backend/notifications/admin.py
@@ -5,7 +5,12 @@ Admin для упрощенной системы уведомлений
from django.contrib import admin
from django.utils.html import format_html
from django.utils.safestring import mark_safe
-from .models import Notification, UserChannelPreferences
+from .models import (
+ Notification,
+ NotificationDeliveryRouting,
+ NotificationInstallation,
+ UserChannelPreferences,
+)
@admin.register(Notification)
@@ -286,3 +291,25 @@ class UserChannelPreferencesAdmin(admin.ModelAdmin):
)
get_disabled_verbs_count.short_description = "Отключено типов"
+
+
+@admin.register(NotificationInstallation)
+class NotificationInstallationAdmin(admin.ModelAdmin):
+ list_display = (
+ "user",
+ "app_kind",
+ "device_name",
+ "installed",
+ "active",
+ "last_seen_at",
+ )
+ list_filter = ("app_kind", "installed", "active")
+ search_fields = ("user__email", "device_name", "endpoint")
+ readonly_fields = ("created_at", "updated_at", "last_seen_at")
+
+
+@admin.register(NotificationDeliveryRouting)
+class NotificationDeliveryRoutingAdmin(admin.ModelAdmin):
+ list_display = ("user", "device_group_id", "updated_at")
+ search_fields = ("user__email", "device_group_id")
+ readonly_fields = ("created_at", "updated_at")
diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py
index 404f5259..9c2cda11 100644
--- a/backend/notifications/api/serializers.py
+++ b/backend/notifications/api/serializers.py
@@ -147,7 +147,38 @@ class SubscribePushRequestSerializer(serializers.Serializer):
endpoint = serializers.CharField()
keys = PushKeysSerializer()
device_name = serializers.CharField(required=False)
+ installation_id = serializers.UUIDField(required=False)
+ device_group_id = serializers.UUIDField(required=False)
+ app_kind = serializers.ChoiceField(
+ choices=("main", "messenger"),
+ required=False,
+ default="main",
+ )
+ installed = serializers.BooleanField(required=False, default=False)
class UnsubscribePushRequestSerializer(serializers.Serializer):
endpoint = serializers.CharField(required=False)
+
+
+class InstallationHeartbeatRequestSerializer(serializers.Serializer):
+ installation_id = serializers.UUIDField()
+ device_group_id = serializers.UUIDField()
+ app_kind = serializers.ChoiceField(choices=("main", "messenger"))
+ device_name = serializers.CharField(required=False, allow_blank=True)
+ installed = serializers.BooleanField(required=False, default=True)
+
+
+class DeliveryRoutingRequestSerializer(serializers.Serializer):
+ device_group_id = serializers.UUIDField()
+ routes = serializers.DictField(
+ child=serializers.ListField(
+ child=serializers.ChoiceField(choices=("main", "messenger")),
+ allow_empty=False,
+ ),
+ )
+
+
+class DisconnectInstallationRequestSerializer(serializers.Serializer):
+ device_group_id = serializers.UUIDField()
+ app_kind = serializers.ChoiceField(choices=("main", "messenger"))
diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py
index db7aeaab..60e1c2a6 100644
--- a/backend/notifications/api/urls.py
+++ b/backend/notifications/api/urls.py
@@ -35,4 +35,19 @@ urlpatterns = [
path("push/vapid-key/", views.get_vapid_public_key, name="vapid_key"),
path("push/subscribe/", views.subscribe_push, name="push_subscribe"),
path("push/unsubscribe/", views.unsubscribe_push, name="push_unsubscribe"),
+ path(
+ "push/installations/heartbeat/",
+ views.installation_heartbeat,
+ name="push_installation_heartbeat",
+ ),
+ path(
+ "push/delivery-routing/",
+ views.delivery_routing,
+ name="push_delivery_routing",
+ ),
+ path(
+ "push/installations/disconnect/",
+ views.disconnect_installation,
+ name="push_installation_disconnect",
+ ),
]
diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py
index 4990779d..6c566875 100644
--- a/backend/notifications/api/views.py
+++ b/backend/notifications/api/views.py
@@ -8,15 +8,23 @@ API endpoints для системы уведомлений v2.0
- UserChannelPreferences для настроек каналов
"""
from datetime import datetime
+import uuid
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Count, Q
+from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
-from ..models import Notification, UserChannelPreferences
+from ..models import (
+ Notification,
+ NotificationDeliveryRouting,
+ NotificationInstallation,
+ UserChannelPreferences,
+)
+from ..routing import NOTIFICATION_CATEGORIES, normalize_routes
from ..cache import (
get_unread_count as get_cached_unread_count,
get_unread_summary,
@@ -29,6 +37,9 @@ from ..realtime import (
from .serializers import (
ChannelPreferencesSerializer,
CountResponseSerializer,
+ DeliveryRoutingRequestSerializer,
+ DisconnectInstallationRequestSerializer,
+ InstallationHeartbeatRequestSerializer,
MarkAllAsReadRequestSerializer,
MarkCategoryAsReadRequestSerializer,
NotificationsListResponseSerializer,
@@ -593,21 +604,22 @@ def subscribe_push(request):
"""
from push_notifications.models import WebPushDevice
- endpoint = request.data.get('endpoint')
- keys = request.data.get('keys', {})
- device_name = request.data.get('device_name', 'unknown')
-
- if not endpoint or not keys:
- return Response(
- {'status': 'error', 'message': 'endpoint and keys required'},
- status=status.HTTP_400_BAD_REQUEST
- )
+ serializer = SubscribePushRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ payload = serializer.validated_data
+ endpoint = payload['endpoint']
+ keys = payload['keys']
+ device_name = payload.get('device_name', 'unknown')
+ app_kind = payload.get('app_kind', NotificationInstallation.APP_MAIN)
+ generated_id = uuid.uuid5(uuid.NAMESPACE_URL, endpoint)
+ installation_id = payload.get('installation_id', generated_id)
+ device_group_id = payload.get('device_group_id', generated_id)
# Регистрируем устройство
device, created = WebPushDevice.objects.update_or_create(
- user=request.user,
registration_id=endpoint,
defaults={
+ 'user': request.user,
'p256dh': keys.get('p256dh', ''),
'auth': keys.get('auth', ''),
'browser': device_name,
@@ -615,6 +627,39 @@ def subscribe_push(request):
}
)
+ NotificationInstallation.objects.filter(endpoint=endpoint).exclude(
+ user=request.user,
+ ).update(endpoint=None, push_device_id=None, active=False)
+
+ installation = NotificationInstallation.objects.filter(
+ user=request.user,
+ endpoint=endpoint,
+ ).first() or NotificationInstallation.objects.filter(
+ user=request.user,
+ device_group_id=device_group_id,
+ app_kind=app_kind,
+ ).first()
+ if installation is None:
+ installation = NotificationInstallation(
+ user=request.user,
+ device_group_id=device_group_id,
+ app_kind=app_kind,
+ )
+ elif installation.endpoint and installation.endpoint != endpoint:
+ WebPushDevice.objects.filter(
+ user=request.user,
+ registration_id=installation.endpoint,
+ ).exclude(pk=device.pk).delete()
+
+ installation.installation_id = installation_id
+ installation.endpoint = endpoint
+ installation.push_device_id = device.id
+ installation.device_name = device_name
+ installation.installed = payload.get('installed', installation.installed)
+ installation.active = True
+ installation.last_seen_at = timezone.now()
+ installation.save()
+
prefs, _ = UserChannelPreferences.objects.get_or_create(
user=request.user,
)
@@ -630,7 +675,192 @@ def subscribe_push(request):
else 'Push subscription updated'
),
'created': created,
- 'device_id': device.id
+ 'device_id': device.id,
+ 'installation_id': str(installation.installation_id),
+ 'app_kind': installation.app_kind,
+ })
+
+
+@extend_schema(
+ tags=["Notifications"],
+ summary="Подтвердить активность установленного приложения",
+ request=InstallationHeartbeatRequestSerializer,
+ responses={200: StatusResponseSerializer},
+)
+@api_view(['POST'])
+@permission_classes([IsAuthenticated])
+def installation_heartbeat(request):
+ serializer = InstallationHeartbeatRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ payload = serializer.validated_data
+
+ installation, created = NotificationInstallation.objects.update_or_create(
+ user=request.user,
+ device_group_id=payload['device_group_id'],
+ app_kind=payload['app_kind'],
+ defaults={
+ 'installation_id': payload['installation_id'],
+ 'device_name': payload.get('device_name', ''),
+ 'installed': payload.get('installed', True),
+ 'active': True,
+ 'last_seen_at': timezone.now(),
+ },
+ )
+ return Response({
+ 'status': 'success',
+ 'created': created,
+ 'installation': _serialize_installation(installation),
+ })
+
+
+def _serialize_installation(installation):
+ return {
+ 'installation_id': str(installation.installation_id),
+ 'device_group_id': str(installation.device_group_id),
+ 'app_kind': installation.app_kind,
+ 'device_name': installation.device_name,
+ 'installed': installation.installed,
+ 'active': installation.active,
+ 'push_ready': installation.push_ready,
+ 'last_seen_at': installation.last_seen_at.isoformat(),
+ }
+
+
+@extend_schema(
+ methods=['GET'],
+ tags=["Notifications"],
+ summary="Получить маршруты доставки по приложениям",
+)
+@extend_schema(
+ methods=['PUT'],
+ tags=["Notifications"],
+ summary="Обновить маршруты доставки по приложениям",
+ request=DeliveryRoutingRequestSerializer,
+)
+@api_view(['GET', 'PUT'])
+@permission_classes([IsAuthenticated])
+def delivery_routing(request):
+ if request.method == 'GET':
+ raw_device_group_id = request.query_params.get('device_group_id')
+ try:
+ device_group_id = uuid.UUID(str(raw_device_group_id))
+ except (TypeError, ValueError, AttributeError):
+ return Response(
+ {'detail': 'device_group_id is required'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ else:
+ serializer = DeliveryRoutingRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ device_group_id = serializer.validated_data['device_group_id']
+
+ routing, _created = NotificationDeliveryRouting.objects.get_or_create(
+ user=request.user,
+ device_group_id=device_group_id,
+ )
+
+ if request.method == 'PUT':
+ routes = normalize_routes(serializer.validated_data['routes'])
+ messenger_requested = any(
+ NotificationInstallation.APP_MESSENGER in destinations
+ for destinations in routes.values()
+ )
+ if messenger_requested and not NotificationInstallation.objects.filter(
+ user=request.user,
+ device_group_id=device_group_id,
+ app_kind=NotificationInstallation.APP_MESSENGER,
+ active=True,
+ installed=True,
+ endpoint__isnull=False,
+ ).exists():
+ return Response(
+ {
+ 'detail': (
+ 'Сначала завершите установку мессенджера и '
+ 'разрешите ему уведомления.'
+ )
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ routing.routes = routes
+ routing.save(update_fields=['routes', 'updated_at'])
+
+ installations = NotificationInstallation.objects.filter(
+ user=request.user,
+ device_group_id=device_group_id,
+ active=True,
+ )
+ return Response({
+ 'device_group_id': str(device_group_id),
+ 'categories': [
+ {'key': key, 'label': label}
+ for key, label in NOTIFICATION_CATEGORIES
+ ],
+ 'routes': normalize_routes(routing.routes),
+ 'installations': [
+ _serialize_installation(installation)
+ for installation in installations
+ ],
+ })
+
+
+@extend_schema(
+ tags=["Notifications"],
+ summary="Отключить приложение доставки на устройстве",
+ request=DisconnectInstallationRequestSerializer,
+ responses={200: StatusResponseSerializer},
+)
+@api_view(['DELETE'])
+@permission_classes([IsAuthenticated])
+def disconnect_installation(request):
+ from push_notifications.models import WebPushDevice
+
+ serializer = DisconnectInstallationRequestSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ payload = serializer.validated_data
+ installations = NotificationInstallation.objects.filter(
+ user=request.user,
+ device_group_id=payload['device_group_id'],
+ app_kind=payload['app_kind'],
+ )
+ endpoints = list(
+ installations.exclude(endpoint__isnull=True).values_list(
+ 'endpoint',
+ flat=True,
+ )
+ )
+ WebPushDevice.objects.filter(
+ user=request.user,
+ registration_id__in=endpoints,
+ ).delete()
+ installations.update(
+ endpoint=None,
+ push_device_id=None,
+ installed=False,
+ active=False,
+ last_seen_at=timezone.now(),
+ )
+
+ routing, _created = NotificationDeliveryRouting.objects.get_or_create(
+ user=request.user,
+ device_group_id=payload['device_group_id'],
+ )
+ routes = normalize_routes(routing.routes)
+ if payload['app_kind'] == NotificationInstallation.APP_MESSENGER:
+ routes = {
+ category: (
+ [app for app in destinations if app != 'messenger']
+ or ['main']
+ )
+ for category, destinations in routes.items()
+ }
+ routing.routes = routes
+ routing.save(update_fields=['routes', 'updated_at'])
+
+ return Response({
+ 'status': 'success',
+ 'routes': routes,
+ 'disconnected': installations.count(),
})
@@ -650,6 +880,14 @@ def unsubscribe_push(request):
endpoint = request.data.get('endpoint')
if endpoint:
+ NotificationInstallation.objects.filter(
+ user=request.user,
+ endpoint=endpoint,
+ ).update(
+ endpoint=None,
+ push_device_id=None,
+ last_seen_at=timezone.now(),
+ )
deleted_count, _ = WebPushDevice.objects.filter(
user=request.user,
registration_id=endpoint
diff --git a/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py b/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py
new file mode 100644
index 00000000..6a27ab38
--- /dev/null
+++ b/backend/notifications/migrations/0013_notificationdeliveryrouting_notificationinstallation.py
@@ -0,0 +1,56 @@
+# Generated by Django 5.2.4 on 2026-08-05 11:44
+
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('notifications', '0012_split_regulation_notifications'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='NotificationDeliveryRouting',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('device_group_id', models.UUIDField(db_index=True)),
+ ('routes', models.JSONField(blank=True, default=dict)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_delivery_routings', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'db_table': 'notifications_delivery_routing',
+ 'constraints': [models.UniqueConstraint(fields=('user', 'device_group_id'), name='notifications_unique_routing_per_device_group')],
+ },
+ ),
+ migrations.CreateModel(
+ name='NotificationInstallation',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('installation_id', models.UUIDField(default=uuid.uuid4)),
+ ('device_group_id', models.UUIDField(db_index=True)),
+ ('app_kind', models.CharField(choices=[('main', 'Основное приложение'), ('messenger', 'Мессенджер')], max_length=20)),
+ ('endpoint', models.URLField(blank=True, max_length=1000, null=True, unique=True)),
+ ('push_device_id', models.PositiveBigIntegerField(blank=True, null=True, unique=True)),
+ ('device_name', models.CharField(blank=True, max_length=255)),
+ ('installed', models.BooleanField(default=False)),
+ ('active', models.BooleanField(default=True)),
+ ('last_seen_at', models.DateTimeField(default=django.utils.timezone.now)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_installations', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'db_table': 'notifications_installation',
+ 'ordering': ('-last_seen_at',),
+ 'constraints': [models.UniqueConstraint(fields=('user', 'installation_id'), name='notifications_unique_installation_per_user'), models.UniqueConstraint(fields=('user', 'device_group_id', 'app_kind'), name='notifications_unique_app_per_device_group')],
+ },
+ ),
+ ]
diff --git a/backend/notifications/models.py b/backend/notifications/models.py
index 99ab9ed1..2c2c50e1 100644
--- a/backend/notifications/models.py
+++ b/backend/notifications/models.py
@@ -16,6 +16,8 @@ TODO: Добавить тесты для моделей:
- UserChannelPreferences.is_in_dnd_period()
"""
+import uuid
+
from django.db import models
from django.conf import settings
from django.utils import timezone
@@ -398,5 +400,78 @@ class UserChannelPreferences(models.Model):
return now >= self.dnd_start_time or now <= self.dnd_end_time
+class NotificationInstallation(models.Model):
+ """An application installation paired with a Web Push subscription."""
+
+ APP_MAIN = "main"
+ APP_MESSENGER = "messenger"
+ APP_KIND_CHOICES = [
+ (APP_MAIN, "Основное приложение"),
+ (APP_MESSENGER, "Мессенджер"),
+ ]
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="notification_installations",
+ )
+ installation_id = models.UUIDField(default=uuid.uuid4)
+ device_group_id = models.UUIDField(db_index=True)
+ app_kind = models.CharField(max_length=20, choices=APP_KIND_CHOICES)
+ endpoint = models.URLField(max_length=1000, unique=True, null=True, blank=True)
+ push_device_id = models.PositiveBigIntegerField(
+ unique=True,
+ null=True,
+ blank=True,
+ )
+ device_name = models.CharField(max_length=255, blank=True)
+ installed = models.BooleanField(default=False)
+ active = models.BooleanField(default=True)
+ last_seen_at = models.DateTimeField(default=timezone.now)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ db_table = "notifications_installation"
+ ordering = ("-last_seen_at",)
+ constraints = [
+ models.UniqueConstraint(
+ fields=("user", "installation_id"),
+ name="notifications_unique_installation_per_user",
+ ),
+ models.UniqueConstraint(
+ fields=("user", "device_group_id", "app_kind"),
+ name="notifications_unique_app_per_device_group",
+ ),
+ ]
+
+ @property
+ def push_ready(self):
+ return self.active and bool(self.endpoint and self.push_device_id)
+
+
+class NotificationDeliveryRouting(models.Model):
+ """Per-device routing of notification categories between installed apps."""
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="notification_delivery_routings",
+ )
+ device_group_id = models.UUIDField(db_index=True)
+ routes = models.JSONField(default=dict, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ db_table = "notifications_delivery_routing"
+ constraints = [
+ models.UniqueConstraint(
+ fields=("user", "device_group_id"),
+ name="notifications_unique_routing_per_device_group",
+ ),
+ ]
+
+
# === Web Push устройства управляются через django-push-notifications ===
# Используйте: from push_notifications.models import WebPushDevice
diff --git a/backend/notifications/routing.py b/backend/notifications/routing.py
new file mode 100644
index 00000000..754a879a
--- /dev/null
+++ b/backend/notifications/routing.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from collections.abc import Iterable
+
+
+APP_MAIN = "main"
+APP_MESSENGER = "messenger"
+VALID_APPS = {APP_MAIN, APP_MESSENGER}
+
+NOTIFICATION_CATEGORIES = (
+ ("messages", "Сообщения"),
+ ("tasks", "Задачи и доски"),
+ ("requests", "Заявления"),
+ ("procurement", "Закупки"),
+ ("documents", "Документы и регламенты"),
+ ("guests", "Гости"),
+ ("calendar", "Календарь"),
+ ("feed", "Новости"),
+ ("other", "Остальные уведомления"),
+)
+CATEGORY_KEYS = tuple(key for key, _label in NOTIFICATION_CATEGORIES)
+
+MESSAGE_VERBS = {
+ "chat_added_to_chat",
+ "chat_new_message",
+ "chat_reply",
+ "chat_mention",
+ "announcement_new_message",
+}
+
+PREFIX_CATEGORIES = (
+ (("task_", "board_"), "tasks"),
+ (("request_",), "requests"),
+ (("procurement_",), "procurement"),
+ (("document_", "regulation_"), "documents"),
+ (("guest_",), "guests"),
+ (("event_", "calendar_"), "calendar"),
+ (("feed_",), "feed"),
+)
+
+
+def notification_category(verb: str) -> str:
+ if verb in MESSAGE_VERBS or verb.startswith("chat_"):
+ return "messages"
+ for prefixes, category in PREFIX_CATEGORIES:
+ if verb.startswith(prefixes):
+ return category
+ return "other"
+
+
+def default_routes() -> dict[str, list[str]]:
+ return {key: [APP_MAIN] for key in CATEGORY_KEYS}
+
+
+def normalize_destinations(destinations: object) -> list[str]:
+ if not isinstance(destinations, Iterable) or isinstance(
+ destinations,
+ (str, bytes),
+ ):
+ return [APP_MAIN]
+ normalized = []
+ for destination in destinations:
+ if destination in VALID_APPS and destination not in normalized:
+ normalized.append(destination)
+ return normalized or [APP_MAIN]
+
+
+def normalize_routes(routes: object) -> dict[str, list[str]]:
+ source = routes if isinstance(routes, dict) else {}
+ return {
+ key: normalize_destinations(source.get(key, [APP_MAIN]))
+ for key in CATEGORY_KEYS
+ }
diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py
index ca6666c6..e570259f 100644
--- a/backend/notifications/senders/push.py
+++ b/backend/notifications/senders/push.py
@@ -3,12 +3,16 @@ Web Push отправитель для browser push notifications
"""
import json
+from datetime import timedelta
from django.conf import settings
+from django.utils import timezone
from push_notifications.models import WebPushDevice
from .base import BaseNotificationSender
from ..config import get
+from ..models import NotificationDeliveryRouting, NotificationInstallation
+from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
class PushNotificationSender(BaseNotificationSender):
@@ -107,12 +111,89 @@ class PushNotificationSender(BaseNotificationSender):
return
device.active = False
device.save(update_fields=["active"])
+ NotificationInstallation.objects.filter(
+ push_device_id=device.id,
+ ).update(endpoint=None, push_device_id=None)
self.logger.info(
"Push device %s deactivated after permanent delivery error: %s",
device.id,
reason,
)
+ def _delivery_devices(self, notification):
+ user = notification.recipient
+ active_devices = list(
+ WebPushDevice.objects.filter(user=user, active=True)
+ )
+ if not active_devices:
+ return [], {}
+
+ devices_by_endpoint = {
+ device.registration_id: device for device in active_devices
+ }
+ installations = list(
+ NotificationInstallation.objects.filter(
+ user=user,
+ active=True,
+ endpoint__in=devices_by_endpoint,
+ )
+ )
+ represented_endpoints = {
+ installation.endpoint for installation in installations
+ }
+ selected = [
+ device
+ for device in active_devices
+ if device.registration_id not in represented_endpoints
+ ]
+ fallback_by_device_id = {}
+ category = notification_category(notification.verb)
+ routing_by_group = {
+ str(routing.device_group_id): normalize_routes(routing.routes)
+ for routing in NotificationDeliveryRouting.objects.filter(
+ user=user,
+ device_group_id__in={
+ installation.device_group_id
+ for installation in installations
+ },
+ )
+ }
+
+ grouped = {}
+ messenger_cutoff = timezone.now() - timedelta(
+ days=getattr(settings, "MESSENGER_PUSH_LEASE_DAYS", 30),
+ )
+ for installation in installations:
+ if (
+ installation.app_kind == APP_MESSENGER
+ and installation.last_seen_at < messenger_cutoff
+ ):
+ continue
+ grouped.setdefault(str(installation.device_group_id), {}).setdefault(
+ installation.app_kind,
+ [],
+ ).append(devices_by_endpoint[installation.endpoint])
+
+ for group_id, app_devices in grouped.items():
+ routes = routing_by_group.get(group_id, normalize_routes({}))
+ destinations = routes[category]
+ main_devices = app_devices.get(APP_MAIN, [])
+ messenger_devices = app_devices.get(APP_MESSENGER, [])
+
+ if APP_MAIN in destinations:
+ selected.extend(main_devices)
+ if APP_MESSENGER in destinations:
+ if messenger_devices:
+ selected.extend(messenger_devices)
+ if APP_MAIN not in destinations and main_devices:
+ for messenger_device in messenger_devices:
+ fallback_by_device_id[messenger_device.id] = main_devices
+ elif APP_MAIN not in destinations:
+ selected.extend(main_devices)
+
+ unique_selected = {device.id: device for device in selected}
+ return list(unique_selected.values()), fallback_by_device_id
+
def send(self, notification, **kwargs) -> bool:
"""
Отправляет Web Push уведомление.
@@ -125,12 +206,9 @@ class PushNotificationSender(BaseNotificationSender):
True если отправлено успешно, False иначе
"""
try:
- user = notification.recipient
+ devices, fallback_by_device_id = self._delivery_devices(notification)
- # Получаем активные устройства через django-push-notifications
- devices = WebPushDevice.objects.filter(user=user, active=True)
-
- if not devices.exists():
+ if not devices:
self.log_skip(notification, "no active push devices")
return False
@@ -185,15 +263,20 @@ class PushNotificationSender(BaseNotificationSender):
failed_count = 0
# Отправляем через все устройства пользователя
- for device in devices:
+ sent_device_ids = set()
+
+ def send_to_device(device):
+ nonlocal sent_count, failed_count
try:
device.send_message(message)
sent_count += 1
+ sent_device_ids.add(device.id)
self.logger.debug(
- f"Push отправлен на device {device.id} ({
- device.browser
- })"
+ "Push отправлен на device %s (%s)",
+ device.id,
+ device.browser,
)
+ return True
except Exception as e:
failed_count += 1
# Вычисляем размер payload для диагностики
@@ -205,6 +288,17 @@ class PushNotificationSender(BaseNotificationSender):
)
if self._is_permanent_device_error(e):
self._deactivate_device(device, error_text)
+ return False
+
+ for device in devices:
+ delivered = send_to_device(device)
+ if delivered:
+ continue
+ for fallback_device in fallback_by_device_id.get(device.id, []):
+ if fallback_device.id in sent_device_ids:
+ continue
+ if send_to_device(fallback_device):
+ break
if sent_count > 0:
self.log_success(notification, f"{sent_count} devices")
diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py
index d718e4f4..e6899257 100644
--- a/backend/tests/api/v1/notifications/test_notifications_api.py
+++ b/backend/tests/api/v1/notifications/test_notifications_api.py
@@ -5,8 +5,12 @@ from django.core.cache import cache
from push_notifications.models import WebPushDevice
from notifications.cache import unread_summary_cache_key
-from notifications.models import Notification
-from notifications.models import UserChannelPreferences
+from notifications.models import (
+ Notification,
+ NotificationDeliveryRouting,
+ NotificationInstallation,
+ UserChannelPreferences,
+)
pytestmark = pytest.mark.django_db
@@ -350,3 +354,132 @@ class TestNotificationPreferencesApi:
assert response.status_code == 200
prefs = UserChannelPreferences.objects.get(user=user)
assert prefs.push_enabled is False
+
+ def test_subscribe_push_registers_application_installation(
+ self, api_client, user_factory
+ ):
+ user = user_factory()
+ api_client.force_authenticate(user=user)
+
+ response = api_client.post(
+ "/api/v1/notifications/push/subscribe/",
+ {
+ "endpoint": "https://push.example.test/messenger",
+ "keys": {"p256dh": "test-p256dh", "auth": "test-auth"},
+ "device_name": "Safari iPhone",
+ "installation_id": "79307716-4220-48c4-9357-57294dfb24f3",
+ "device_group_id": "084127c8-ac7a-47c8-92c5-d7d41713b150",
+ "app_kind": "messenger",
+ "installed": True,
+ },
+ format="json",
+ )
+
+ assert response.status_code == 200
+ installation = NotificationInstallation.objects.get(user=user)
+ assert installation.app_kind == "messenger"
+ assert installation.installed is True
+ assert installation.push_ready is True
+
+ def test_delivery_routing_requires_ready_messenger(
+ self, api_client, user_factory
+ ):
+ user = user_factory()
+ api_client.force_authenticate(user=user)
+ device_group_id = "73d2d87f-2cd7-44ca-a658-d7fd19a85898"
+
+ response = api_client.put(
+ "/api/v1/notifications/push/delivery-routing/",
+ {
+ "device_group_id": device_group_id,
+ "routes": {"messages": ["messenger"]},
+ },
+ format="json",
+ )
+
+ assert response.status_code == 409
+
+ def test_delivery_routing_can_select_both_apps(
+ self, api_client, user_factory
+ ):
+ user = user_factory()
+ api_client.force_authenticate(user=user)
+ device_group_id = "beb3746c-0af9-4b60-a9c2-42747c08a87f"
+ device = WebPushDevice.objects.create(
+ user=user,
+ registration_id="https://push.example.test/messenger-ready",
+ p256dh="test-p256dh",
+ auth="test-auth",
+ browser="Safari",
+ active=True,
+ )
+ NotificationInstallation.objects.create(
+ user=user,
+ installation_id="bda0fdc5-1f0c-4d13-a9e2-e143eb9a44db",
+ device_group_id=device_group_id,
+ app_kind="messenger",
+ endpoint=device.registration_id,
+ push_device_id=device.id,
+ installed=True,
+ )
+
+ response = api_client.put(
+ "/api/v1/notifications/push/delivery-routing/",
+ {
+ "device_group_id": device_group_id,
+ "routes": {
+ "messages": ["main", "messenger"],
+ "tasks": ["main"],
+ },
+ },
+ format="json",
+ )
+
+ assert response.status_code == 200
+ routing = NotificationDeliveryRouting.objects.get(user=user)
+ assert routing.routes["messages"] == ["main", "messenger"]
+ assert response.data["routes"]["tasks"] == ["main"]
+ assert response.data["routes"]["requests"] == ["main"]
+
+ def test_disconnect_messenger_restores_main_routes(
+ self, api_client, user_factory
+ ):
+ user = user_factory()
+ api_client.force_authenticate(user=user)
+ device_group_id = "174b2b10-e7b9-465c-b0a1-05f0db81b256"
+ device = WebPushDevice.objects.create(
+ user=user,
+ registration_id="https://push.example.test/disconnect-messenger",
+ p256dh="test-p256dh",
+ auth="test-auth",
+ browser="Chrome",
+ active=True,
+ )
+ installation = NotificationInstallation.objects.create(
+ user=user,
+ installation_id="4971e5b3-adf8-4dbd-ae91-755d28bd5d44",
+ device_group_id=device_group_id,
+ app_kind="messenger",
+ endpoint=device.registration_id,
+ push_device_id=device.id,
+ installed=True,
+ )
+ NotificationDeliveryRouting.objects.create(
+ user=user,
+ device_group_id=device_group_id,
+ routes={"messages": ["messenger"], "tasks": ["main", "messenger"]},
+ )
+
+ response = api_client.delete(
+ "/api/v1/notifications/push/installations/disconnect/",
+ {"device_group_id": device_group_id, "app_kind": "messenger"},
+ format="json",
+ )
+
+ assert response.status_code == 200
+ assert response.data["routes"]["messages"] == ["main"]
+ assert response.data["routes"]["tasks"] == ["main"]
+ installation.refresh_from_db()
+ assert installation.installed is False
+ assert installation.endpoint is None
+ assert not WebPushDevice.objects.filter(pk=device.pk).exists()
diff --git a/backend/tests/api/v1/notifications/test_push_sender.py b/backend/tests/api/v1/notifications/test_push_sender.py
index 7769e43a..23cf9590 100644
--- a/backend/tests/api/v1/notifications/test_push_sender.py
+++ b/backend/tests/api/v1/notifications/test_push_sender.py
@@ -1,22 +1,29 @@
import json
+from datetime import timedelta
from unittest.mock import patch
import pytest
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
+from django.utils import timezone
from push_notifications.models import WebPushDevice
-from notifications.models import Notification, UserChannelPreferences
+from notifications.models import (
+ Notification,
+ NotificationDeliveryRouting,
+ NotificationInstallation,
+ UserChannelPreferences,
+)
from notifications.senders.push import PushNotificationSender
pytestmark = pytest.mark.django_db
-def _make_push_device(user):
+def _make_push_device(user, suffix=""):
return WebPushDevice.objects.create(
user=user,
- registration_id="https://push.example.test/endpoint",
+ registration_id=f"https://push.example.test/endpoint{suffix}",
p256dh="test-p256dh",
auth="test-auth",
browser="CHROME",
@@ -244,3 +251,199 @@ class TestPushNotificationSenderPayload:
assert result is False
device.refresh_from_db()
assert device.active is True
+
+ def test_message_routes_to_messenger_only(self, user_factory):
+ recipient = user_factory(email="routing-recipient@example.com")
+ main_device = _make_push_device(recipient, "-main")
+ messenger_device = _make_push_device(recipient, "-messenger")
+ device_group_id = "ad15799f-f493-4551-aa65-962486211bcc"
+ NotificationInstallation.objects.create(
+ user=recipient,
+ installation_id="5fe41a07-fdb0-4157-8170-ed6333a2b2ac",
+ device_group_id=device_group_id,
+ app_kind="main",
+ endpoint=main_device.registration_id,
+ push_device_id=main_device.id,
+ installed=True,
+ )
+ NotificationInstallation.objects.create(
+ user=recipient,
+ installation_id="26a8a013-9338-4a4f-ac14-db4092cb46e7",
+ device_group_id=device_group_id,
+ app_kind="messenger",
+ endpoint=messenger_device.registration_id,
+ push_device_id=messenger_device.id,
+ installed=True,
+ )
+ NotificationDeliveryRouting.objects.create(
+ user=recipient,
+ device_group_id=device_group_id,
+ routes={"messages": ["messenger"]},
+ )
+ notification = _make_notification(
+ recipient,
+ verb="chat_new_message",
+ description="Сообщение",
+ action_url="/messages/1",
+ )
+
+ sender = PushNotificationSender()
+ with patch.object(
+ WebPushDevice,
+ "send_message",
+ autospec=True,
+ ) as send_message:
+ assert sender.send(notification) is True
+
+ assert send_message.call_count == 1
+ assert send_message.call_args.args[0].id == messenger_device.id
+
+ def test_messenger_failure_falls_back_to_main(self, user_factory):
+ recipient = user_factory(email="fallback-routing@example.com")
+ main_device = _make_push_device(recipient, "-main")
+ messenger_device = _make_push_device(recipient, "-messenger")
+ device_group_id = "f5a714b9-7066-4d56-bbca-86ca4fe7fe72"
+ for app_kind, installation_id, device in (
+ (
+ "main",
+ "68ddb9b8-2001-4ede-89d9-3f46efbb3c42",
+ main_device,
+ ),
+ (
+ "messenger",
+ "397b4a43-85a7-4b78-a9f0-b051327da8f9",
+ messenger_device,
+ ),
+ ):
+ NotificationInstallation.objects.create(
+ user=recipient,
+ installation_id=installation_id,
+ device_group_id=device_group_id,
+ app_kind=app_kind,
+ endpoint=device.registration_id,
+ push_device_id=device.id,
+ installed=True,
+ )
+ NotificationDeliveryRouting.objects.create(
+ user=recipient,
+ device_group_id=device_group_id,
+ routes={"messages": ["messenger"]},
+ )
+ notification = _make_notification(
+ recipient,
+ verb="chat_new_message",
+ description="Сообщение",
+ action_url="/messages/1",
+ )
+
+ def send(device, _message):
+ if device.id == messenger_device.id:
+ raise Exception("410 Gone")
+
+ sender = PushNotificationSender()
+ with patch.object(
+ WebPushDevice,
+ "send_message",
+ autospec=True,
+ side_effect=send,
+ ) as send_message:
+ assert sender.send(notification) is True
+
+ assert [call.args[0].id for call in send_message.call_args_list] == [
+ messenger_device.id,
+ main_device.id,
+ ]
+ messenger_device.refresh_from_db()
+ assert messenger_device.active is False
+
+ def test_non_message_keeps_default_main_route(self, user_factory):
+ recipient = user_factory(email="default-routing@example.com")
+ main_device = _make_push_device(recipient, "-main")
+ messenger_device = _make_push_device(recipient, "-messenger")
+ device_group_id = "65664580-0840-4a30-9446-1887999acd4d"
+ for app_kind, installation_id, device in (
+ (
+ "main",
+ "63661c89-58f6-4d1a-a51d-df85aac22273",
+ main_device,
+ ),
+ (
+ "messenger",
+ "d7b7e05f-a48d-4af0-bbd8-0bcd70b7970e",
+ messenger_device,
+ ),
+ ):
+ NotificationInstallation.objects.create(
+ user=recipient,
+ installation_id=installation_id,
+ device_group_id=device_group_id,
+ app_kind=app_kind,
+ endpoint=device.registration_id,
+ push_device_id=device.id,
+ installed=True,
+ )
+ notification = _make_notification(
+ recipient,
+ verb="request_approved",
+ description="Заявление одобрено",
+ action_url="/requests",
+ )
+
+ sender = PushNotificationSender()
+ with patch.object(
+ WebPushDevice,
+ "send_message",
+ autospec=True,
+ ) as send_message:
+ assert sender.send(notification) is True
+
+ assert send_message.call_count == 1
+ assert send_message.call_args.args[0].id == main_device.id
+
+ @override_settings(MESSENGER_PUSH_LEASE_DAYS=30)
+ def test_stale_messenger_route_falls_back_to_main(self, user_factory):
+ recipient = user_factory(email="stale-routing@example.com")
+ main_device = _make_push_device(recipient, "-main")
+ messenger_device = _make_push_device(recipient, "-messenger")
+ device_group_id = "f699909c-2a64-4240-9db4-62c068223f2c"
+ NotificationInstallation.objects.create(
+ user=recipient,
+ installation_id="df61bef7-8eb1-46c7-890a-a3828778e5ad",
+ device_group_id=device_group_id,
+ app_kind="main",
+ endpoint=main_device.registration_id,
+ push_device_id=main_device.id,
+ installed=True,
+ )
+ NotificationInstallation.objects.create(
+ user=recipient,
+ installation_id="fe5fd3fd-1ab2-47a3-8e38-37e3ec5d8327",
+ device_group_id=device_group_id,
+ app_kind="messenger",
+ endpoint=messenger_device.registration_id,
+ push_device_id=messenger_device.id,
+ installed=True,
+ last_seen_at=timezone.now() - timedelta(days=31),
+ )
+ NotificationDeliveryRouting.objects.create(
+ user=recipient,
+ device_group_id=device_group_id,
+ routes={"messages": ["messenger"]},
+ )
+ notification = _make_notification(
+ recipient,
+ verb="chat_new_message",
+ description="Сообщение",
+ action_url="/messages/1",
+ )
+
+ sender = PushNotificationSender()
+ with patch.object(
+ WebPushDevice,
+ "send_message",
+ autospec=True,
+ ) as send_message:
+ assert sender.send(notification) is True
+
+ assert send_message.call_count == 1
+ assert send_message.call_args.args[0].id == main_device.id
diff --git a/frontend/public/messenger-icon-180.png b/frontend/public/messenger-icon-180.png
new file mode 100644
index 00000000..34f8a830
Binary files /dev/null and b/frontend/public/messenger-icon-180.png differ
diff --git a/frontend/public/messenger-icon-192.png b/frontend/public/messenger-icon-192.png
new file mode 100644
index 00000000..3b703cc8
Binary files /dev/null and b/frontend/public/messenger-icon-192.png differ
diff --git a/frontend/public/messenger-icon-512-maskable.png b/frontend/public/messenger-icon-512-maskable.png
new file mode 100644
index 00000000..90da5ac8
Binary files /dev/null and b/frontend/public/messenger-icon-512-maskable.png differ
diff --git a/frontend/public/messenger-icon-512.png b/frontend/public/messenger-icon-512.png
new file mode 100644
index 00000000..3f63a1e2
Binary files /dev/null and b/frontend/public/messenger-icon-512.png differ
diff --git a/frontend/public/messenger-icon-source.svg b/frontend/public/messenger-icon-source.svg
new file mode 100644
index 00000000..09405fb7
--- /dev/null
+++ b/frontend/public/messenger-icon-source.svg
@@ -0,0 +1,11 @@
+
diff --git a/frontend/public/messenger-sw.js b/frontend/public/messenger-sw.js
new file mode 100644
index 00000000..71dba6ae
--- /dev/null
+++ b/frontend/public/messenger-sw.js
@@ -0,0 +1,4 @@
+// The messenger has an independent registration and PushSubscription while
+// reusing the same cache and notification behavior as the main application.
+self.CORP_SW_APP_KIND = "messenger";
+importScripts("/sw.js");
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
index e0615b51..2bb93918 100644
--- a/frontend/public/sw.js
+++ b/frontend/public/sw.js
@@ -7,6 +7,7 @@
*/
const SW_VERSION = '2.1.2';
+const SW_APP_KIND = self.CORP_SW_APP_KIND || 'main';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
'/manifest.webmanifest',
@@ -163,7 +164,7 @@ self.addEventListener('push', (event) => {
notificationData.requireInteraction = payload.requireInteraction;
}
- } catch (e) {
+ } catch {
console.log('[SW] Push data is not JSON, using as text');
notificationData.body = event.data.text();
}
@@ -207,9 +208,11 @@ self.addEventListener('notificationclick', (event) => {
return exactClient.focus();
}
- const sameOriginClients = windowClients.filter(
- (client) => new URL(client.url).origin === self.location.origin,
- );
+ const sameOriginClients = windowClients.filter((client) => {
+ const clientUrl = new URL(client.url);
+ if (clientUrl.origin !== self.location.origin) return false;
+ return SW_APP_KIND !== 'messenger' || clientUrl.pathname.startsWith('/messages');
+ });
const sameOriginClient =
sameOriginClients.find((client) => client.focused) ||
sameOriginClients.find((client) => client.visibilityState === 'visible') ||
diff --git a/frontend/src/app/manifest.ts b/frontend/src/app/manifest.ts
deleted file mode 100644
index f5b72c21..00000000
--- a/frontend/src/app/manifest.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { MetadataRoute } from "next";
-
-export default function manifest(): MetadataRoute.Manifest {
- return {
- name: "CORP - HiRo",
- short_name: "CORP - HiRo",
- description: "Корпоративная система управления CORP - HiRo",
- start_url: "/",
- scope: "/",
- display: "standalone",
- background_color: "#111923",
- theme_color: "#111923",
- icons: [
- {
- src: "/icon-192.png",
- sizes: "192x192",
- type: "image/png",
- },
- {
- src: "/icon-512.png",
- sizes: "512x512",
- type: "image/png",
- },
- {
- src: "/icon-512-maskable.png",
- sizes: "512x512",
- type: "image/png",
- purpose: "maskable",
- },
- ],
- };
-}
diff --git a/frontend/src/app/manifest.webmanifest/route.ts b/frontend/src/app/manifest.webmanifest/route.ts
new file mode 100644
index 00000000..71e401e6
--- /dev/null
+++ b/frontend/src/app/manifest.webmanifest/route.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+
+export function GET() {
+ return NextResponse.json(
+ {
+ id: "/",
+ name: "CORP - HiRo",
+ short_name: "CORP - HiRo",
+ description: "Корпоративная система управления CORP - HiRo",
+ start_url: "/",
+ scope: "/",
+ display: "standalone",
+ background_color: "#111923",
+ theme_color: "#111923",
+ icons: [
+ {
+ src: "/icon-192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "/icon-512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ {
+ src: "/icon-512-maskable.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ },
+ {
+ headers: {
+ "Content-Type": "application/manifest+json",
+ "Cache-Control": "public, max-age=3600",
+ },
+ },
+ );
+}
diff --git a/frontend/src/app/messages/layout.tsx b/frontend/src/app/messages/layout.tsx
index 60793429..fe7f342c 100644
--- a/frontend/src/app/messages/layout.tsx
+++ b/frontend/src/app/messages/layout.tsx
@@ -1,7 +1,26 @@
+import type { Metadata } from "next";
import type { ReactNode } from "react";
import MessagesWorkspaceShell from "@/components/messages/MessagesWorkspaceShell";
+export const metadata: Metadata = {
+ title: "CORP Мессенджер",
+ applicationName: "CORP Мессенджер",
+ manifest: "/messenger.webmanifest",
+ icons: {
+ icon: [
+ { url: "/messenger-icon-192.png", sizes: "192x192", type: "image/png" },
+ { url: "/messenger-icon-512.png", sizes: "512x512", type: "image/png" },
+ ],
+ apple: "/messenger-icon-180.png",
+ },
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: "CORP Мессенджер",
+ },
+};
+
export default function MessagesLayout({ children }: { children: ReactNode }) {
return
Сообщения