Надсилання сповіщень
Віддавайте перевагу skill send-notifications (у .claude/skills/ вашого згенерованого репозиторію)
замість ручної реалізації. Це чек-лист-версія цієї сторінки.
Сповіщення — це повідомлення, що потрапляє до вхідних користувача: іконка дзвіночка з лічильником непрочитаних у верхній панелі та окрема сторінка Сповіщень; якщо користувач онлайн, воно також одразу надсилається в браузер. Конфігураційний код надсилає його одним викликом з Обробника даних або з behavior документа: "імпорт завершено", "3 документи потребують перевірки".
Сповіщення — це збережений рядок; живе надсилання лише доповнення. Користувач, який був офлайн, побачить його при наступному вході. Це не новий вид сутності — немає ні Dto, ні атрибута, ні міграції, ні реєстрації в DI. Ви ін'єктуєте один сервіс і викликаєте його.
Сервіс
INotificationService — звичайний scoped-сервіс, тож його можна ін'єктувати через конструктор будь-де
на сервері. Це свідомо відрізняється від записувачів регістрів і сервісу проведення, які доступні
лише через ISubmitScope, що документ передає в OnSubmitAsync — надсилання повідомлення не є
операцією проведення.
| Метод | Одержувач |
|---|---|
NotifyCallerAsync | Той, хто ініціював поточну роботу — звичайний вибір (див. розділ про два режими запуску нижче) |
NotifyUserAsync / NotifyUsersAsync | Один або кілька названих користувачів |
NotifyRoleAsync | Учасники ролі (роль має дозволити це, див. нижче) |
NotifyWithPermissionAsync | Усі, хто має певний дозвіл |
NotifyAdminsAsync | Усі, кого рушій вважає адміністраторами |
Кожен метод приймає те саме значення Notification, яке каже, що повідомляється, і ніколи — хто
це почує, тож те саме повідомлення можна надіслати і користувачеві, і ролі. Метод повертає кількість
одержувачів, яким воно було адресовано.
Notification має Title, необов'язковий Body, Severity (Info, Success, Warning, Error),
необов'язкові Links та необов'язковий DeduplicationKey. Текст — це NotificationText:
NotificationText.ForKey("Some_Key", arg0, arg1) — ключ ресурсу локалізації, що перетворюється на
текст мовою читача в момент відкриття (додайте ключ до кожного resx-файлу вашого проєкту
локалізації, з плейсхолдерами на кшталт {0}), тоді як NotificationText.ForLiteral("...")
зберігається як є й показується всім однією мовою. Надавайте перевагу ключам.
Наскрізний приклад: повідомити, коли імпорт курсів НБУ завершено
Обробник даних ImportNbuRates еталонної конфігурації (той самий, який сторінка
Планувальник задач запускає щоранку) — перший продюсер. Після створення документа
RateImport він завершується так:
// Acme.Application/DataProcessors/ImportNbuRates.cs
using NotificationMessage = Kandra.Application.Abstractions.Services.Notifications.Notification;
public class ImportNbuRatesBehavior(
/* ... */
INotificationService notifications,
IUnitOfWork unitOfWork)
: IDataProcessorBehavior<ImportNbuRatesDto, ImportNbuRatesResult>
{
public async Task<ImportNbuRatesResult> OnProcessAsync(ImportNbuRatesDto input, CancellationToken cancellationToken)
{
// ... отримати курси, побудувати та вставити RateImport як `created` ...
await notifications.NotifyCallerAsync(new NotificationMessage(
Title: NotificationText.ForKey("NbuRatesImport_Done", dto.Lines.Count, dto.Code),
Severity: NotificationSeverity.Success,
Links: [new NotificationLink
{
Kind = NotificationLinkKind.Document,
EntityTypeId = RateImport.TypeId,
EntityId = created.Id,
CaptionSnapshot = $"Rate Import {dto.Code}",
}]), cancellationToken);
await unitOfWork.SaveAsync(cancellationToken);
return new ImportNbuRatesResult { /* ... */ };
}
}
Псевдонім using ... = ...Notification потрібен, бо запис Notification рушія збігається за назвою з
іншими типами, які ймовірно є у вашій області видимості. Із resx-записом NbuRatesImport_Done =
Imported {0} rate(s) into {1}. (та його українським і російським варіантами) кожен читач бачить
заголовок своєю мовою, із посиланням, що відкриває створений документ.
Дві деталі, які варто перейняти: повідомлення надсилається після успішного виконання роботи (запуск, що завершився помилкою, ніколи не оголошує успіх), і це одне зведення на запуск, а не по одному на кожен імпортований рядок.
Один виклик, два режими запуску
Один і той самий обробник можна запустити двома способами: людина натискає Execute, або Планувальник задач запускає його без нагляду від імені користувача Run As цієї задачі. Код продюсера вище однаковий в обох випадках, і кожен раз сповіщення потрапляє до потрібної людини:
- Ручний запуск —
NotifyCallerAsyncчерезICallerContextвизначає користувача, що увійшов. - Запланований запуск — планувальник встановив перевизначення виклику на користувача Run As задачі, тож той самий виклик сповіщає його, а не того, хто випадково створив задачу.
Тому не розгалужуйте код за принципом "чи я задача?" і не прописуйте одержувача жорстко заради
запланованого випадку — це зламає інший шлях. Викликайте NotifyCallerAsync і дозвольте
ICallerContext вирішити. (Якщо ви хочете, щоб адміністратор дізнався в будь-якому разі, додайте
окремий NotifyAdminsAsync.)
Писати сповіщення "я завершився помилкою" для запланованих обробників не потрібно: планувальник сам сповіщає адміністраторів і користувача Run As, коли задача завершується помилкою (раз на серію збоїв, із 24-годинною паузою), і надсилає одне повідомлення "відновилось", коли вона знову успішна. Достатньо, щоб обробник кинув виняток.
Збереження: сервіс готує, ви фіксуєте
INotificationService ніколи не зберігає. Кожен виклик додає рядки до поточної одиниці роботи
(unit of work), завдяки чому повідомлення атомарне з роботою, яку воно описує:
- У хуку behavior документа (
OnSubmitAsync,OnSaveAsync, ...) власне збереження документа фіксує сповіщення разом із документом. Нічого додатково робити не потрібно. - В Обробнику даних викличте
await unitOfWork.SaveAsync(cancellationToken)після сповіщення, як у прикладі. Якщо забути, підготовлені рядки мовчки відкидаються після завершення scope запиту — без помилки і без сповіщення.
Живе надсилання у відкриті браузери відбувається лише після успішної фіксації та є best-effort: збій надсилання логується і ніколи не провалює ваше збереження.
Надсилання ролі, за дозволом або адміністраторам
Методи для аудиторії призначені для "комусь потрібно на це подивитись", а не "скажи одній людині":
await notifications.NotifyAdminsAsync(msg, cancellationToken: ct);
await notifications.NotifyRoleAsync("Accountants", msg, cancellationToken: ct);
await notifications.NotifyWithPermissionAsync(new SomeAuthorizationPoint(), msg, cancellationToken: ct);
Три запобіжники не дають їм перетворитися на спам-гармату:
- Ролі дають згоду.
NotifyRoleAsyncкидає виняток, якщо в сторінці адміністрування ролей не ввімкнено перемикач Може отримувати групові сповіщення. Якщо роль існує, але учасників немає, записується попередження і нікому нічого не надсилається. Ролі зіставляються за назвою. - Обмеження розсилки. Понад 50 одержувачів — виняток. Передайте
new AudienceOptions { MaxRecipients = 200 }, коли це свідомо. - Відправник виключається зі своєї ж розсилки, якщо не задати
IncludeSender = true.
Посилання
Links додають до сповіщення клікабельні посилання. Існує два види:
Kind | Задається | Відкриває |
|---|---|---|
Document | EntityTypeId (TypeId документа) та EntityId | сторінку документа |
JobRun | EntityId (ідентифікатор запланованого запуску) | історію запусків планувальника |
CaptionSnapshot — текст посилання, і ви пишете його в момент надсилання. Це свідомо копія, а не
пошук, тож посилання читається правильно, навіть якщо ціль пізніше перейменують чи видалять. Виду
посилання на довідник наразі немає; посилайтесь на документ або вкажіть назву в заголовку.
Ідемпотентні продюсери: DeduplicationKey
Обробник, що багаторазово запускається над тими самими фактами — скажімо, запланована перевірка рахунків без накладної — інакше нагадував би про той самий рахунок щоразу. Надайте кожному логічному повідомленню стабільний ключ:
new NotificationMessage(
Title: NotificationText.ForKey("Invoice_NoWaybill", invoice.Code),
Severity: NotificationSeverity.Warning,
DeduplicationKey: $"hint:InvoiceWithoutWaybill:{invoice.Id}")
Одержувач, у якого вже є сповіщення з таким ключем, мовчки пропускається, а виклик враховує лише тих,
кого справді сповістили (можливо, 0). Ключ перевіряється для кожного одержувача окремо, і вже
прочитане сповіщення все одно блокує повтор. Не вказуйте ключ для одноразових повідомлень "запуск
завершено", які мають надходити завжди.
Перевірка
- Запустіть обробник з його сторінки та простежте, як зростає лічильник дзвіночка. Відкрийте Сповіщення й перевірте локалізований заголовок та те, що посилання відкриває потрібний документ.
- Для запланованого обробника створіть задачу з користувачем Run As, відмінним від вас, і натисніть Trigger now: сповіщення надійде користувачеві Run As, а не вам.
- Якщо ви використали
DeduplicationKey, запустіть двічі й переконайтесь, що сповіщення все ще одне.
Що ще не побудовано
Щоб ви не планували на тому, чого немає:
- Чату (розмови між користувачами та підтримка) не існує. Цю сторінку буде розширено, коли він з'явиться.
- Підказки робочого процесу — запланований обробник, що нагадує про відсутні наступні кроки, — не
потребують змін рушія (це саме шаблон
DeduplicationKeyвище), але готового прикладу в еталонній конфігурації поки немає. - Зберігання фіксоване. Сповіщення, які ви прочитали, видаляються через 90 днів (перевірка щошість годин); непрочитані зберігаються безстроково. Налаштування для окремої інсталяції немає.
- Не місце для обробників (handlers). Handlers — це серверні запити посеред редагування; не надсилайте з них сповіщення.
Дивіться також
- Створення Обробника даних — де зазвичай живе продюсер
- Планувальник задач — запуски без нагляду,
Run Asта сповіщення про збої, які рушій надсилає за вас - Створення документа — хуки behavior, які можуть сповіщати
- Локалізація — додавання resx-ключів, потрібних заголовку
ForKey