Improve notification labels and push avatars

This commit is contained in:
lizerk 2026-06-12 10:50:15 +03:00
parent 947883243c
commit 3f471424cd
10 changed files with 464 additions and 75 deletions

View File

@ -4,6 +4,7 @@ Web Push отправитель для browser push notifications
import json
from django.conf import settings
from push_notifications.models import WebPushDevice
from .base import BaseNotificationSender
@ -44,6 +45,42 @@ class PushNotificationSender(BaseNotificationSender):
return ""
return title.strip()
def _get_site_url(self) -> str:
site_url = getattr(settings, "SITE_URL", "").strip()
if site_url:
return site_url.rstrip("/")
if settings.ALLOWED_HOSTS and settings.ALLOWED_HOSTS[0] != "*":
protocol = (
"https"
if getattr(settings, "SECURE_SSL_REDIRECT", False)
else "http"
)
return f"{protocol}://{settings.ALLOWED_HOSTS[0]}"
return "http://localhost:9000"
def _get_actor_avatar_url(self, notification) -> str:
actor = notification.actor
if not actor:
return ""
try:
avatar = getattr(actor, "avatar", None)
if not avatar:
return ""
avatar_url = avatar.url
except Exception:
return ""
if not avatar_url:
return ""
if avatar_url.startswith(("http://", "https://")):
return avatar_url
if avatar_url.startswith("/"):
return f"{self._get_site_url()}{avatar_url}"
return f"{self._get_site_url()}/{avatar_url}"
def _build_title(self, notification) -> str:
if notification.verb in self.CHAT_NOTIFICATION_VERBS:
return self._get_actor_name(notification) or self.DEFAULT_TITLE
@ -84,6 +121,11 @@ class PushNotificationSender(BaseNotificationSender):
body = body[:297] + "..."
# Получаем иконки из конфигурации (None = browser default)
actor_icon = (
self._get_actor_avatar_url(notification)
if notification.verb in self.CHAT_NOTIFICATION_VERBS
else ""
)
default_icon = get("PUSH_DEFAULT_ICON")
default_badge = get("PUSH_DEFAULT_BADGE")
@ -102,8 +144,10 @@ class PushNotificationSender(BaseNotificationSender):
},
}
# Добавляем иконки только если они настроены
if default_icon:
# Для сообщений используем аватар автора, иначе текущую иконку.
if actor_icon:
message_data["icon"] = actor_icon
elif default_icon:
message_data["icon"] = default_icon
if default_badge:
message_data["badge"] = default_badge

View File

@ -39,7 +39,11 @@ class MessageTemplates:
"""Шаблоны сообщений для уведомлений."""
@staticmethod
def new_request(title: str, total_cost: float) -> tuple[str, str]:
def new_request(
title: str,
total_cost: float,
author_name: str,
) -> tuple[str, str]:
"""
Шаблон для новой заявки.
@ -47,16 +51,21 @@ class MessageTemplates:
tuple: (notification_title, description)
"""
notification_title = 'Новая заявка на закупку'
description = f'Создана заявка "{title}" на сумму {total_cost}₽'
description = (
f'{author_name} создал заявку "{title}" '
f'на сумму {total_cost}₽'
)
return notification_title, description
@staticmethod
def department_request(
title: str, department_name: str
title: str,
department_name: str,
author_name: str,
) -> tuple[str, str]:
notification_title = 'Новая заявка для отдела'
notification_title = 'Новая заявка на закупку'
description = (
f'Заявка "{title}" направлена в отдел '
f'{author_name} направил заявку "{title}" в отдел '
f'{department_name}.'
)
return notification_title, description

View File

