feat(notifications): add messenger PWA delivery routing

This commit is contained in:
lizerk 2026-08-05 15:47:12 +03:00
parent f8919dcdbd
commit fababde18c
38 changed files with 1985 additions and 141 deletions

View File

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

View File

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

View File

@ -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",
),
]

View File

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

View File

@ -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')],
},
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#111923"/>
<path
d="M416 336c0 17.7-14.3 32-32 32H173.2c-8.5 0-16.6 3.4-22.6 9.4l-35.2 35.2c-7.2 7.2-19.4 2.1-19.4-8V144c0-17.7 14.3-32 32-32h256c17.7 0 32 14.3 32 32Z"
fill="none"
stroke="#27b7f6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="30"
/>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

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

View File

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

View File

@ -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",
},
],
};
}

View File

@ -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",
},
},
);
}

View File

@ -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 <MessagesWorkspaceShell>{children}</MessagesWorkspaceShell>;
}

View File

@ -4,7 +4,7 @@ import { MessageCircle, Plus } from "lucide-react";
import MessagesChatListControls from "@/components/messages/MessagesChatListControls";
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
import MessagesImmersiveModeButton from "@/components/messages/MessagesImmersiveModeButton";
import MessagesInstallButton from "@/components/messages/MessagesInstallButton";
import { useMessagesWorkspace } from "@/components/messages/MessagesWorkspaceContext";
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
@ -58,7 +58,7 @@ function StandardMessagesList() {
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Сообщения</p>
<div className="flex items-center gap-1">
<MessagesFocusModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "wide" : "standard")} />
<MessagesImmersiveModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "immersive" : "standard")} />
<MessagesInstallButton />
<button type="button" onClick={openCreateChat} className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition">
<Plus size={14} /> Создать чат
</button>

