Entity: EventReminderEntity
Дом полей сущности «Напоминание о событии» контекста calendar — материализованная строка «напомнить за N
минут до начала события», порождаемая из офсетов ReminderOffsetsMinutes календарного события
(calendar-event.md). Материализованные строки + polling-диспетчер — решение
ADR-0064 п.5 (без Redis/TTL,
ADR-0056).
Назначение
EventReminderEntity фиксирует конкретный момент DueAt, в который владельцу события (и клиенту, если
он есть) должно уйти напоминание. Наступившие строки выбирает фоновый диспетчер
(../background/reminder-dispatch.md, цикл 60 с, построчная блокировка) и публикует
pin.calendar.reminder-due.v1; доставку по каналам выполняет контекст notifications. Собственных
API-операций у напоминаний нет — они управляются только жизненным циклом родительского события.
Классификация
| Поле | Значение |
|---|---|
| Контекст | calendar |
| Kind | Entity (owned — часть агрегата CalendarEventEntity) |
| Source of truth | calendar |
| Таблица | calendar.event_reminders |
| Tenant isolation | через родительское событие (CalendarEventId); диспетчер работает в system-scope (ADR-0052) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор строки напоминания. |
CalendarEventId | Guid | Да | FK fk_event_reminders_event_id → calendar.calendar_events(id), cascade; unique partial ux_event_reminders_pending (CalendarEventId, OffsetMinutes) where status = 'Pending' | app | Родительское событие. |
OffsetMinutes | int | Да | > 0 | из ReminderOffsetsMinutes события | Офсет «за N минут до StartAt». |
DueAt | DateTimeOffset | Да | index ix_event_reminders_status_due_at (Status, DueAt) | app: StartAt − OffsetMinutes | Момент срабатывания, UTC. |
Status | ReminderStatus | Да | enum (хранится как text), varchar(32) | app / worker | См. enum и переходы. |
SentAt | DateTimeOffset? | Нет | — | worker | Фактический момент отправки (→ Sent). |
CreatedAt | DateTimeOffset | Да | — | app | Момент материализации строки. |
UpdatedAt | DateTimeOffset | Да | — | app / worker | Последнее изменение. |
Enum ReminderStatus
| Значение | Описание |
|---|---|
Pending = 1 | Ожидает срабатывания (DueAt в будущем на момент материализации). |
Sent = 2 | Диспетчер обработал строку и опубликовал pin.calendar.reminder-due.v1. Терминальный. |
Skipped = 3 | Напоминание не актуально: событие отменено/завершено до DueAt, либо DueAt уже в прошлом на момент материализации. Терминальный. |
Правила материализации
- Создание события. Для каждого офсета из
ReminderOffsetsMinutesсоздаётся ровно одна строка:DueAt = StartAt − OffsetMinutes. ЕслиDueAt ≤ now— строка создаётся сразу вSkipped(прошедшие напоминания не отправляются задним числом). Пустой массив офсетов — строк нет. - Перенос события (reschedule) и изменение офсетов. Все
Pending-строки события удаляются, новый набор материализуется от новогоStartAtпо актуальным офсетам (правило 1 применяется заново, включаяSkippedдля уже прошедшихDueAt).Sent/Skipped-строки не трогаются — это исторический факт. - Отмена или завершение события (
Status → Cancelled | Completed). ВсеPending-строки события переводятся вSkippedв том же согласованном изменении. - Диспетчеризация. Worker
../background/reminder-dispatch.mdциклом 60 с выбирает строкиStatus = Pending AND DueAt < nowпод построчной блокировкой, переводит каждую вSent(SentAt = now) и атомарно публикуетpin.calendar.reminder-due.v1. Опоздание доставки до ~1 цикла — штатная точность механизма.
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-REM-1 | У события не более одной Pending-строки на офсет (unique partial ux_event_reminders_pending); для каждой Pending-строки DueAt = StartAt − OffsetMinutes согласован с актуальным StartAt события — согласованность обеспечивается пересозданием при переносе (правило 2) | материализация, перенос события | — (структурная гарантия) |
| INV-REM-2 | Терминальные Sent/Skipped иммутабельны. Sent выставляет только диспетчер, однократно — ровно одно reminder-due на строку; Skipped выставляется при отмене/завершении события и при материализации с DueAt в прошлом | диспетчеризация, каскады события | — (структурная гарантия) |
Идемпотентность и отказоустойчивость
- Идемпотентность диспетчера. Переход
Pending → Sentвыполняется под построчной блокировкой в одном согласованном изменении с публикацией события; повторный проход worker-а видитSentи не делает ничего. Два экземпляра worker-а не обработают одну строку дважды (построчная блокировка с пропуском занятых строк). - Падение между переходом и доставкой. Публикация
reminder-dueатомарна со сменой статуса (transactional outbox); downstream-доставка — at-least-once, дедупликация на сторонеnotificationsпоReminderId. - Гонка «диспетчер ↔ отмена события». Арбитраж — построчная блокировка: если отмена успела первой,
диспетчер видит
Skippedи пропускает; если первым успел диспетчер — напоминание уходит, событие отменяется следом (клиент получит и напоминание, и уведомление об отмене — допустимое окно).
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
ReminderDueEvent | интеграционное, topic pin.calendar.reminder-due.v1, ContractVersion = 1, key = EventId | Pending → Sent (диспетчер) | ReminderId, EventId, TenantId, OwnerUserId, ClientUserId?, Kind, StartAt, TimeZone, OffsetMinutes, AppointmentId?; консюмер: notifications (AppointmentReminder для событий записи, CalendarReminder для остальных); контракт — ../events/reminder-due.md; PII в payload нет |
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ix_event_reminders_status_due_at (Status, DueAt) — выборка диспетчера Pending + DueAt < now; unique partial ux_event_reminders_pending (CalendarEventId, OffsetMinutes) where status = 'Pending' |
| Партиционирование / retention | none (ADR-0056); терминальные строки старше 180 дней допустимо удалять фоновой уборкой вместе с архивом событий |
| Concurrency | построчная блокировка диспетчера; вне worker-а строки меняются только каскадами родительского события |
| Permissions / PII | собственных API-операций нет; строки видны опосредованно (офсеты — в модели события). PII не хранится |
| Audit | CreatedAt/UpdatedAt/SentAt; факт отправки — событие reminder-due |
Acceptance-критерии
- Материализация по default. Дано: событие создаётся за 3 дня до
StartAtс офсетами{1440, 60}. Тогда созданы двеPending-строки сDueAt = StartAt − 24 чиStartAt − 1 ч. - Прошедший офсет. Дано: событие создаётся за 30 минут до
StartAtс офсетами{1440, 60}. Тогда обе строки созданы сразу вSkipped, ни одноreminder-dueне публикуется. - Срабатывание. Дано:
Pending-строка сDueAt < now. Когда проходит диспетчер. Тогда строкаSent(SentAtзаполнен), опубликовано ровно одноpin.calendar.reminder-due.v1. - Пересоздание при переносе. Дано: событие с двумя
Pending-строками переносится на неделю позже. Тогда старыеPending-строки удалены, созданы новые сDueAtот новогоStartAt; ранее отправленныеSent-строки не изменились. - Отмена события. Дано: событие с
Pending-строками отменяется. Тогда всеPending→Skipped, напоминания не отправляются. - Идемпотентность. Дано: строка уже
Sent. Когда диспетчер проходит повторно. Тогда статус и число опубликованных событий не меняются.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: AppointmentEntity, Entity / Aggregate: CalendarEventEntity
- API (14): GET /api/v1/agent/calendar/events — Список событий календаря агента, GET /api/v1/agent/calendar/events/{id} — Карточка события календаря, POST /api/public/calendar/appointments — Самозапись на слот (бронирование), POST /api/v1/agent/calendar/appointments/{appointmentId}/cancel — Отмена записи агентом, POST /api/v1/agent/calendar/appointments/{appointmentId}/complete — Фиксация исхода встречи, POST /api/v1/agent/calendar/appointments/{appointmentId}/decline — Отклонение записи, POST /api/v1/agent/calendar/events — Создание события календаря, POST /api/v1/agent/calendar/events/{id}/cancel — Отмена события (→ Cancelled), POST /api/v1/agent/calendar/events/{id}/complete — Завершение события (→ Completed), POST /api/v1/agent/calendar/events/{id}/reschedule — Перенос события календаря, POST /api/v1/agent/calendar/events/{id}/start — Начало события (Planned → InProgress), POST /api/v1/investor/calendar/appointments/{appointmentId}/cancel — Отмена моей записи (+2)
- Use Cases (8): UC-CAL-001. Агент ведёт рабочий календарь, UC-CAL-003. Инвестор бронирует слот с витрины, UC-CAL-004. Агент подтверждает или отклоняет запись, UC-CAL-005. Отмена и перенос записи, UC-CAL-007. Дедлайны брони CRM в календаре агента, UC-CAL-008. Напоминания о событиях календаря, UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя, US-реестр контекста calendar (US-CAL)
- События (3): Event Contract: pin.calendar.event-rescheduled (CalendarEventRescheduledEvent), Event Contract: pin.calendar.event-status-changed (CalendarEventStatusChangedEvent), Event Contract: pin.calendar.reminder-due (ReminderDueEvent)
- Консюмеры (2): Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events), Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events)
- Фоновые задачи (2): Background Job: AppointmentTimeoutJob, Background Job: ReminderDispatchJob
- index.md (1): Calendar Service
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- calendar-event.md — родительский агрегат, офсеты
ReminderOffsetsMinutes. - Диспетчер:
../background/reminder-dispatch.md; контракт события:../events/reminder-due.md. - Паттерн polling + построчная блокировка: booking-expiration.md, ADR-0056.