@ -149,12 +149,15 @@ def notify_new_request(request):
)
return
actor = request.requestor
notification_title, description = MessageTemplates.new_request(
request.title, request.total_cost
request.title,
request.total_cost,
_actor_name(actor),
)
notify.send(
sender=None,
sender=actor,
recipient=request.department.head,
verb=NotificationVerbs.NEW_REQUEST,
action_object=request,
@ -178,10 +181,12 @@ def notify_processing_department_request(request, actor=None):
if not request.processing_department_id:
return
actor = actor or request.requestor
department_name = request.processing_department.name
notification_title, description = MessageTemplates.department_request(
request.title,
department_name,
_actor_name(actor),
)
recipients = _processing_department_recipients(request)
_notify_many(

View File

@ -37,8 +37,11 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
title = f"📩 Вам адресовано заявление от {author_name}"
message = f'Тип: "{request_type}". {comment_preview}'
title = "Новое заявление"
message = (
f'{author_name} адресовал вам заявление. '
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@staticmethod
@ -51,8 +54,11 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
title = f"📋 Вы в копии заявления от {author_name}"
message = f'Тип: "{request_type}". {comment_preview}'
title = "Новое заявление"
message = (
f'{author_name} поставил вас в копию заявления. '
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@staticmethod
@ -65,8 +71,11 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
title = f"✅ Новое заявление на согласование от {author_name}"
message = f'Тип: "{request_type}". {comment_preview}'
title = "Новое заявление"
message = (
f'{author_name} отправил заявление вам на согласование. '
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@staticmethod
@ -79,8 +88,11 @@ class MessageTemplates:
Returns:
tuple: (title, message)
"""
title = f"📝 Новое заявление в отделе от {author_name}"
message = f'Тип: "{request_type}". {comment_preview}'
title = "Новое заявление"
message = (
f'{author_name} направил заявление в отдел. '
f'Тип: "{request_type}". {comment_preview}'
)
return title, message
@staticmethod

View File

@ -12,7 +12,9 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from unittest.mock import patch
from requests_app.models import Request, RequestComment
from communications.comments_helpers import create_comment
from requests_app.models import Request
from requests_app.notifications.handlers import notify_new_request
from employees.models import Department
from notifications.models import Notification
@ -29,30 +31,51 @@ class TestRequestNotifications(TestCase):
self.author = Employee.objects.create_user(
username='test_author',
email='author@test.com',
phone_number='+79990000001',
first_name='Иван',
last_name='Автор'
last_name='Автор',
email_verified=True,
send_activation_email=False,
)
self.recipient = Employee.objects.create_user(
username='test_recipient',
email='recipient@test.com',
phone_number='+79990000002',
first_name='Петр',
last_name='Получатель'
last_name='Получатель',
email_verified=True,
send_activation_email=False,
)
self.cc_user = Employee.objects.create_user(
username='test_cc',
email='cc@test.com',
phone_number='+79990000003',
first_name='Сидор',
last_name='Копия'
last_name='Копия',
email_verified=True,
send_activation_email=False,
)
self.approver = Employee.objects.create_user(
username='test_approver',
email='approver@test.com',
phone_number='+79990000004',
first_name='Алексей',
last_name='Согласующий'
last_name='Согласующий',
email_verified=True,
send_activation_email=False,
)
for employee in [
self.author,
self.recipient,
self.cc_user,
self.approver,
]:
employee.email_verified = True
employee.is_active = True
employee.save(update_fields=["email_verified", "is_active"])
# Создаем отдел
self.department = Department.objects.create(
@ -84,7 +107,14 @@ class TestRequestNotifications(TestCase):
notif = notifications.first()
# Проверяем содержимое
self.assertIn('Иван Автор', notif.data['title'])
self.assertEqual(
notif.data['title'],
'Новое заявление',
)
self.assertIn(
'Иван Автор адресовал вам заявление',
notif.description,
)
self.assertEqual(notif.data['request_id'], request.id)
self.assertEqual(notif.data['is_primary_recipient'], True)
self.assertTrue(notif.unread)
@ -101,6 +131,8 @@ class TestRequestNotifications(TestCase):
request.recipients.set([self.recipient])
request.cc_users.set([self.cc_user])
Notification.objects.all().delete()
notify_new_request(request)
# Проверяем уведомление для CC
notifications = Notification.objects.filter(
@ -112,7 +144,71 @@ class TestRequestNotifications(TestCase):
notif = notifications.first()
self.assertEqual(notif.data['is_cc'], True)
self.assertIn('копии', notif.data['title'])
self.assertEqual(
notif.data['title'],
'Новое заявление',
)
self.assertIn(
'Иван Автор поставил вас в копию заявления',
notif.description,
)
def test_new_request_notification_to_approver(self):
"""Тест: уведомление согласующему при создании заявления"""
request = Request.objects.create(
employee=self.author,
type='vacation',
comment='Отпуск',
status='pending',
department=self.department,
)
request.approver = self.approver
Notification.objects.all().delete()
notify_new_request(request)
notif = Notification.objects.get(
recipient=self.approver,
verb='request_new',
)
self.assertEqual(
notif.data['title'],
'Новое заявление',
)
self.assertIn(
'Иван Автор отправил заявление вам на согласование',
notif.description,
)
self.assertEqual(notif.data['is_approver'], True)
def test_new_request_notification_to_department(self):
"""Тест: уведомление отделу при создании заявления"""
request = Request.objects.create(
employee=self.author,
type='vacation',
comment='Отпуск',
status='pending',
department=self.department,
)
Notification.objects.all().delete()
notify_new_request(request)
notif = Notification.objects.get(
recipient=self.approver,
verb='request_new',
)
self.assertEqual(
notif.data['title'],
'Новое заявление',
)
self.assertIn(
'Иван Автор направил заявление в отдел',
notif.description,
)
self.assertEqual(notif.data['is_approver'], False)
def test_request_approval_notification(self):
"""Тест: уведомление об одобрении заявления"""
@ -130,8 +226,7 @@ class TestRequestNotifications(TestCase):
Notification.objects.all().delete()
# Одобряем заявление
request.status = 'approved'
request.save()
request.approve(self.approver)
# Проверяем уведомление автору
notifications = Notification.objects.filter(
@ -161,8 +256,7 @@ class TestRequestNotifications(TestCase):
Notification.objects.all().delete()
# Отклоняем заявление
request.status = 'rejected'
request.save()
request.reject(self.approver)
notifications = Notification.objects.filter(
recipient=self.author,
@ -189,23 +283,23 @@ class TestRequestNotifications(TestCase):
Notification.objects.all().delete()
# Добавляем комментарий
comment = RequestComment.objects.create(
request=request,
comment = create_comment(
obj=request,
author=self.recipient,
text='Нужна справка'
content='Нужна справка'
)
# Проверяем уведомление автору
notifications = Notification.objects.filter(
recipient=self.author,
verb='request_comment'
verb='commented'
)
self.assertEqual(notifications.count(), 1)
notif = notifications.first()
self.assertIn('комментарий', notif.data['title'])
self.assertEqual(notif.data['comment_id'], comment.id)
self.assertEqual(notif.data['message_id'], comment.id)
self.assertIn('Нужна справка', notif.description)
def test_no_notification_to_author_on_own_comment(self):
@ -222,16 +316,16 @@ class TestRequestNotifications(TestCase):
Notification.objects.all().delete()
# Автор комментирует свое заявление
RequestComment.objects.create(
request=request,
create_comment(
obj=request,
author=self.author,
text='Дополнительная информация'
content='Дополнительная информация'
)
# Автор НЕ должен получить уведомление
notifications = Notification.objects.filter(
recipient=self.author,
verb='request_comment'
verb='commented'
)
self.assertEqual(notifications.count(), 0)
@ -254,7 +348,6 @@ class TestRequestNotifications(TestCase):
verb='request_new'
)
self.assertEqual(notifications.count(), 2)
recipients = [n.recipient for n in notifications]
self.assertIn(self.recipient, recipients)

View File

@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from push_notifications.models import WebPushDevice
from notifications.models import Notification, UserChannelPreferences
@ -83,9 +84,43 @@ class TestPushNotificationSenderPayload:
"url": "/messages/123",
"notification_id": notification.id,
}
assert "icon" not in payload
assert "chat_new_message" not in json.dumps(payload)
assert "verb" not in payload["data"]
@override_settings(SITE_URL="https://corp.example.test")
def test_chat_push_uses_actor_avatar_as_icon(self, user_factory):
recipient = user_factory(email="avatar-recipient@example.com")
actor = user_factory(
email="avatar-actor@example.com",
first_name="Олеся",
last_name="Рубцова",
)
actor.avatar = "users/avatars/actor.jpg"
actor.save(update_fields=["avatar"])
_make_push_device(recipient)
notification = _make_notification(
recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.pk,
verb="chat_new_message",
description="Полинина",
action_url="/messages/123",
data={
"title": "Новое сообщение от Рубцова Олеся",
"chat_id": 123,
"message_id": 456,
},
)
payload = _send_and_load_payload(notification)
assert (
payload["icon"]
== "https://corp.example.test/media/users/avatars/actor.jpg"
)
def test_non_chat_push_uses_notification_data_title(self, user_factory):
recipient = user_factory(email="request-recipient@example.com")
_make_push_device(recipient)
@ -103,6 +138,27 @@ class TestPushNotificationSenderPayload:
assert payload["head"] == "Заявка одобрена"
assert "request_approved" not in json.dumps(payload)
def test_non_chat_push_does_not_use_actor_avatar_icon(self, user_factory):
recipient = user_factory(email="request-avatar-recipient@example.com")
actor = user_factory(email="request-avatar-actor@example.com")
actor.avatar = "users/avatars/request-actor.jpg"
actor.save(update_fields=["avatar"])
_make_push_device(recipient)
notification = _make_notification(
recipient,
actor_content_type=ContentType.objects.get_for_model(actor),
actor_object_id=actor.pk,
verb="request_approved",
description="Ваша заявка одобрена",
action_url="/requests?request=10",
data={"title": "Заявка одобрена"},
)
payload = _send_and_load_payload(notification)
assert "icon" not in payload
def test_push_title_falls_back_without_actor_or_data_title(
self, user_factory
):

View File

@ -36,6 +36,7 @@ from procurement.models import (
ProcurementItem,
ProcurementRequest,
)
from procurement.notifications.handlers import notify_new_request
from notifications.models import Notification, UserChannelPreferences
@ -976,10 +977,13 @@ class TestProcessingDepartmentWorkflow:
assert response.status_code == status.HTTP_201_CREATED
assert response.data["status"] == ProcurementStatus.WAITING
recipients = sorted(
item["recipient"].email
department_notifications = [
item
for item in sent
if item["verb"] == "procurement_department_request"
]
recipients = sorted(
item["recipient"].email for item in department_notifications
)
assert recipients == [
"role-supply@example.com",
@ -987,6 +991,54 @@ class TestProcessingDepartmentWorkflow:
"supply@example.com",
]
assert user.email not in recipients
author_name = user.get_full_name() or user.username
assert all(
item["sender"] == user for item in department_notifications
)
assert all(
item["data"]["title"] == "Новая заявка на закупку"
for item in department_notifications
)
assert all(
f'{author_name} направил заявку "Расходники"'
in item["description"]
for item in department_notifications
)
def test_legacy_new_request_notification_uses_requestor_as_sender(
self, user, department, department_head, monkeypatch
):
sent = []
def fake_notify_send(**kwargs):
sent.append(kwargs)
monkeypatch.setattr(
"procurement.notifications.handlers.notify.send",
fake_notify_send,
)
procurement_request = ProcurementRequest.objects.create(
title="Расходники",
description="Нужно для производства",
department=department,
requestor=user,
status=ProcurementStatus.DRAFT,
urgency=UrgencyLevel.MEDIUM,
)
notify_new_request(procurement_request)
assert len(sent) == 1
author_name = user.get_full_name() or user.username
notification = sent[0]
assert notification["sender"] == user
assert notification["recipient"] == department_head
assert notification["verb"] == "procurement_new_request"
assert notification["data"]["title"] == "Новая заявка на закупку"
assert (
f'{author_name} создал заявку "Расходники"'
in notification["description"]
)
def test_available_shows_processing_department_requests(
self, api_client, supply_user, processing_request, procurement_item_factory

View File

@ -389,6 +389,12 @@ def test_create_request_sends_notification_to_cc_users(
).first()
assert cc_notification is not None
assert cc_notification.data["is_cc"] is True
author_name = regular_user.get_full_name() or regular_user.username
assert cc_notification.data["title"] == "Новое заявление"
assert (
f"{author_name} поставил вас в копию заявления"
in cc_notification.description
)
def test_create_draft_allows_minimal_payload(

View File

@ -75,7 +75,7 @@ function Toggle({
disabled?: boolean;
}) {
return (
<label className="relative inline-flex cursor-pointer items-center">
<label className="relative inline-flex shrink-0 cursor-pointer items-center">
<input
type="checkbox"
checked={checked}
@ -83,7 +83,7 @@ function Toggle({
disabled={disabled}
className="peer sr-only"
/>
<div className="h-6 w-11 rounded-full bg-[var(--surface-tertiary)] transition after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-all after:content-[''] peer-checked:bg-[var(--accent-primary)] peer-checked:after:translate-x-full peer-disabled:cursor-not-allowed peer-disabled:opacity-50" />
<div className="h-6 w-11 shrink-0 rounded-full bg-[var(--surface-tertiary)] transition after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-all after:content-[''] peer-checked:bg-[var(--accent-primary)] peer-checked:after:translate-x-full peer-disabled:cursor-not-allowed peer-disabled:opacity-50" />
</label>
);
}
@ -731,15 +731,15 @@ export default function SettingsPage() {
return (
<label
key={verbType.verb}
className={`flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 transition ${
className={`grid w-full max-w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 overflow-hidden rounded-xl px-3 py-3 transition sm:px-4 ${
enabled ? "app-surface" : "app-surface-muted opacity-80"
}`}
>
<div className="min-w-0 pr-4">
<p className="truncate text-sm font-medium text-[var(--foreground)]">
<div className="min-w-0 flex-1">
<p className="app-text-wrap whitespace-normal text-sm font-medium leading-snug text-[var(--foreground)]">
{getVerbName(verbType.verb) || verbType.name}
</p>
<p className="app-text-muted mt-1 text-xs">
<p className="app-text-muted app-text-wrap mt-1 text-xs leading-snug">
Всего: {verbType.total} · Непрочитано: {verbType.unread}
</p>
</div>

View File

@ -8,14 +8,14 @@
// Маппинг verb → категория для группировки
export const VERB_CATEGORIES: Record<string, string> = {
// Заявки
request_new: 'Заявки',
request_created: 'Заявки',
request_updated: 'Заявки',
request_approved: 'Заявки',
request_rejected: 'Заявки',
request_comment: 'Заявки',
request_commented: 'Заявки',
request_status_changed: 'Заявки',
request_new: 'Заявления',
request_created: 'Заявления',
request_updated: 'Заявления',
request_approved: 'Заявления',
request_rejected: 'Заявления',
request_comment: 'Заявления',
request_commented: 'Заявления',
request_status_changed: 'Заявления',
// Сообщения и чат
chat_new_message: 'Сообщения',
@ -24,17 +24,34 @@ export const VERB_CATEGORIES: Record<string, string> = {
chat_added_to_chat: 'Чат',
announcement_new_message: 'Объявления',
message_received: 'Сообщения',
commented: 'Комментарии',
liked: 'Реакции',
approved: 'Согласования',
reminder: 'Напоминания',
mention: 'Упоминания',
mentioned: 'Упоминания',
replied: 'Сообщения',
// Документы
document_ready: 'Документы',
document_uploaded: 'Документы',
document_shared: 'Документы',
document_signed_all: 'Документы',
document_reminder: 'Документы',
document_comment: 'Документы',
document_comment_reply: 'Документы',
document_related: 'Документы',
// Календарь
calendar_invitation: 'Календарь',
calendar_event: 'Календарь',
event_created: 'Календарь',
event_changed: 'Календарь',
event_updated: 'Календарь',
event_cancelled: 'Календарь',
event_reminder: 'Календарь',
event_reminder_hour: 'Календарь',
event_reminder_day: 'Календарь',
// Закупки
procurement_new_request: 'Закупки',
@ -58,55 +75,91 @@ export const VERB_CATEGORIES: Record<string, string> = {
feed_new_post: 'Новости',
feed_post_comment: 'Новости',
feed_post_reaction: 'Новости',
// Отделы
department_new_employee: 'Отдел',
department_employee_left: 'Отдел',
department_structure_changed: 'Отдел',
department_new_head: 'Отдел',
department_announcement: 'Отдел',
// Профиль
profile_data_changed: 'Профиль',
profile_password_changed: 'Профиль',
profile_email_changed: 'Профиль',
profile_messenger_linked: 'Профиль',
profile_new_login: 'Профиль',
// Системные
notification: 'Система',
system_notice: 'Система',
system_maintenance: 'Система',
system_announcement: 'Система',
system_new_feature: 'Система',
system_policy_change: 'Система',
system_notification: 'Система',
};
// Маппинг verb → название для UI (используется когда нет title в data)
export const VERB_NAMES: Record<string, string> = {
// Заявки
request_new: 'Новая заявка',
request_created: 'Новая заявка',
request_updated: 'Заявка обновлена',
request_approved: 'Заявка одобрена',
request_rejected: 'Заявка отклонена',
request_comment: 'Комментарий к заявке',
request_commented: 'Комментарий к заявке',
request_status_changed: 'Статус заявки изменен',
request_new: 'Новое заявление',
request_created: 'Новое заявление',
request_updated: 'Заявление обновлено',
request_approved: 'Заявление одобрено',
request_rejected: 'Заявление отклонено',
request_comment: 'Комментарий к заявлению',
request_commented: 'Комментарий к заявлению',
request_status_changed: 'Статус заявления изменён',
// Сообщения и чат
chat_new_message: 'Новое сообщение',
chat_mention: 'Вас упомянули',
chat_reply: 'Ответ на сообщение',
chat_added_to_chat: 'Добавлены в чат',
chat_added_to_chat: 'Добавление в чат',
announcement_new_message: 'Новое объявление',
message_received: 'Новое сообщение',
message_received: 'Личное сообщение',
commented: 'Новый комментарий',
liked: 'Новая реакция',
approved: 'Одобрение',
reminder: 'Напоминание',
mention: 'Вас упомянули',
mentioned: 'Вас упомянули',
replied: 'Ответ на сообщение',
// Документы
document_ready: 'Документ готов',
document_ready: 'Документ на ознакомление',
document_uploaded: 'Новый документ',
document_shared: 'Документ предоставлен',
document_signed_all: 'Все ознакомились с документом',
document_reminder: 'Напоминание об ознакомлении',
document_comment: 'Комментарий к документу',
document_comment_reply: 'Ответ на комментарий к документу',
document_related: 'Связанный документ',
// Календарь
calendar_invitation: 'Приглашение на событие',
calendar_event: 'Событие календаря',
event_created: 'Событие создано',
event_changed: 'Событие изменено',
event_updated: 'Событие изменено',
event_cancelled: 'Событие отменено',
event_reminder: 'Напоминание о событии',
event_reminder_hour: 'Напоминание за час',
event_reminder_day: 'Напоминание за день',
// Закупки
procurement_new_request: 'Новая закупка',
procurement_department_request: 'Новая заявка для отдела',
procurement_new_request: 'Новая заявка на закупку',
procurement_department_request: 'Заявка на закупку для отдела',
procurement_pending_approval: 'Требуется согласование закупки',
procurement_stage_approved: 'Этап согласования закупки пройден',
procurement_approved: 'Закупка одобрена',
procurement_rejected: 'Закупка отклонена',
procurement_in_progress: 'Закупка взята в работу',
procurement_approved: 'Заявка на закупку одобрена',
procurement_rejected: 'Заявка на закупку отклонена',
procurement_in_progress: 'Заявка на закупку взята в работу',
procurement_executor_reassigned: 'Заявку забрал другой сотрудник',
procurement_arrival_notice: 'Поступление по закупке',
procurement_completed: 'Закупка завершена',
procurement_cancelled: 'Закупка отменена',
procurement_completed: 'Заявка на закупку завершена',
procurement_cancelled: 'Заявка на закупку отменена',
procurement_item_updated: 'Позиция закупки изменена',
procurement_request_commented: 'Комментарий к закупке',
procurement_item_commented: 'Комментарий к позиции закупки',
@ -117,12 +170,69 @@ export const VERB_NAMES: Record<string, string> = {
feed_new_post: 'Новая публикация',
feed_post_comment: 'Комментарий к публикации',
feed_post_reaction: 'Реакция на публикацию',
// Отделы
department_new_employee: 'Новый сотрудник в отделе',
department_employee_left: 'Сотрудник покинул отдел',
department_structure_changed: 'Структура отдела изменена',
department_new_head: 'Новый руководитель отдела',
department_announcement: 'Объявление отдела',
// Профиль
profile_data_changed: 'Данные профиля изменены',
profile_password_changed: 'Пароль изменён',
profile_email_changed: 'Email изменён',
profile_messenger_linked: 'Мессенджер привязан',
profile_new_login: 'Вход из нового места',
// Системные
notification: 'Уведомление',
system_notice: 'Системное уведомление',
system_maintenance: 'Технические работы',
system_announcement: 'Системное объявление',
system_new_feature: 'Новый функционал',
system_policy_change: 'Изменение политик',
system_notification: 'Системное уведомление',
};
const VERB_TOKEN_NAMES: Record<string, string> = {
announcement: 'объявление',
approval: 'согласование',
approved: 'одобрено',
cancelled: 'отменено',
changed: 'изменено',
chat: 'чат',
comment: 'комментарий',
commented: 'комментарий',
completed: 'завершено',
created: 'создано',
day: 'за день',
document: 'документ',
employee: 'сотрудник',
event: 'событие',
feed: 'лента',
hour: 'за час',
item: 'позиция',
maintenance: 'обслуживание',
message: 'сообщение',
new: 'новое',
notification: 'уведомление',
pending: 'ожидает',
post: 'публикация',
procurement: 'закупка',
profile: 'профиль',
reaction: 'реакция',
rejected: 'отклонено',
reminder: 'напоминание',
reply: 'ответ',
request: 'заявка',
signed: 'подписан',
status: 'статус',
system: 'система',
transferred: 'передано',
updated: 'обновлено',
};
/**
* Получить категорию для verb
*/
@ -145,7 +255,7 @@ export function getVerbsByCategory(category: string): string[] {
* Получить название для verb (fallback если нет title в notification.data)
*/
export function getVerbName(verb: string): string {
return VERB_NAMES[verb] || verb.replace(/_/g, ' ');
return VERB_NAMES[verb] || humanizeVerb(verb);
}
/**
@ -153,8 +263,10 @@ export function getVerbName(verb: string): string {
* Преобразует snake_case в Title Case
*/
export function humanizeVerb(verb: string): string {
return verb
const translated = verb
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.map((word) => VERB_TOKEN_NAMES[word] || word)
.join(' ');
return translated.charAt(0).toUpperCase() + translated.slice(1);
}