View File

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
export function GET() {
return NextResponse.json(
{
id: "/messenger",
name: "CORP Мессенджер",
short_name: "Мессенджер",
description: "Сообщения CORP - HiRo",
start_url: "/messages?pwa=messenger",
scope: "/messages",
display: "standalone",
background_color: "#111923",
theme_color: "#111923",
icons: [
{
src: "/messenger-icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/messenger-icon-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/messenger-icon-512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
{
headers: {
"Content-Type": "application/manifest+json",
"Cache-Control": "public, max-age=3600",
},
},
);
}

View File

@ -25,6 +25,7 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import NotificationDeliveryApps from "@/components/settings/NotificationDeliveryApps";
import { usePwa } from "@/contexts/PwaContext";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import {
@ -664,6 +665,8 @@ export default function SettingsPage() {
</div>
</div>
<NotificationDeliveryApps />
<div className="app-surface-muted rounded-2xl p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">

View File

@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
import MessagesChatListControls from "@/components/messages/MessagesChatListControls";
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
import MessagesImmersiveModeButton from "@/components/messages/MessagesImmersiveModeButton";
import MessagesInstallButton from "@/components/messages/MessagesInstallButton";
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
@ -33,10 +33,7 @@ export default function MessagesFocusSidebar({
enabled={mode !== "standard"}
onChange={(enabled) => onModeChange(enabled ? "wide" : "standard")}
/>
<MessagesImmersiveModeButton
enabled={mode === "immersive"}
onChange={(enabled) => onModeChange(enabled ? "immersive" : "wide")}
/>
<MessagesInstallButton />
<button
type="button"
onClick={onCreateChat}

View File

@ -1,28 +0,0 @@
"use client";
import { PanelTopClose, PanelTopOpen } from "lucide-react";
type MessagesImmersiveModeButtonProps = {
enabled: boolean;
onChange: (enabled: boolean) => void;
};
export default function MessagesImmersiveModeButton({
enabled,
onChange,
}: MessagesImmersiveModeButtonProps) {
const label = enabled ? "Выйти из режима мессенджера" : "Открыть режим мессенджера";
return (
<button
type="button"
onClick={() => onChange(!enabled)}
className="app-action-ghost hidden h-9 w-9 shrink-0 items-center justify-center rounded-md lg:inline-flex"
title={label}
aria-label={label}
aria-pressed={enabled}
>
{enabled ? <PanelTopOpen size={17} /> : <PanelTopClose size={17} />}
</button>
);
}

View File

@ -0,0 +1,266 @@
"use client";
import { Check, Download, Monitor, Share2, Smartphone } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { usePwa } from "@/contexts/PwaContext";
import { useWebPush } from "@/hooks/useWebPush";
import { apiClient } from "@/lib/api";
import {
clearPendingMessengerRoute,
getDeviceGroupId,
getPendingMessengerRoute,
isMessengerLaunch,
savePendingMessengerRoute,
type NotificationDestination,
} from "@/lib/notifications/installations";
import { Modal } from "@/components/ui";
type DeliveryChoice = "messenger" | "both" | "main";
const CHOICES: Array<{
value: DeliveryChoice;
title: string;
description: string;
destinations: NotificationDestination[];
}> = [
{
value: "messenger",
title: "Только мессенджер",
description: "Без дубликатов в основном приложении на этом устройстве.",
destinations: ["messenger"],
},
{
value: "both",
title: "Оба приложения",
description: "Сообщения будут приходить в CORP и мессенджер.",
destinations: ["main", "messenger"],
},
{
value: "main",
title: "Только CORP",
description: "Мессенджер установится без push о сообщениях.",
destinations: ["main"],
},
];
function isIosDevice() {
if (typeof navigator === "undefined") return false;
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
export default function MessagesInstallButton() {
const {
canInstallMessenger,
installMessenger,
} = usePwa();
const messengerLaunch = useMemo(() => isMessengerLaunch(), []);
const pendingRoute = useMemo(() => getPendingMessengerRoute(), []);
const [open, setOpen] = useState(Boolean(messengerLaunch && pendingRoute));
const [choice, setChoice] = useState<DeliveryChoice>("messenger");
const [showIosInstructions, setShowIosInstructions] = useState(false);
const [finishing, setFinishing] = useState(false);
const [messengerConnected, setMessengerConnected] = useState(false);
const messengerPush = useWebPush({
appKind: "messenger",
enabled: messengerLaunch || open,
});
useEffect(() => {
if (messengerLaunch && getPendingMessengerRoute()) {
setOpen(true);
}
}, [messengerLaunch]);
useEffect(() => {
let mounted = true;
void apiClient.getNotificationDeliverySettings(getDeviceGroupId())
.then((settings) => {
if (!mounted) return;
setMessengerConnected(settings.installations.some(
(installation) => installation.app_kind === "messenger" && installation.installed && installation.push_ready,
));
})
.catch(() => undefined);
return () => {
mounted = false;
};
}, []);
const finishSetup = async (destinations: NotificationDestination[]) => {
setFinishing(true);
try {
const subscribed = messengerPush.isSubscribed || await messengerPush.subscribe();
if (!subscribed) return;
const deviceGroupId = getDeviceGroupId();
const current = await apiClient.getNotificationDeliverySettings(deviceGroupId);
await apiClient.updateNotificationDeliverySettings({
device_group_id: deviceGroupId,
routes: {
...current.routes,
messages: destinations,
},
});
clearPendingMessengerRoute();
setMessengerConnected(true);
setOpen(false);
toast.success("Мессенджер подключен к уведомлениям");
} catch (error) {
console.error(error);
toast.error("Не удалось завершить настройку уведомлений");
} finally {
setFinishing(false);
}
};
const startInstall = async () => {
const destinations = CHOICES.find((item) => item.value === choice)?.destinations || ["messenger"];
savePendingMessengerRoute(destinations);
const result = await installMessenger();
if (result === "accepted") {
setOpen(false);
toast.success("Откройте установленный мессенджер, чтобы включить уведомления");
return;
}
if (result === "dismissed") {
toast.info("Установка отменена. Настройки CORP не изменены");
return;
}
setShowIosInstructions(true);
};
if (!messengerLaunch && (messengerConnected || !canInstallMessenger)) {
return null;
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="app-action-ghost inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
title="Установить мессенджер"
aria-label="Установить мессенджер"
>
<Download size={17} />
</button>
<Modal
isOpen={open}
onClose={() => setOpen(false)}
title={messengerLaunch ? "Завершить настройку" : "Установить мессенджер"}
size="sm"
footer={
<div className="flex w-full justify-end gap-2">
{showIosInstructions ? (
<button
type="button"
onClick={() => setOpen(false)}
className="app-action-primary rounded-lg px-4 py-2 text-sm"
>
Понятно
</button>
) : (
<>
<button
type="button"
onClick={() => setOpen(false)}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={() => {
const destinations = pendingRoute || CHOICES.find((item) => item.value === choice)?.destinations || ["messenger"];
if (messengerLaunch) {
void finishSetup(destinations);
} else {
void startInstall();
}
}}
disabled={finishing || messengerPush.isLoading}
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
<Download size={16} />
{messengerLaunch ? "Включить уведомления" : "Продолжить"}
</button>
</>
)}
</div>
}
>
{showIosInstructions ? (
<div className="space-y-4 pb-5">
<div className="app-selected rounded-xl p-4">
<div className="flex items-start gap-3">
<Share2 size={20} className="app-accent-text mt-0.5 shrink-0" />
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
{isIosDevice() ? "Установка на iPhone или iPad" : "Завершите установку мессенджера"}
</p>
<p className="app-text-muted mt-1 text-sm">
{isIosDevice()
? "Нажмите «Поделиться» в браузере, выберите «На экран Домой», затем откройте установленный мессенджер."
: "Используйте команду установки в меню браузера, затем откройте CORP Мессенджер из списка приложений."}
</p>
</div>
</div>
</div>
<p className="app-text-muted text-sm">
Настройки обычного CORP останутся активными, пока мессенджер не запустится и не получит разрешение на уведомления.
</p>
</div>
) : messengerLaunch ? (
<div className="space-y-4 pb-5">
<div className="app-selected rounded-xl p-4">
<div className="flex items-start gap-3">
<Smartphone size={20} className="app-accent-text mt-0.5 shrink-0" />
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">Разрешите системные уведомления</p>
<p className="app-text-muted mt-1 text-sm">
После подтверждения выбранный маршрут сообщений начнет действовать на этом устройстве.
</p>
</div>
</div>
</div>
</div>
) : (
<div className="space-y-4 pb-5">
<p className="app-text-muted text-sm">
Выберите, где получать уведомления о сообщениях на этом устройстве.
</p>
<div className="space-y-2">
{CHOICES.map((item) => {
const selected = choice === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => setChoice(item.value)}
className={`flex w-full items-start gap-3 rounded-xl p-3 text-left transition ${selected ? "app-selected" : "app-surface-muted"}`}
>
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${selected ? "app-badge app-badge-accent" : "app-badge"}`}>
{selected ? <Check size={15} /> : item.value === "main" ? <Monitor size={15} /> : <Smartphone size={15} />}
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-[var(--foreground)]">{item.title}</span>
<span className="app-text-muted mt-1 block text-xs">{item.description}</span>
</span>
</button>
);
})}
</div>
{isIosDevice() ? (
<p className="app-text-muted text-xs">На iOS после выбора появится инструкция добавления на домашний экран.</p>
) : null}
</div>
)}
</Modal>
</>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { AppShell } from "@/components/AppShell";
@ -10,7 +10,12 @@ import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar";
import { MessagesWorkspaceContext } from "@/components/messages/MessagesWorkspaceContext";
import { useUser } from "@/contexts/UserContext";
import { useMessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
import { getMessagesActiveChatId, isMessagesDialogPath } from "@/lib/messages/workspace.js";
import {
getMessagesActiveChatId,
isMessagesDialogPath,
resolveEffectiveMessagesWorkspaceMode,
} from "@/lib/messages/workspace.js";
import { isMessengerLaunch } from "@/lib/notifications/installations";
type MessagesWorkspaceShellProps = {
children: ReactNode;
@ -20,7 +25,12 @@ export default function MessagesWorkspaceShell({ children }: MessagesWorkspaceSh
const pathname = usePathname();
const { user } = useUser();
const [createChatOpen, setCreateChatOpen] = useState(false);
const [mode, setMode] = useMessagesWorkspaceMode(user?.id);
const [storedMode, setStoredMode] = useMessagesWorkspaceMode(user?.id);
const messengerLaunch = useMemo(() => isMessengerLaunch(), []);
const mode = resolveEffectiveMessagesWorkspaceMode(storedMode, messengerLaunch);
const setMode = useCallback((nextMode: typeof mode) => {
if (!messengerLaunch) setStoredMode(nextMode);
}, [messengerLaunch, setStoredMode]);
const workspaceMode = mode !== "standard";
const immersiveMode = mode === "immersive";
const activeChatId = getMessagesActiveChatId(pathname);

View File

@ -0,0 +1,233 @@
"use client";
import { Check, Monitor, Save, Smartphone, Unplug } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { apiClient } from "@/lib/api";
import {
clearMessengerInstallMarker,
getDeviceGroupId,
type NotificationDeliverySettings,
type NotificationDestination,
} from "@/lib/notifications/installations";
function destinationEnabled(
destinations: NotificationDestination[] | undefined,
destination: NotificationDestination,
) {
return destinations?.includes(destination) ?? destination === "main";
}
export default function NotificationDeliveryApps() {
const [settings, setSettings] = useState<NotificationDeliverySettings | null>(null);
const [savedRoutes, setSavedRoutes] = useState<Record<string, NotificationDestination[]>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let mounted = true;
void apiClient.getNotificationDeliverySettings(getDeviceGroupId())
.then((response) => {
if (!mounted) return;
setSettings(response);
setSavedRoutes(response.routes);
})
.catch((error) => {
console.error(error);
toast.error("Не удалось загрузить приложения доставки");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, []);
const messenger = settings?.installations.find(
(installation) => installation.app_kind === "messenger",
);
const messengerReady = Boolean(messenger?.installed && messenger.push_ready);
const dirty = useMemo(
() => JSON.stringify(settings?.routes || {}) !== JSON.stringify(savedRoutes),
[savedRoutes, settings?.routes],
);
const toggleDestination = (
category: string,
destination: NotificationDestination,
) => {
setSettings((current) => {
if (!current) return current;
const existing = current.routes[category] || ["main"];
const enabled = existing.includes(destination);
const next = enabled
? existing.filter((item) => item !== destination)
: [...existing, destination];
if (next.length === 0) {
toast.info("Для категории должно остаться хотя бы одно приложение");
return current;
}
return {
...current,
routes: { ...current.routes, [category]: next },
};
});
};
const save = async () => {
if (!settings || !dirty) return;
setSaving(true);
try {
const response = await apiClient.updateNotificationDeliverySettings({
device_group_id: settings.device_group_id,
routes: settings.routes,
});
setSettings(response);
setSavedRoutes(response.routes);
toast.success("Приложения доставки обновлены");
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : "Не удалось сохранить маршруты");
} finally {
setSaving(false);
}
};
const disconnectMessenger = async () => {
if (!settings || !window.confirm(
"Отключить мессенджер на этом устройстве? Уведомления вернутся в основной CORP.",
)) return;
setSaving(true);
try {
const response = await apiClient.disconnectNotificationInstallation({
device_group_id: settings.device_group_id,
app_kind: "messenger",
});
setSettings((current) => current ? {
...current,
routes: response.routes,
installations: current.installations.filter(
(installation) => installation.app_kind !== "messenger",
),
} : current);
setSavedRoutes(response.routes);
clearMessengerInstallMarker();
toast.success("Мессенджер отключен, доставка возвращена в CORP");
} catch (error) {
console.error(error);
toast.error("Не удалось отключить мессенджер");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="app-surface-muted rounded-2xl p-5">
<p className="app-text-muted text-sm">Загрузка приложений доставки...</p>
</div>
);
}
if (!settings) return null;
return (
<div className="app-surface-muted rounded-2xl p-4 sm:p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">Приложения доставки</p>
<p className="app-text-muted mt-1 text-sm">
Настройки относятся к текущему устройству. Тип события можно доставлять в одно или оба приложения.
</p>
</div>
<button
type="button"
onClick={() => void save()}
disabled={!dirty || saving}
className="app-action-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50"
>
<Save size={15} />
{saving ? "Сохраняем..." : "Сохранить"}
</button>
</div>
<div className="mb-4 grid gap-2 sm:grid-cols-2">
<div className="app-surface rounded-xl p-3">
<div className="flex items-center gap-3">
<span className="app-badge app-badge-accent flex h-9 w-9 items-center justify-center rounded-xl">
<Monitor size={17} />
</span>
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">Основной CORP</p>
<p className="app-text-muted text-xs">Подключен на этом устройстве</p>
</div>
</div>
</div>
<div className="app-surface rounded-xl p-3">
<div className="flex items-center gap-3">
<span className={`app-badge flex h-9 w-9 items-center justify-center rounded-xl ${messengerReady ? "app-badge-accent" : ""}`}>
<Smartphone size={17} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">CORP Мессенджер</p>
<p className="app-text-muted text-xs">
{messengerReady ? "Установлен, push подключен" : "Не подключен на этом устройстве"}
</p>
</div>
</div>
{!messengerReady ? (
<Link href="/messages" className="app-accent-text mt-2 inline-flex text-xs font-medium">
Перейти к установке
</Link>
) : (
<button
type="button"
onClick={() => void disconnectMessenger()}
className="app-action-ghost mt-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs"
>
<Unplug size={13} />
Отключить на устройстве
</button>
)}
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[34rem] space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_9rem_9rem] gap-2 px-3 text-xs font-semibold uppercase text-[var(--muted-foreground)]">
<span>Тип уведомлений</span>
<span className="text-center">CORP</span>
<span className="text-center">Мессенджер</span>
</div>
{settings.categories.map((category) => (
<div
key={category.key}
className="app-surface grid grid-cols-[minmax(0,1fr)_9rem_9rem] items-center gap-2 rounded-xl px-3 py-2.5"
>
<span className="min-w-0 text-sm font-medium text-[var(--foreground)]">{category.label}</span>
{(["main", "messenger"] as const).map((destination) => {
const enabled = destinationEnabled(settings.routes[category.key], destination);
const disabled = destination === "messenger" && !messengerReady;
return (
<button
key={destination}
type="button"
onClick={() => toggleDestination(category.key, destination)}
disabled={disabled}
className={`mx-auto flex h-8 w-8 items-center justify-center rounded-full transition ${enabled ? "app-badge app-badge-accent" : "app-badge"} disabled:cursor-not-allowed disabled:opacity-35`}
aria-label={`${enabled ? "Отключить" : "Включить"} ${destination === "main" ? "CORP" : "мессенджер"} для ${category.label}`}
aria-pressed={enabled}
>
{enabled ? <Check size={15} /> : null}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -1,12 +1,18 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import {
type BeforeInstallPromptEvent,
isStandaloneDisplayMode,
registerAppServiceWorker,
} from "@/lib/pwa";
import {
hasMessengerInstallMarker,
isMessengerLaunch,
markMessengerInstalled,
} from "@/lib/notifications/installations";
type InstallResult = "accepted" | "dismissed" | "unavailable";
@ -15,13 +21,29 @@ type PwaContextValue = {
isInstalled: boolean;
isRegistrationReady: boolean;
install: () => Promise<InstallResult>;
canInstallMessenger: boolean;
isMessengerInstalled: boolean;
installMessenger: () => Promise<InstallResult>;
};
const PwaContext = createContext<PwaContextValue | undefined>(undefined);
function supportsManualIosInstall() {
if (typeof navigator === "undefined" || isStandaloneDisplayMode()) return false;
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
export function PwaProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(() => isStandaloneDisplayMode());
const [messengerPrompt, setMessengerPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(
() => isStandaloneDisplayMode() && !isMessengerLaunch(),
);
const [isMessengerInstalled, setIsMessengerInstalled] = useState(
() => isMessengerLaunch() || hasMessengerInstallMarker(),
);
const [isRegistrationReady, setIsRegistrationReady] = useState(false);
useEffect(() => {
@ -61,12 +83,22 @@ export function PwaProvider({ children }: { children: ReactNode }) {
const handleBeforeInstallPrompt = (event: Event) => {
const installEvent = event as BeforeInstallPromptEvent;
installEvent.preventDefault();
setDeferredPrompt(installEvent);
if (pathname.startsWith("/messages")) {
setMessengerPrompt(installEvent);
} else {
setDeferredPrompt(installEvent);
}
};
const handleAppInstalled = () => {
setDeferredPrompt(null);
setIsInstalled(true);
if (pathname.startsWith("/messages")) {
setMessengerPrompt(null);
setIsMessengerInstalled(true);
markMessengerInstalled();
} else {
setDeferredPrompt(null);
setIsInstalled(true);
}
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
@ -76,7 +108,7 @@ export function PwaProvider({ children }: { children: ReactNode }) {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
}, [pathname]);
const install = useCallback(async (): Promise<InstallResult> => {
if (!deferredPrompt) {
@ -95,14 +127,41 @@ export function PwaProvider({ children }: { children: ReactNode }) {
return "dismissed";
}, [deferredPrompt]);
const installMessenger = useCallback(async (): Promise<InstallResult> => {
if (!messengerPrompt) {
return "unavailable";
}
await messengerPrompt.prompt();
const choice = await messengerPrompt.userChoice;
if (choice.outcome === "accepted") {
setMessengerPrompt(null);
setIsMessengerInstalled(true);
markMessengerInstalled();
return "accepted";
}
return "dismissed";
}, [messengerPrompt]);
const value = useMemo<PwaContextValue>(
() => ({
canInstall: !isInstalled && deferredPrompt !== null,
isInstalled,
isRegistrationReady,
install,
canInstallMessenger: messengerPrompt !== null || supportsManualIosInstall(),
isMessengerInstalled,
installMessenger,
}),
[deferredPrompt, install, isInstalled, isRegistrationReady],
[
deferredPrompt,
install,
installMessenger,
isInstalled,
isMessengerInstalled,
isRegistrationReady,
messengerPrompt,
],
);
return <PwaContext.Provider value={value}>{children}</PwaContext.Provider>;

View File

@ -15,6 +15,13 @@ import {
} from '@/lib/push';
import { apiClient } from '@/lib/api';
import { toast } from 'sonner';
import {
getDeviceGroupId,
getDeviceName,
getInstallationId,
isMessengerLaunch,
type NotificationAppKind,
} from '@/lib/notifications/installations';
interface UseWebPushReturn {
isSupported: boolean;
@ -29,7 +36,13 @@ interface UseWebPushReturn {
/**
* Хук для управления Web Push подписками
*/
export function useWebPush(): UseWebPushReturn {
export function useWebPush({
appKind = 'main',
enabled = true,
}: {
appKind?: NotificationAppKind;
enabled?: boolean;
} = {}): UseWebPushReturn {
const [isSupported] = useState(() => isPushSupported());
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(true);
@ -39,13 +52,14 @@ export function useWebPush(): UseWebPushReturn {
// Инициализация: проверка поддержки и текущего статуса
useEffect(() => {
if (!isSupported) {
if (!enabled || !isSupported) {
setIsLoading(false);
return;
}
async function init() {
try {
setIsLoading(true);
// Получаем разрешение
const perm = getNotificationPermission();
setPermission(perm);
@ -55,13 +69,23 @@ export function useWebPush(): UseWebPushReturn {
setVapidPublicKey(response.vapid_public_key);
// Регистрируем Service Worker
const registration = await registerServiceWorker();
const registration = await registerServiceWorker(appKind);
setSwRegistration(registration);
// Проверяем текущую подписку
const subscription = await getCurrentSubscription(registration);
setIsSubscribed(subscription !== null);
if (appKind === 'main' || isMessengerLaunch()) {
await apiClient.heartbeatNotificationInstallation({
installation_id: getInstallationId(appKind),
device_group_id: getDeviceGroupId(),
app_kind: appKind,
device_name: getDeviceName(),
installed: appKind === 'messenger' ? isMessengerLaunch() : true,
});
}
console.log('[useWebPush] Инициализация завершена, подписка:', subscription !== null);
} catch (error) {
console.error('[useWebPush] Ошибка инициализации:', error);
@ -72,7 +96,7 @@ export function useWebPush(): UseWebPushReturn {
}
init();
}, [isSupported]);
}, [appKind, enabled, isSupported]);
// Запрос разрешения на уведомления
const requestPermission = useCallback(async (): Promise<NotificationPermission> => {
@ -107,7 +131,11 @@ export function useWebPush(): UseWebPushReturn {
// Отправляем на сервер
await apiClient.subscribePush({
...subscriptionData,
device_name: getBrowserInfo(),
device_name: getDeviceName(),
installation_id: getInstallationId(appKind),
device_group_id: getDeviceGroupId(),
app_kind: appKind,
installed: appKind === 'main' || isMessengerLaunch(),
});
setIsSubscribed(true);
@ -120,7 +148,7 @@ export function useWebPush(): UseWebPushReturn {
} finally {
setIsLoading(false);
}
}, [isSupported, swRegistration, vapidPublicKey, permission, requestPermission]);
}, [appKind, isSupported, swRegistration, vapidPublicKey, permission, requestPermission]);
// Отписаться от push уведомлений
const unsubscribe = useCallback(async (): Promise<boolean> => {
@ -167,25 +195,3 @@ export function useWebPush(): UseWebPushReturn {
requestPermission,
};
}
/**
* Получает информацию о браузере для device_name
*/
function getBrowserInfo(): string {
if (typeof window === 'undefined') return 'Unknown';
const ua = navigator.userAgent;
let browser = 'Unknown';
if (ua.indexOf('Firefox') > -1) {
browser = 'Firefox';
} else if (ua.indexOf('Edge') > -1) {
browser = 'Edge';
} else if (ua.indexOf('Chrome') > -1) {
browser = 'Chrome';
} else if (ua.indexOf('Safari') > -1) {
browser = 'Safari';
}
return browser;
}

View File

@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RequestFn } from './utils';
import type {
NotificationAppKind,
NotificationDeliverySettings,
NotificationDestination,
} from '../notifications/installations';
export type UnreadNotificationsSummary = {
total: number;
@ -44,11 +49,46 @@ export function createNotificationsApi(request: RequestFn) {
},
// Push
getVapidPublicKey: (): Promise<{ vapid_public_key: string }> => request('/api/v1/notifications/push/vapid-key/'),
subscribePush: (data: { endpoint: string; keys: { p256dh: string; auth: string }; device_name?: string }): Promise<{ status: string; message: string; created: boolean }> =>
subscribePush: (data: {
endpoint: string;
keys: { p256dh: string; auth: string };
device_name?: string;
installation_id?: string;
device_group_id?: string;
app_kind?: NotificationAppKind;
installed?: boolean;
}): Promise<{ status: string; message: string; created: boolean }> =>
request('/api/v1/notifications/push/subscribe/', { method: 'POST', body: JSON.stringify(data) }),
unsubscribePush: (endpoint?: string): Promise<{ status: string; message: string }> =>
request('/api/v1/notifications/push/unsubscribe/', { method: 'DELETE', body: JSON.stringify({ endpoint }) }),
getPushSubscriptions: (): Promise<{ subscriptions: any[] }> => request('/api/v1/notifications/push/subscriptions/'),
heartbeatNotificationInstallation: (data: {
installation_id: string;
device_group_id: string;
app_kind: NotificationAppKind;
device_name?: string;
installed?: boolean;
}) => request('/api/v1/notifications/push/installations/heartbeat/', {
method: 'POST',
body: JSON.stringify(data),
}),
getNotificationDeliverySettings: (deviceGroupId: string): Promise<NotificationDeliverySettings> =>
request(`/api/v1/notifications/push/delivery-routing/?device_group_id=${encodeURIComponent(deviceGroupId)}`),
updateNotificationDeliverySettings: (data: {
device_group_id: string;
routes: Record<string, NotificationDestination[]>;
}): Promise<NotificationDeliverySettings> => request('/api/v1/notifications/push/delivery-routing/', {
method: 'PUT',
body: JSON.stringify(data),
}),
disconnectNotificationInstallation: (data: {
device_group_id: string;
app_kind: NotificationAppKind;
}): Promise<{ status: string; routes: Record<string, NotificationDestination[]> }> =>
request('/api/v1/notifications/push/installations/disconnect/', {
method: 'DELETE',
body: JSON.stringify(data),
}),
// CRUD
deleteNotification: (id: number): Promise<void> => request(`/api/v1/notifications/${id}/`, { method: 'DELETE' }),
deleteAllReadNotifications: (): Promise<{ status: string; count: number }> => request('/api/v1/notifications/delete-all-read/', { method: 'DELETE' }),

View File

@ -6,5 +6,10 @@ export function resolveMessagesWorkspaceMode(stored: {
legacyImmersive: string | null;
}): MessagesWorkspaceMode;
export function resolveEffectiveMessagesWorkspaceMode(
storedMode: MessagesWorkspaceMode,
messengerLaunch: boolean,
): MessagesWorkspaceMode;
export function getMessagesActiveChatId(pathname: string): number | undefined;
export function isMessagesDialogPath(pathname: string): boolean;

View File

@ -14,6 +14,15 @@ export function resolveMessagesWorkspaceMode({ current, legacyFocus, legacyImmer
return legacyFocus === "0" ? "standard" : "wide";
}
/**
* @param {MessagesWorkspaceMode} storedMode
* @param {boolean} messengerLaunch
* @returns {MessagesWorkspaceMode}
*/
export function resolveEffectiveMessagesWorkspaceMode(storedMode, messengerLaunch) {
return messengerLaunch ? "immersive" : storedMode;
}
/**
* @param {string} pathname
* @returns {number | undefined}

View File

@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import {
getMessagesActiveChatId,
isMessagesDialogPath,
resolveEffectiveMessagesWorkspaceMode,
resolveMessagesWorkspaceMode,
} from "./workspace.js";
@ -19,6 +20,12 @@ test("workspace mode migrates legacy settings", () => {
assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: null, legacyImmersive: null }), "wide");
});
test("installed messenger always uses the isolated workspace without changing the stored mode", () => {
assert.equal(resolveEffectiveMessagesWorkspaceMode("standard", true), "immersive");
assert.equal(resolveEffectiveMessagesWorkspaceMode("wide", true), "immersive");
assert.equal(resolveEffectiveMessagesWorkspaceMode("standard", false), "standard");
});
test("message paths expose chat ownership without treating settings as a dialog", () => {
assert.equal(getMessagesActiveChatId("/messages/42"), 42);
assert.equal(getMessagesActiveChatId("/messages/42/settings"), 42);

View File

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearPendingMessengerRoute,
getDeviceGroupId,
getInstallationId,
getPendingMessengerRoute,
savePendingMessengerRoute,
} from "./notifications/installations.ts";
function createStorage() {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
};
}
test("notification installations keep a stable device group and separate app ids", () => {
globalThis.window = {
localStorage: createStorage(),
};
const deviceGroupId = getDeviceGroupId();
assert.equal(getDeviceGroupId(), deviceGroupId);
assert.notEqual(getInstallationId("main"), getInstallationId("messenger"));
assert.equal(getInstallationId("main"), getInstallationId("main"));
delete globalThis.window;
});
test("pending messenger delivery is stored until setup completes", () => {
globalThis.window = {
localStorage: createStorage(),
};
savePendingMessengerRoute(["main", "messenger"]);
assert.deepEqual(getPendingMessengerRoute(), ["main", "messenger"]);
clearPendingMessengerRoute();
assert.equal(getPendingMessengerRoute(), null);
delete globalThis.window;
});

View File

@ -0,0 +1,129 @@
export type NotificationAppKind = "main" | "messenger";
export type NotificationDestination = NotificationAppKind;
export type NotificationInstallation = {
installation_id: string;
device_group_id: string;
app_kind: NotificationAppKind;
device_name: string;
installed: boolean;
active: boolean;
push_ready: boolean;
last_seen_at: string;
};
export type NotificationDeliverySettings = {
device_group_id: string;
categories: Array<{ key: string; label: string }>;
routes: Record<string, NotificationDestination[]>;
installations: NotificationInstallation[];
};
const DEVICE_GROUP_KEY = "corp:notifications:device-group";
const INSTALLATION_KEY_PREFIX = "corp:notifications:installation:";
const MESSENGER_PENDING_ROUTE_KEY = "corp:messenger:pending-message-route";
const MESSENGER_INSTALLED_KEY = "corp:messenger:installed";
function createUuid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (token) => {
const random = Math.floor(Math.random() * 16);
const value = token === "x" ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}
function getOrCreateStorageValue(key: string): string {
if (typeof window === "undefined") return "";
const existing = window.localStorage.getItem(key);
if (existing) return existing;
const value = createUuid();
window.localStorage.setItem(key, value);
return value;
}
export function getDeviceGroupId(): string {
return getOrCreateStorageValue(DEVICE_GROUP_KEY);
}
export function getInstallationId(appKind: NotificationAppKind): string {
return getOrCreateStorageValue(`${INSTALLATION_KEY_PREFIX}${appKind}`);
}
export function isStandaloneApp(): boolean {
if (typeof window === "undefined") return false;
return Boolean(
window.matchMedia?.("(display-mode: standalone)").matches ||
(navigator as Navigator & { standalone?: boolean }).standalone,
);
}
export function isMessengerLaunch(): boolean {
if (typeof window === "undefined" || !isStandaloneApp()) return false;
const launchFlag = new URLSearchParams(window.location.search).get("pwa") === "messenger";
const sessionFlag = window.sessionStorage.getItem(MESSENGER_INSTALLED_KEY) === "1";
if (launchFlag) {
window.sessionStorage.setItem(MESSENGER_INSTALLED_KEY, "1");
window.localStorage.setItem(MESSENGER_INSTALLED_KEY, "1");
}
return launchFlag || sessionFlag;
}
export function hasMessengerInstallMarker(): boolean {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(MESSENGER_INSTALLED_KEY) === "1";
}
export function markMessengerInstalled(): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(MESSENGER_INSTALLED_KEY, "1");
}
export function clearMessengerInstallMarker(): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(MESSENGER_INSTALLED_KEY);
window.sessionStorage.removeItem(MESSENGER_INSTALLED_KEY);
}
export function savePendingMessengerRoute(
destinations: NotificationDestination[],
): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(
MESSENGER_PENDING_ROUTE_KEY,
JSON.stringify(destinations),
);
}
export function getPendingMessengerRoute(): NotificationDestination[] | null {
if (typeof window === "undefined") return null;
try {
const value = JSON.parse(
window.localStorage.getItem(MESSENGER_PENDING_ROUTE_KEY) || "null",
);
if (!Array.isArray(value)) return null;
const normalized = value.filter(
(item): item is NotificationDestination => item === "main" || item === "messenger",
);
return normalized.length > 0 ? normalized : null;
} catch {
return null;
}
}
export function clearPendingMessengerRoute(): void {
if (typeof window === "undefined") return;
window.localStorage.removeItem(MESSENGER_PENDING_ROUTE_KEY);
}
export function getDeviceName(): string {
if (typeof window === "undefined") return "Unknown";
const userAgentData = (navigator as Navigator & {
userAgentData?: { platform?: string };
}).userAgentData;
const platform = userAgentData?.platform || navigator.platform || "";
const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) ? "Mobile" : "Desktop";
return [platform, mobile].filter(Boolean).join(" · ") || "Browser";
}

View File

@ -2,7 +2,8 @@
* Web Push утилиты для управления подписками
*/
import { registerAppServiceWorker } from "@/lib/pwa";
import { registerAppServiceWorker, registerMessengerServiceWorker } from "@/lib/pwa";
import type { NotificationAppKind } from "@/lib/notifications/installations";
/**
* Конвертирует base64 строку в Uint8Array для applicationServerKey
@ -69,8 +70,12 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
/**
* Регистрирует Service Worker
*/
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration> {
const registration = await registerAppServiceWorker();
export async function registerServiceWorker(
appKind: NotificationAppKind = "main",
): Promise<ServiceWorkerRegistration> {
const registration = appKind === "messenger"
? await registerMessengerServiceWorker()
: await registerAppServiceWorker();
if (!registration) {
throw new Error('Service Worker не поддерживается');
}

View File

@ -21,8 +21,10 @@ export function isStandaloneDisplayMode(): boolean {
}
async function unregisterDevelopmentServiceWorker(): Promise<void> {
const registration = await navigator.serviceWorker.getRegistration("/");
await registration?.unregister().catch(() => undefined);
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister().catch(() => false)),
);
if ("caches" in window) {
const cacheNames = await caches.keys().catch(() => []);
@ -66,3 +68,27 @@ export async function registerAppServiceWorker(): Promise<ServiceWorkerRegistrat
return registration;
}
export async function registerMessengerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return null;
}
if (process.env.NODE_ENV !== "production") {
return null;
}
const scope = `${window.location.origin}/messages`;
const registrations = await navigator.serviceWorker.getRegistrations();
const existingRegistration = registrations.find(
(registration) => registration.scope === scope,
);
if (existingRegistration) {
await existingRegistration.update().catch(() => undefined);
return existingRegistration;
}
return navigator.serviceWorker.register("/messenger-sw.js", {
scope: "/messages",
});
}