UC-CAL-008. Напоминания о событиях календаря
Назначение
Описывает сквозной механизм напоминаний: из офсетов календарного события («за сутки», «за час»)
материализуются конкретные моменты срабатывания, фоновый диспетчер публикует наступившие напоминания, а
notifications доставляет их по каналам владельцу события и клиенту (если он есть). Напоминания живут
только жизненным циклом события: перенос пересоздаёт их, отмена/завершение — гасит. Связан с контекстом
calendar.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-CAL-008 |
| Контекст | calendar (см. docs/06-services/calendar/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | System (диспетчер напоминаний); получатели — владелец события (агент/консультант ПИН) и клиент-инвестор записи |
| Permissions / scope | system-scope worker-а (ADR-0052); собственных API-операций у напоминаний нет |
| Tenant | Через родительское событие; диспетчер работает поверх всех tenant-ов |
| Record-level | Офсеты напоминаний управляются владельцем события (calendar.events.manage) |
Предусловие
- Существует событие с непустыми
ReminderOffsetsMinutes(default{1440, 60}) — ручное (UC-CAL-001), от записи (UC-CAL-003) или дедлайн брони (UC-CAL-007). - У получателей настроены каналы доставки в
notifications(иначе — деградация доставки на сторонеnotifications, календарь не затронут).
Триггер
| Поле | Значение |
|---|---|
| Источник | Schedule |
| Триггерящий элемент | Worker ../background/reminder-dispatch.md — polling-цикл 60 с, построчная блокировка (ADR-0056) |
Основной Поток
- При создании события для каждого офсета из
ReminderOffsetsMinutesматериализуется строка напоминанияDueAt = StartAt − OffsetMinutes, статусPending; офсет с уже прошедшимDueAt— сразуSkipped(задним числом напоминания не отправляются) — правила материализации в event-reminder.md. - Диспетчер циклом (60 с) выбирает строки
PendingсDueAt < nowпод построчной блокировкой (два экземпляра worker-а не обработают одну строку дважды). - Каждую выбранную строку диспетчер переводит
Pending → Sent(SentAt = now) и атомарно с переходом публикуетpin.calendar.reminder-due.v1(ReminderId,EventId,OwnerUserId,ClientUserId?,Kind,StartAt,TimeZone,OffsetMinutes,AppointmentId?; PII нет). notificationsпотребляетreminder-due(../../notifications/consumers/calendar-events.md) и доставляет по каналам: kindAppointmentReminder— по событиям записей (получатели — агент и инвестор),CalendarReminder— по остальным событиям (получатель — владелец). Дедупликация на сторонеnotifications— поReminderId(доставка at-least-once).- Владелец (и клиент) получают напоминание с локальным временем события (по
TimeZone). Точность срабатывания — до ~1 цикла диспетчера (штатно ≤ 2 мин отDueAt).
Альтернативные Потоки
A1. Перенос события или смена офсетов
- При переносе события (UC-CAL-001, дедлайн —
UC-CAL-007) или изменении
ReminderOffsetsMinutesвсеPending-строки события удаляются и набор материализуется заново от актуальногоStartAt(правило 1 применяется заново). Ранее отправленныеSent-строки не трогаются — исторический факт.
A2. Отмена или завершение события
- Переход события в
Cancelled/Completedпереводит все егоPending-строки вSkippedв том же согласованном изменении — напоминания об отменённой встрече не уходят.
A3. Гонка «диспетчер ↔ отмена события»
- Арбитраж — построчная блокировка: если отмена успела первой, диспетчер видит
Skippedи пропускает; если первым успел диспетчер — напоминание уходит, следом приходит уведомление об отмене (допустимое окно, не ошибка).
A4. Падение и повтор диспетчера
- Переход
Pending → Sentи публикацияreminder-dueнеразделимы: «отправили, но не отметили» / «отметили, но не отправили» невозможно. Повторный проход видитSent— no-op; ровно одноreminder-dueна строку (INV-REM-2). Downstream-повторы доставки гасятся дедупликацией поReminderId.
A5. Отставание диспетчера
- Диспетчер долго не работал (сбой) — накопившиеся
Pendingс прошедшимDueAtотправляются при восстановлении одним прогоном (напоминание всё ещё полезно, если событие впереди); мониторинг ловит отставание (alert:PendingсDueAtстарше 5 мин). Строки событий, уже ставшихCancelled/Completedза время простоя, к этому моменту —Skippedи не отправляются.
A6. Событие без напоминаний
- Пустой
ReminderOffsetsMinutes— строки не материализуются, напоминаний нет; событие живёт штатно.
Постусловие
- Каждая наступившая
Pending-строка ровно один раз переведена вSentи породила ровно одноreminder-due; неактуальные строки —Skipped. - Для каждой
Pending-строкиDueAtсогласован с актуальнымStartAtсобытия (INV-REM-1). - Получатели уведомлены
notificationsв пределах точности механизма.
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
ReminderDueEvent.v1 | pin.calendar.reminder-due.v1 | Переход строки Pending → Sent (шаг 3); key = EventId | notifications (AppointmentReminder для событий записей, CalendarReminder для остальных) |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Событие за 3 дня до StartAt с офсетами {1440, 60} даёт две Pending-строки с DueAt = StartAt − 24 ч и StartAt − 1 ч | тест материализации |
| AC2 | Событие за 30 минут до StartAt с теми же офсетами даёт обе строки сразу Skipped, reminder-due не публикуется | тест прошедших офсетов |
| AC3 | Диспетчер переводит наступившую строку в Sent и публикует ровно одно reminder-due атомарно; повторный проход — no-op | тест диспетчеризации и идемпотентности (A4) |
| AC4 | Перенос события удаляет Pending-строки и пересоздаёт от нового StartAt; Sent-строки неизменны | тест переноса (A1) |
| AC5 | Отмена события переводит Pending → Skipped, напоминания не уходят | тест отмены (A2) |
| AC6 | Два параллельных экземпляра диспетчера не обрабатывают одну строку дважды (построчная блокировка) | тест конкуренции |
| AC7 | По reminder-due события записи notifications доставляет AppointmentReminder агенту и инвестору; по ручному событию — CalendarReminder владельцу | интеграционный тест доставки |
| AC8 | Точность срабатывания ≤ 2 мин от DueAt при штатном цикле 60 с | нагрузочный/интеграционный тест |
Наблюдаемость
- Логи (без PII).
reminder-sent(reminderId,eventId,tenantId,offsetMinutes,lagSeconds = now − DueAt);reminder-skippedс причиной. - Метрики.
calendar_reminders_sent_total,calendar_reminders_skipped_total{reason},calendar_reminder_dispatch_lag_seconds(p95; alert:PendingсDueAtстарше 5 мин), длительность цикла диспетчера.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (5): UC-CAL-001. Агент ведёт рабочий календарь, UC-CAL-004. Агент подтверждает или отклоняет запись, UC-CAL-007. Дедлайны брони CRM в календаре агента, UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя, US-реестр контекста calendar (US-CAL)
- scenarios (1): UC-OVR-003. Бронирование объекта у агента (+3a)
- index.md (1): Calendar Service
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Domain: event-reminder.md (правила материализации, INV-REM-1/2,
stateDiagram), calendar-event.md (
ReminderOffsetsMinutes, ограничения офсетов). - Worker:
../background/reminder-dispatch.md; событие:../events/reminder-due.md. - Доставка:
../../notifications/consumers/calendar-events.md, notification.md (kindsAppointmentReminder/CalendarReminder). - Паттерн polling + построчная блокировка: booking-expiration.md, ADR-0056.
- Связанные UC: UC-CAL-001 (офсеты ручных событий), UC-CAL-003 (напоминания о встречах), UC-CAL-007 (напоминания о дедлайнах).