Consumer: Calendar Events → Notifications (notifications-calendar-events)
Назначение
Группа notifications-calendar-events слушает интеграционные события calendar (календарные события,
записи на встречи, напоминания) и для каждого создаёт набор уведомлений
(NotificationEntity) — fan-out получатель × канал — с учётом настроек
получателя (user-notification-settings.md) и шаблонов
(notification-template.md). Разделение ответственности с источником:
calendar решает, когда и о чём оповестить (материализует напоминания, публикует reminder-due,
каскадит записи и автособытия дедлайнов), notifications — как и кому доставить (получатели, каналы,
тихие часы, шаблоны). Собственных доменных фактов consumer не меняет (кросс-контекстной записи в calendar
нет); действует по схеме persist-then-send: сначала уведомление в статусе Pending, затем отправку
выполняет dispatcher.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | notifications (проект Pin.Notifications.Background) |
| Consumer group | notifications-calendar-events |
| Обрабатываемые события | 9 топиков pin.calendar.*.v1 (таблица ниже) |
| Producer | calendar (контракты — upstream source of truth, ../../calendar/events/) |
| ContractVersion | ожидается 1 по каждому топику; при несовпадении версии — warn-and-proceed |
| Эффект (событие → что меняется) | создаются NotificationEntity (Pending) для получателей события |
Топики и маппинг событие → Kind → получатели
Семейство pin.calendar.event-* дублирует каскады записей (одно бронирование порождает и
appointment-created, и event-created), поэтому consumer фильтрует по Origin: уведомления по факту
записи строит только более специфичное семейство appointment-* — дублей нет (правило зафиксировано в
event-контрактах calendar). Получатели — поля payload соответствующего контракта.
Топик (pin.calendar.*.v1) | Условие (payload) | NotificationKind | Получатели | Urgency |
|---|---|---|---|---|
event-created | Origin = CrmBooking (Kind = BookingDeadline) | CalendarReminder | OwnerUserId — «дедлайн брони добавлен в календарь» (срок из StartAt + TimeZone) | Normal |
event-created | Origin = Manual (собственное действие владельца) / Origin = Appointment (уведомляет appointment-created) | — | — (no-op, ack) | — |
event-rescheduled | Origin = Manual | CalendarEventRescheduled | ClientUserId (если задан; без клиента — no-op: перенос — своё действие владельца) | Normal |
event-rescheduled | Origin = CrmBooking (дедлайн продлён) | CalendarEventRescheduled | OwnerUserId | Normal |
event-status-changed | Origin = Manual, Status = Cancelled, ClientUserId задан | AppointmentCancelled | ClientUserId — «встреча отменена» (+CancelReason) | Normal |
event-status-changed | Origin = CrmBooking, Status = Cancelled | AppointmentCancelled | OwnerUserId — «дедлайн брони снят» | Normal |
event-status-changed | прочие комбинации: Origin = Appointment (уведомляют appointment-cancelled/appointment-completed); Status = InProgress/Completed; Cancelled без клиента | — | — (no-op, ack) | — |
event-owner-changed | всегда (Origin ∈ {CrmBooking, Manual}) | CalendarEventOwnerChanged | OwnerUserId — «на вас переведено обязательство сделки» (срок из StartAt + TimeZone); PreviousOwnerUserId — «обязательство снято» | Normal |
appointment-created | всегда (при RescheduledFromAppointmentId — текст о переносе, а не о новой заявке) | AppointmentCreated | AgentUserId — «новая запись» (при Status = Pending — с призывом подтвердить до дедлайна); InvestorUserId — подтверждение создания заявки | Normal |
appointment-confirmed | всегда | AppointmentConfirmed | InvestorUserId; агенту не строится (ручное подтверждение — его действие; при AutoConfirmed = true агента уведомил appointment-created) | Normal |
appointment-cancelled | RescheduledToAppointmentId пуст | AppointmentCancelled | по Reason: DeclinedByAgent/CancelledByAgent → InvestorUserId; CancelledByInvestor → AgentUserId; Expired → оба (инвестору — «агент не подтвердил запись», агенту — «запись просрочена») | Normal |
appointment-cancelled | RescheduledToAppointmentId задан (перенос) | — | — (уведомление подавляется — участников уведомит appointment-created новой записи; перенос ≠ отмена) | — |
appointment-completed | всегда | — | — (no-op в v1: исход фиксирует сам агент, инвестору результат не рассылается; уведомления по исходу добавляются additive-но) | — |
reminder-due | AppointmentId задан | AppointmentReminder | OwnerUserId и ClientUserId — напоминание о встрече по записи | Critical |
reminder-due | AppointmentId пуст | CalendarReminder | OwnerUserId (+ ClientUserId, если задан) — напоминание о событии календаря | Critical |
- Urgency напоминаний —
Critical: напоминание time-critical — дефер тихими часами/GlobalMuteдо открытия окна обесценил бы его (событие может начаться раньше). Владелец управляет напоминаниями офсетами события (ReminderOffsetsMinutes), получатель — per-Kind opt-out настроек (INV-NTF-3 действует и дляCritical;Criticalобходит только тихие часы иGlobalMute, INV-NTF-4/INV-UNS-4). - Тексты внутри одного
Kind— role-specific (агенту/инвестору/владельцу/клиенту), плейсхолдеры — из типизированного payload (notification.md, payload-модели calendar); локальное время всегда рендерится изStartAt+TimeZone(IANA) события/слота. - Дом полей payload и правил реакции — event-контракты
calendar(напр. appointment-created.md, reminder-due.md).
Контекст события
- Из метаданных события доступны:
MessageId(Guid v7, дедупликация),CreatedAt,Version(=ContractVersion),AggregateId,AggregateType. - Actor — system-actor консюмера; tenant уведомления берётся из получателя (компания или
nullдля инвестора), не изTenantIdсобытия (это компания агента/владельца календаря). - Correlation сквозного трейса восстанавливается из метаданных события.
- Идемпотентность: consumer-level — ключ
notifications-calendar:{EventType}:{MessageId}; business-level — unique(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) защищает от дублей fan-out при replay; дляreminder-due— дополнительный бизнес-ключReminderId(повторная публикация строки напоминания не порождает второго напоминания получателю).
Входные данные / payload события
Consumer читает из каждого события идентификаторы получателей и маршрутные/display-поля для payload уведомления (notification.md); маппинг поле-в-поле — по дому event-контракта. ФИО/контакты в payload не приходят (PII отсутствует по дизайну) — адрес и display-данные (ФИО/локаль) берутся снапшотом из контактной реплики recipient-contact.md на enqueue (шаг 5 алгоритма), а не синхронным lookup.
| Поле payload (типовое) | Тип | Обяз. | Как используется |
|---|---|---|---|
EventId / AppointmentId / ReminderId | Guid | Зав. от топика | → id в payload уведомления + DeepLink + дедуп-контекст (ReminderId — бизнес-ключ напоминаний) |
OwnerUserId / ClientUserId? / AgentUserId / InvestorUserId | Guid | Зав. от топика | множество получателей fan-out (по таблице маппинга, после дедупликации) |
Kind (EventKind) / Origin / Status / Reason / Outcome | string (enum) | Зав. от топика | выбор ветки маппинга (NotificationKind / no-op) и текста шаблона |
StartAt / EndAt? / TimeZone / OffsetMinutes | DateTimeOffset / string / int | Зав. от топика | локальное время и горизонт напоминания в тексте уведомления |
DealId? / BookingId? / ProjectId? / PropertyObjectId? | Guid? | Нет | контекст в payload уведомления (DeepLink на бронь/объект) |
TenantId | Guid | Да | контекст события; tenant уведомления берётся из получателя |
Comment (appointment-created) | string? | Нет | в уведомление не включается (класс Internal, адресат — лента лида CRM); маскирование в логах |
Алгоритм
- Контекст события. Восстановить метаданные (
MessageId, агрегат-источник, время); сверитьVersionсContractVersion = 1(несовпадение — warn-and-proceed); actor = system. Проверить consumer-идемпотентность по ключуnotifications-calendar:{EventType}:{MessageId}: событие уже обработано → логskipped-duplicate, подтвердить (ack, no-op). - Проверка входа. Обязательные идентификаторы непусты (
Guid.Emptyзапрещён); обязательные для топика поля присутствуют. Нарушение контракта →NTF_INVALID_PAYLOAD(non-retriable → DLQ + алерт). - Ветка маппинга. По таблице выше определить
NotificationKindи получателей. Ветка no-op (Origin = Appointment/Manualуevent-*,appointment-completed, подавленная отмена при переносе, отсутствие получателя) → логfilteredс причиной, зафиксировать отметку идемпотентности, ack. Неизвестное значениеOrigin/Reason/Kind— fail-safe по правилу контракта: warning + нейтральная реакция (для неизвестногоOriginуведомление не строится — лучше пропустить, чем продублировать). - Stale-guard напоминаний. Для
reminder-due: еслиStartAtуже в прошлом на момент обработки (просрочка при простое диспетчера/лаге), напоминание не доставляется — логskipped-stale, отметка идемпотентности, ack (fail-safe контракта reminder-due). - Резолв получателей и доставки. Собрать множество получателей по таблице, дедуплицировать
(владелец = агент для событий записей — не уведомлять дважды). По каждому получателю:
- определить каналы для
Kind/Urgencyпо настройкам получателя (INV-UNS-3/4: дефолтыKind+ явные opt-in/out;GlobalMuteи правилоCritical); - подобрать шаблон
(Kind, Locale, Channel)(INV-TPL-5; fallback локалиru; нет тела канала — канал пропускается); нетActive-шаблона подKind—NTF_TEMPLATE_NOT_RESOLVED(non-retriable → DLQ + алерт); - адрес доставки, локаль и display-ФИО — снапшот из контактной реплики
RecipientContactEntity(recipient-contact.md, INV-RC-4/5); отсутствие реплики/точки не ошибка: внешний адрес догоняется адаптером/следующим апдейтом реплики (graceful), in-app адрес не требуется; - тихие часы: interruptive-канал при
Urgency != Criticalв окне тишины откладывается до открытия окна (INV-NTF-4); напоминания (Critical) доставляются сразу.
- определить каналы для
- Материализация. Для каждой валидной пары
(получатель, канал)создаётсяNotificationEntityв статусеPendingс отрисованнымиTitle/Body/DeepLink; дубль по ключу(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) пропускается (идемпотентно). In-app уведомление после фиксации доставляется realtime. - Согласованность. Все созданные
Notification(Pending)и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. Отправку выполняет dispatcher (persist-then-send); финальный исход эмитится какpin.notifications.dispatched.v1dispatcher-ом, не этим consumer-ом. - Результат. Успех (в т.ч.
filtered/skipped-stale/skipped-duplicate) → ack. Retriable-код → retry; non-retriable → DLQ + алерт.
Диаграмма
Идемпотентность и replay
- Consumer-level: ключ
notifications-calendar:{EventType}:{MessageId}— повторная доставка того же события пропускается. - Business-level: unique
(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1) — replay топика не создаёт дублирующих уведомлений; дляreminder-dueдополнительноReminderId— на строку напоминания получатель получает не более одного напоминания. - Порядок и устаревшие сообщения. Ключи партиций источника (
EventIdуevent-*/reminder-due,AppointmentIdуappointment-*) гарантируют порядок внутри агрегата в своём топике; межтопиковый порядок не гарантирован. Consumer состояние агрегатовcalendarне хранит — каждое сообщение самодостаточно; best-effort защита от «уведомления после финала»: если по тому жеEventId/AppointmentIdуже создано уведомление о терминальном исходе (отмена/завершение), более раннее по смыслу сообщение (перенос/подтверждение) пропускается — логskipped-stale, warning. - Replay: да. Повторный re-read безопасен (dedup-ключи). Runbook —
notifications/runbooks/(будущий путь).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны) | — | Exponential backoff с паузой партиции | Нет | Runbook: восстановить зависимость |
| Нарушение контракта payload (нет обязательного поля) | NTF_INVALID_PAYLOAD | Нет | Да + алерт | Согласовать контракт с calendar, replay |
Шаблон (Kind, Locale) не резолвится (нет Active) | NTF_TEMPLATE_NOT_RESOLVED | Нет | Да + алерт | Завести/опубликовать шаблон (SEED/admin), replay |
Неизвестное значение enum (Origin/Reason/Kind/Outcome) | — | Нет (warning, fail-safe реакция по контракту) | Нет | Согласовать additive-значение, при необходимости — replay |
| Конкурентная гонка при dedup | — | Оптимистичный ретрай по версии | Нет | Автоматически |
Отсутствие контактной реплики/точки канала — не failure: уведомление создаётся, адрес email/sms догоняется на
retry (in-app доставляется всегда). Просроченное напоминание (StartAt в прошлом) — не failure и не DLQ:
skipped-stale + ack.
Кросс-сервисные контракты
- Ожидает от
calendar: интеграционные события на 9 топикахpin.calendar.*.v1, key =EventId(семействоevent-*,reminder-due) /AppointmentId(семействоappointment-*),ContractVersion = 1, доставка at-least-once (публикация через outbox источника атомарно с изменением агрегата). Порядок — только внутри одного агрегата в одном топике. Контракты самодостаточны (несут участников, время, таймзону) — синхронных запросов вcalendarнет. PII (ФИО/телефоны/Title/Location) в payload отсутствует по дизайну;Comment(appointment-created) — Internal, в уведомления не включается. Ровно одноreminder-dueна строку напоминания (INV-REM-2 источника). - Предоставляет: набор
NotificationEntity(Pending) на паруполучатель × канал; вcalendarничего не пишет. Финальный исход доставки эмитит dispatcher какpin.notifications.dispatched.v1. - При устаревшей реплике/несовпадении версии:
ContractVersion != 1— warn-and-proceed (обработка по известным полям); отсутствующее обязательное поле —NTF_INVALID_PAYLOAD(DLQ), контракт согласуется сcalendar.
Пред- и постусловия
- Предусловие. Событие получено;
ContractVersionсовместима; для целевогоKind/LocaleсуществуетActive-шаблон с включённым каналом. - Постусловие (успех). Для каждой разрешённой пары
(получатель, канал)существует ровно одинNotificationEntity(Pending, INV-NTF-1); отметка идемпотентности зафиксирована в том же согласованном изменении; offset подтверждён после фиксации; in-app доставлено realtime. Напоминания (AppointmentReminder/CalendarReminder,Urgency = Critical) доставлены без учёта тихих часов иGlobalMute(INV-NTF-4, INV-UNS-4). No-op-ветки (Origin-фильтр, перенос,appointment-completed, stale-напоминание) не породили ни одного уведомления, но идемпотентно подтверждены.
Acceptance criteria
- Given
appointment-created(Status = Pending) сActive-шаблоном, when consumer обрабатывает, then агенту создано уведомление «новая запись, подтвердите», инвестору — «заявка создана», по всем разрешённым каналам; offset подтверждён после фиксации. - Given бронирование с auto-confirm (
appointment-created+appointment-confirmedв разных топиках), when оба события обработаны в любом порядке, then агент уведомлён ровно один раз (поappointment-created), инвестор получилAppointmentCreatedиAppointmentConfirmed— дублей нет. - Given
appointment-cancelled(Reason = CancelledByInvestor), when обработка, then уведомлениеAppointmentCancelledсоздано только агенту (AgentUserId); инвестор не уведомляется. - Given
appointment-cancelled(Reason = Expired), when обработка, then уведомлены обе стороны с role-specific текстами (инвестору — «агент не подтвердил», агенту — «запись просрочена»). - Given
appointment-cancelledс заполненнымRescheduledToAppointmentId, when обработка, then уведомление об отмене не создано (filtered); участников уведомляетappointment-createdновой записи. - Given
event-createdсOrigin = Appointment, when обработка, then уведомления не созданы (no-op) — дублей сappointment-createdнет. - Given
event-createdсOrigin = CrmBooking(BookingId/DealIdзаполнены), when обработка, then владельцу созданоCalendarReminder«дедлайн брони добавлен в календарь» со сроком в таймзонеTimeZone. - Given
event-status-changed(Origin = CrmBooking,Status = Cancelled), when обработка, then владельцу созданоAppointmentCancelled«дедлайн брони снят». - Given
event-status-changed(Origin = Manual,Status = Cancelled,ClientUserIdзадан), when обработка, then клиенту созданоAppointmentCancelledсо временем встречи и причиной. - Given
reminder-dueс заполненнымAppointmentIdи получателем в тихих часах, when обработка, thenAppointmentReminderсоздан владельцу и клиенту сUrgency = Critical— доставка без дефера. - Given
reminder-due, у которогоStartAtуже в прошлом (простой диспетчера), when обработка, then уведомления не созданы —skipped-stale, ack, без DLQ. - Given повторная доставка любого события (тот же
MessageId) или replay топика, when обработка, then дублей уведомлений нет (skipped-duplicate/ dedup INV-NTF-1, для напоминаний —ReminderId). - Given сообщение с неизвестным
Origin, when обработка, then warning, уведомление не строится (fail-safe), сообщение не в DLQ. - Given payload без обязательного идентификатора, when повторная обработка не удалась, then
NTF_INVALID_PAYLOAD→ DLQ + алерт; события других агрегатов обрабатываются.
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/filtered(с причиной ветки) /skipped-stale/enqueued(count по каналам) /failedс полямиMessageId,EventType,Kind, затронутыеRecipientUserId. PII (адрес/тело) иComment/CancelReason— маскируются. - Metrics:
notifications_enqueue_duration_ms,notifications_enqueued_total{kind,channel},notifications_skipped_total{reason}(в т.ч.reason = stale-reminder),notifications_dlq_total,notifications_consumer_lag_seconds. - Traces: span приёма события → span обработки → spans обращений к хранилищу (уведомления + контактная реплика); correlation из метаданных события.
- Alerts: лаг консюмера > 5 мин (для
reminder-dueнапоминания time-critical — SLA источника < 1 мин, деградация видна поstale-reminder); DLQ > 0; всплескNTF_TEMPLATE_NOT_RESOLVED. - Безопасность: system-actor; уведомление несёт
TenantIdполучателя (null— инвестор); топикиcalendarдоступны по m2m ACL Kafka.
Обратные ссылки
Автоссылки: где используется этот документ.
- События (1): Event Contract: pin.notifications.dispatched
- index.md (1): Notifications Service
Связанные документы
- Целевой агрегат: notification.md (
Kind/payload/dedup/Urgency); настройки — user-notification-settings.md; шаблон — notification-template.md. - Входные контракты (дом payload и правил реакции): calendar/events/ — event-created, event-rescheduled, event-status-changed, event-owner-changed, appointment-created, appointment-confirmed, appointment-cancelled, appointment-completed, reminder-due; карта — README контекста.
- ADR: 0064 calendar-контекст, 0052 tenancy, 0056 simplicity-first.