Event Contract: pin.calendar.reminder-due (ReminderDueEvent)
Версионируемый контракт интеграционного события «Наступило напоминание» — материализованная строка
напоминания достигла DueAt и обработана диспетчером. Consumer-документы ссылаются сюда, не переописывая
payload. Дом полей — entity: EventReminderEntity; родительское событие —
calendar-event.md.
Назначение
Фиксирует момент «пора напомнить»: фоновый диспетчер (../background/reminder-dispatch.md, цикл 60 с)
перевёл строку напоминания Pending → Sent и опубликовал событие. Публичное: доставку по каналам (push,
email и др.) выполняет notifications — сам calendar каналов доставки не знает и получателей не
рассылает. Разделение ответственности: calendar отвечает за «когда и о чём напомнить», notifications —
за «как и кому доставить».
Metadata
| Поле | Значение |
|---|---|
| Event type | pin.calendar.reminder-due |
| Topic | pin.calendar.reminder-due.v1 |
| Partitions / key | 3 (ADR-0056) / key = EventId (все напоминания одного календарного события — в одной партиции, по порядку) |
| ContractVersion | 1 |
| Producer | calendar: диспетчер напоминаний ../background/reminder-dispatch.md (переход Pending → Sent, построчная блокировка) |
| Статус | draft |
Событие публикуется атомарно с переходом строки Pending → Sent (transactional outbox): пока переход
не зафиксирован, событие не отправляется; после фиксации — доставляется гарантированно (at-least-once),
ключ = EventId. Ровно одно событие на строку напоминания (INV-REM-2).
Payload ReminderDueEvent
Дом полей — event-reminder.md и
calendar-event.md; здесь перечислено только то, что реально в событии.
Контракт самодостаточен: несёт всё нужное для текста напоминания (вид, время, таймзона, офсет) —
notifications не запрашивает calendar синхронно. Title/Description/Location события в payload
не входят (см. «Безопасность»).
| Поле | Тип | Обязательность | Источник (поле сущности) | Compatibility | Описание |
|---|---|---|---|---|---|
ReminderId | Guid | Да | EventReminderEntity.Id | Immutable | Идентификатор строки напоминания — ключ дедупликации у notifications. |
EventId | Guid | Да | EventReminderEntity.CalendarEventId | Immutable | Родительское календарное событие (= Kafka key). |
TenantId | Guid | Да | CalendarEventEntity.TenantId | Immutable | Компания владельца календаря (ADR-0052). |
OwnerUserId | Guid | Да | CalendarEventEntity.OwnerUserId | Immutable | Владелец календаря — обязательный получатель напоминания. |
ClientUserId | Guid? | Нет | CalendarEventEntity.ClientUserId | Additive only | Клиент-инвестор — второй получатель, если у события есть клиент; display-данные — из реплики identity. |
Kind | string (enum EventKind) | Да | CalendarEventEntity.Kind | Additive only (новые значения) | Вид события: Showing / Consultation / Meeting / Task / BookingDeadline — определяет текст напоминания. |
StartAt | DateTimeOffset | Да | CalendarEventEntity.StartAt | Immutable (значение на момент публикации; при переносе строки пересоздаются) | Начало события, UTC. |
TimeZone | string | Да | CalendarEventEntity.TimeZone | Additive only | IANA-таймзона события — для локального времени в тексте напоминания. |
OffsetMinutes | int | Да | EventReminderEntity.OffsetMinutes | Immutable | Офсет «за N минут до начала» — для текста («через сутки», «через час»). |
AppointmentId | Guid? | Нет | CalendarEventEntity.AppointmentId | Immutable | Порождающая запись — заполнен для событий Origin = Appointment; по нему выбирается NotificationKind. |
Когда публикуется
| Условие | Переход состояния | Публикация |
|---|---|---|
Диспетчер выбрал строку Status = Pending с DueAt < now (построчная блокировка) | Pending → Sent (SentAt = now) | атомарно с переходом строки |
Не публикуется: для строк, переведённых в Skipped (событие отменено/завершено до DueAt; офсет в прошлом
на момент материализации), и для строк, удалённых пересозданием при переносе события
(event-reminder.md, правила материализации 1–3).
Потребители (consumers)
| Consumer-контекст | Side effects | Идемпотентность | Задержка (SLA) |
|---|---|---|---|
notifications | Доставка напоминания по каналам получателя: NotificationKind = AppointmentReminder (если AppointmentId задан — напоминание о встрече по записи, получатели — владелец и клиент) / CalendarReminder (остальные события, получатель — владелец; клиент ClientUserId добавляется, если задан). Текст — вид события + локальное время (StartAt + TimeZone) + горизонт (OffsetMinutes). Консюмер — ../../notifications/consumers/calendar-events.md | ReminderId (бизнес-ключ; дубликаты доставки не порождают второго напоминания) | < 1 мин (напоминание time-critical: суммарная точность — ~1 цикл диспетчера + доставка) |
Версионирование и совместимость
ContractVersion = 1; при несовпадении версии контракта потребитель логирует предупреждение и продолжает обработку. Топик кодирует версию по конвенции суффиксом.v1.- Additive-only для minor: новые поля добавляются как optional с безопасным default — без bump.
- Breaking (удаление/переименование/смена типа или смысла) —
ContractVersion = 2+ новый топикpin.calendar.reminder-due.v2+ dual-publish на период миграции + deadline миграции consumers + sunset.v1. - Unknown enum (
Kind) — fallback: сохранить строку как есть, warning; напоминание с нейтральным текстом «событие календаря скоро начнётся» (fail safe — напоминание важнее точной формулировки).
Безопасность
| Поле | Класс данных | Правило |
|---|---|---|
ReminderId, EventId, TenantId, OwnerUserId, ClientUserId, Kind, StartAt, TimeZone, OffsetMinutes, AppointmentId | Internal | include (только идентификаторы, время и enum) |
Title, Description, Location события | Internal / потенциально PII | exclude — в payload не входят по дизайну; текст напоминания строит notifications из Kind/времени |
- PII (ФИО/телефоны) в событии отсутствует по дизайну: display-данные получателей
notificationsберёт из своей репликиpin.identity.users.v1. - Доступ к топику — m2m ACL Kafka (
notifications).
Доставка, retry/DLQ и восстановление
- Гарантия. At-least-once после атомарной публикации из outbox; ключ =
EventId— напоминания одного события приходят по порядку. Ровно одно событие на строку напоминания (INV-REM-2); дубликаты возможны только на уровне доставки — консюмер дедуплицирует поReminderId. - Точность. Штатное опоздание — до ~1 цикла диспетчера (60 с) + окно доставки; SLA консюмера < 1 мин.
Просроченные при простое диспетчера строки публикуются при восстановлении (напоминание лучше поздно, чем
никогда — в пределах разумного: строки, чей
StartAtуже прошёл,notificationsвправе не доставлять, fail safe). - Гонка с отменой события. Если отмена события успела раньше диспетчера — строки
Skipped, событие не публикуется; если диспетчер успел первым — напоминание уходит, а следом придёт уведомление об отмене (event-status-changed / appointment-cancelled) — допустимое окно (event-reminder.md). - Retry. Транзиентный сбой консюмера → повтор с backoff; партиция не продвигается дальше застрявшего оффсета, порядок по ключу сохраняется.
- DLQ. Неразбираемое/несовместимое сообщение после конечного числа попыток — в DLQ с алертом; напоминания других событий не блокируются.
Acceptance-критерии (контракт потребления)
- Напоминание о встрече. Дано: событие
Origin = Appointment(AppointmentIdзадан), строка сOffsetMinutes = 60сработала. Когдаnotificationsобрабатывает событие. Тогда владельцу и клиенту уходитAppointmentReminderс локальным временем встречи. - Напоминание владельцу. Дано:
Taskбез клиента, строка сработала. Когдаnotificationsобрабатывает. Тогда только владельцу уходитCalendarReminder. - Дедупликация. Дано: сообщение с одним
ReminderIdдоставлено дважды. Тогда напоминание доставлено получателям ровно один раз. - Ровно одно на строку. Дано: событие с офсетами
{1440, 60}. Когда обе строки сработали. Тогда опубликовано ровно дваreminder-due(по одному на строку), получатели получили два напоминания с разным горизонтом. - Несовместимый payload. Дано: сообщение без обязательного поля. Когда консюмер повторно не смог его обработать. Тогда сообщение уходит в DLQ, остальные напоминания обрабатываются.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: NotificationEntity
- Use Cases (1): US-реестр контекста calendar (US-CAL)
- События (4): Event Contract: pin.calendar.event-created (CalendarEventCreatedEvent), Event Contract: pin.calendar.event-owner-changed (CalendarEventOwnerChangedEvent), Event Contract: pin.calendar.event-rescheduled (CalendarEventRescheduledEvent), Event Contract: pin.calendar.event-status-changed (CalendarEventStatusChangedEvent)
- Консюмеры (1): Consumer: Calendar Events → Notifications (notifications-calendar-events)
- registries (1): Реестр событий PIN
Связанные документы
- Сущность/дом полей: event-reminder.md (правила материализации, INV-REM-1/2,
гонки); родительское событие: calendar-event.md (офсеты
ReminderOffsetsMinutes); запись: appointment.md. - Producer:
../background/reminder-dispatch.md(паттерн polling + построчная блокировка — booking-expiration.md); consumer:../../notifications/consumers/calendar-events.md. - Соседние контракты семейства: event-created.md, event-rescheduled.md, event-status-changed.md.
- ADR: 0064 calendar-контекст п.5, 0056 simplicity-first, 0052 tenancy.