Перейти к основному содержимому

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 groupnotifications-calendar-events
Обрабатываемые события9 топиков pin.calendar.*.v1 (таблица ниже)
Producercalendar (контракты — 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-createdOrigin = CrmBooking (Kind = BookingDeadline)CalendarReminderOwnerUserId — «дедлайн брони добавлен в календарь» (срок из StartAt + TimeZone)Normal
event-createdOrigin = Manual (собственное действие владельца) / Origin = Appointment (уведомляет appointment-created)— (no-op, ack)
event-rescheduledOrigin = ManualCalendarEventRescheduledClientUserId (если задан; без клиента — no-op: перенос — своё действие владельца)Normal
event-rescheduledOrigin = CrmBooking (дедлайн продлён)CalendarEventRescheduledOwnerUserIdNormal
event-status-changedOrigin = Manual, Status = Cancelled, ClientUserId заданAppointmentCancelledClientUserId — «встреча отменена» (+CancelReason)Normal
event-status-changedOrigin = CrmBooking, Status = CancelledAppointmentCancelledOwnerUserId — «дедлайн брони снят»Normal
event-status-changedпрочие комбинации: Origin = Appointment (уведомляют appointment-cancelled/appointment-completed); Status = InProgress/Completed; Cancelled без клиента— (no-op, ack)
event-owner-changedвсегда (Origin ∈ {CrmBooking, Manual})CalendarEventOwnerChangedOwnerUserId — «на вас переведено обязательство сделки» (срок из StartAt + TimeZone); PreviousOwnerUserId — «обязательство снято»Normal
appointment-createdвсегда (при RescheduledFromAppointmentId — текст о переносе, а не о новой заявке)AppointmentCreatedAgentUserId — «новая запись» (при Status = Pending — с призывом подтвердить до дедлайна); InvestorUserId — подтверждение создания заявкиNormal
appointment-confirmedвсегдаAppointmentConfirmedInvestorUserId; агенту не строится (ручное подтверждение — его действие; при AutoConfirmed = true агента уведомил appointment-created)Normal
appointment-cancelledRescheduledToAppointmentId пустAppointmentCancelledпо Reason: DeclinedByAgent/CancelledByAgentInvestorUserId; CancelledByInvestorAgentUserId; Expired → оба (инвестору — «агент не подтвердил запись», агенту — «запись просрочена»)Normal
appointment-cancelledRescheduledToAppointmentId задан (перенос)— (уведомление подавляется — участников уведомит appointment-created новой записи; перенос ≠ отмена)
appointment-completedвсегда— (no-op в v1: исход фиксирует сам агент, инвестору результат не рассылается; уведомления по исходу добавляются additive-но)
reminder-dueAppointmentId заданAppointmentReminderOwnerUserId и ClientUserId — напоминание о встрече по записиCritical
reminder-dueAppointmentId пустCalendarReminderOwnerUserId (+ 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 / ReminderIdGuidЗав. от топика→ id в payload уведомления + DeepLink + дедуп-контекст (ReminderId — бизнес-ключ напоминаний)
OwnerUserId / ClientUserId? / AgentUserId / InvestorUserIdGuidЗав. от топикамножество получателей fan-out (по таблице маппинга, после дедупликации)
Kind (EventKind) / Origin / Status / Reason / Outcomestring (enum)Зав. от топикавыбор ветки маппинга (NotificationKind / no-op) и текста шаблона
StartAt / EndAt? / TimeZone / OffsetMinutesDateTimeOffset / string / intЗав. от топикалокальное время и горизонт напоминания в тексте уведомления
DealId? / BookingId? / ProjectId? / PropertyObjectId?Guid?Нетконтекст в payload уведомления (DeepLink на бронь/объект)
TenantIdGuidДаконтекст события; tenant уведомления берётся из получателя
Comment (appointment-created)string?Нетв уведомление не включается (класс Internal, адресат — лента лида CRM); маскирование в логах

Алгоритм

  1. Контекст события. Восстановить метаданные (MessageId, агрегат-источник, время); сверить Version с ContractVersion = 1 (несовпадение — warn-and-proceed); actor = system. Проверить consumer-идемпотентность по ключу notifications-calendar:{EventType}:{MessageId}: событие уже обработано → лог skipped-duplicate, подтвердить (ack, no-op).
  2. Проверка входа. Обязательные идентификаторы непусты (Guid.Empty запрещён); обязательные для топика поля присутствуют. Нарушение контракта → NTF_INVALID_PAYLOAD (non-retriable → DLQ + алерт).
  3. Ветка маппинга. По таблице выше определить NotificationKind и получателей. Ветка no-op (Origin = Appointment/Manual у event-*, appointment-completed, подавленная отмена при переносе, отсутствие получателя) → лог filtered с причиной, зафиксировать отметку идемпотентности, ack. Неизвестное значение Origin/Reason/Kind — fail-safe по правилу контракта: warning + нейтральная реакция (для неизвестного Origin уведомление не строится — лучше пропустить, чем продублировать).
  4. Stale-guard напоминаний. Для reminder-due: если StartAt уже в прошлом на момент обработки (просрочка при простое диспетчера/лаге), напоминание не доставляется — лог skipped-stale, отметка идемпотентности, ack (fail-safe контракта reminder-due).
  5. Резолв получателей и доставки. Собрать множество получателей по таблице, дедуплицировать (владелец = агент для событий записей — не уведомлять дважды). По каждому получателю:
    • определить каналы для Kind/Urgency по настройкам получателя (INV-UNS-3/4: дефолты Kind + явные opt-in/out; GlobalMute и правило Critical);
    • подобрать шаблон (Kind, Locale, Channel) (INV-TPL-5; fallback локали ru; нет тела канала — канал пропускается); нет Active-шаблона под KindNTF_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) доставляются сразу.
  6. Материализация. Для каждой валидной пары (получатель, канал) создаётся NotificationEntity в статусе Pending с отрисованными Title/Body/DeepLink; дубль по ключу (SourceMessageId, RecipientUserId, Channel, Kind) (INV-NTF-1) пропускается (идемпотентно). In-app уведомление после фиксации доставляется realtime.
  7. Согласованность. Все созданные Notification(Pending) и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации. Отправку выполняет dispatcher (persist-then-send); финальный исход эмитится как pin.notifications.dispatched.v1 dispatcher-ом, не этим consumer-ом.
  8. Результат. Успех (в т.ч. 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

СбойКодRetryDLQРучное действие
Инфраструктурный (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 обработка, then AppointmentReminder создан владельцу и клиенту с 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.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы