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

Event Contract: pin.calendar.appointment-created (AppointmentCreatedEvent)

Версионируемый контракт интеграционного события «Запись создана» — инвестор забронировал слот агента. Consumer-документы ссылаются сюда, не переописывая payload. Дом полей агрегата — entity: AppointmentEntity; слот — availability-slot.md.

Назначение

Фиксирует появление записи: слот перешёл Available → Booked, создана запись (Pending или Confirmed при auto-confirm) и календарное событие (Planned, Origin = Appointment) — всё в одном согласованном изменении. Публичное: crm делает get-or-create клиента/лида и пополняет ленту лида (в т.ч. комментарием инвестора Comment) без синхронного запроса в calendar; notifications уведомляет агента (при Pending — «подтвердите запись») и инвестора. Календарь чужие таблицы не пишет — интеграция с CRM только событийная (ADR-0064, п.4).

Metadata

ПолеЗначение
Event typepin.calendar.appointment-created
Topicpin.calendar.appointment-created.v1
Partitions / key3 (ADR-0056) / key = AppointmentId (порядок событий одной записи во всех топиках pin.calendar.appointment-*)
ContractVersion1
Producercalendar: бронирование слота (../api/public/book-slot.md — инвестор или аноним через lazy-регистрацию по телефону); вторая половина переноса (../api/investor/reschedule-my-appointment.md — новая запись атомарной пары)
Статусdraft

Событие публикуется атомарно со всей связкой «слот Available → Booked + запись + календарное событие Planned»: пока изменение не зафиксировано, событие не отправляется; после фиксации — доставляется гарантированно (at-least-once, transactional outbox), ключ = AppointmentId. Параллельно публикуется event-created (Origin = Appointment), а при auto-confirm — ещё и appointment-confirmed.

Payload AppointmentCreatedEvent

Дом полей — entity: AppointmentEntity (раздел «Доменные и интеграционные события»); здесь перечислено только то, что реально в событии. StartAt/EndAt/TimeZone/Kind — иммутабельный снапшот слота на момент бронирования (INV-APT-5).

ПолеТипОбязательностьИсточник (поле агрегата)CompatibilityОписание
AppointmentIdGuidДаAppointmentEntity.IdImmutableКлюч агрегата (= Kafka key).
TenantIdGuidДаAppointmentEntity.TenantIdImmutableКомпания агента (агентство / ПИН, ADR-0052).
SlotIdGuidДаAppointmentEntity.SlotIdImmutableЗабронированный слот.
AgentUserIdGuidДаAppointmentEntity.AgentUserIdImmutableАгент/консультант — вторая сторона встречи.
InvestorUserIdGuidДаAppointmentEntity.InvestorUserIdImmutableИнвестор (identity; для анонима — созданный lazy-регистрацией пользователь); ключ get-or-create клиента/лида в CRM.
Kindstring (enum EventKind)ДаAppointmentEntity.KindAdditive only (новые значения)Вид встречи: Showing / Consultation (подмножество EventKind).
StartAtDateTimeOffsetДаAppointmentEntity.StartAtImmutable (снапшот слота)Начало встречи, UTC.
EndAtDateTimeOffsetДаAppointmentEntity.EndAtImmutable (снапшот слота)Конец встречи, UTC.
TimeZonestringДаAppointmentEntity.TimeZoneAdditive onlyIANA-таймзона слота — для отображения локального времени.
Statusstring (enum AppointmentStatus)ДаAppointmentEntity.StatusAdditive only (новые значения)Начальный статус: Pending (AutoConfirm = false) / Confirmed (AutoConfirm = true).
Commentstring?НетAppointmentEntity.CommentAdditive onlyКомментарий инвестора при записи (≤ 1024); класс Internal, маскирование в логах — нужен ленте лида CRM.
ProjectIdGuid?НетAvailabilitySlotEntity.ProjectId (снапшот на момент бронирования)Additive onlyПроект застройщика (chessboard, логическая ссылка) — контекст показа.
PropertyObjectIdGuid?НетAvailabilitySlotEntity.PropertyObjectId (снапшот на момент бронирования)Additive onlyОбъект недвижимости (chessboard, логическая ссылка).
CalendarEventIdGuidДаAppointmentEntity.CalendarEventIdImmutableПорождённое календарное событие (Origin = Appointment, связь 1:1).
RescheduledFromAppointmentIdGuid?НетAppointmentEntity.RescheduledFromAppointmentIdImmutableПредыдущая запись — заполнен, если запись создана переносом (атомарная пара, INV-APT-5); консюмеры трактуют как продолжение, а не новую заявку.

Когда публикуется

УсловиеПереход состоянияПубликация
Инвестор/аноним забронировал слот с AutoConfirm = false[*] → Pending (слот Available → Booked, событие [*] → Planned)атомарно со всей связкой
Инвестор/аноним забронировал слот с AutoConfirm = true[*] → Confirmedатомарно со всей связкой; следом в своём топике — appointment-confirmed
Инвестор перенёс запись — создана новая запись атомарной пары (RescheduledFromAppointmentId заполнен)[*] → Pending/Confirmed (по AutoConfirm нового слота)атомарно с отменой старой записи (её топик — appointment-cancelled)

Дальнейший жизненный цикл записи публикуется отдельными контрактами семейства: appointment-confirmed.md, appointment-cancelled.md, appointment-completed.md.

Потребители (consumers)

Consumer-контекстSide effectsИдемпотентностьЗадержка (SLA)
crmGet-or-create клиента/лида по InvestorUserId (display-данные и телефон — из реплики identity) + запись в ленту лида о созданной встрече: вид, время, слот, Comment инвестора, привязка ProjectId/PropertyObjectId; при заполненном RescheduledFromAppointmentId — запись о переносе, а не новая заявка. Консюмер — ../../crm/consumers/calendar-appointments.md; лента — lead.mdMessageId (unique-ключ записи ленты) + business key AppointmentId< 1 мин (лид должен появиться в CRM до реакции агента)
notificationsNotificationKind = AppointmentCreated: агенту — «новая запись» (при Status = Pending — с призывом подтвердить до дедлайна); инвестору — подтверждение создания заявки на встречу. Консюмер — ../../notifications/consumers/calendar-events.mdMessageId< 5 мин

Версионирование и совместимость

  • ContractVersion = 1; при несовпадении версии контракта потребитель логирует предупреждение и продолжает обработку. Топик кодирует версию по конвенции суффиксом .v1.
  • Additive-only для minor: новые поля добавляются как optional с безопасным default — без bump.
  • Breaking (удаление/переименование/смена типа или смысла) — ContractVersion = 2 + новый топик pin.calendar.appointment-created.v2 + dual-publish на период миграции + deadline миграции consumers + sunset .v1.
  • Unknown enum (Kind/Status) — fallback: сохранить строку как есть, warning; get-or-create клиента/лида от значения enum не зависит (fail safe), текст уведомления — нейтральный.

Безопасность

ПолеКласс данныхПравило
AppointmentId, TenantId, SlotId, AgentUserId, InvestorUserId, Kind, StartAt, EndAt, TimeZone, Status, ProjectId, PropertyObjectId, CalendarEventId, RescheduledFromAppointmentIdInternalinclude (только идентификаторы и время)
CommentInternal (свободный текст инвестора)include — нужен ленте лида CRM; маскирование в логах producer/consumer, в публичную витрину не отдаётся
  • PII (ФИО/телефоны) в событии отсутствует по дизайну: display-данные агента и инвестора консюмеры берут из своей реплики pin.identity.users.v1; телефон анонима остаётся в identity после lazy-регистрации. Comment — единственное свободнотекстовое поле семейства pin.calendar.*: инвестор может написать в нём что угодно, поэтому поле обрабатывается как конфиденциальное (маскирование в логах, без индексации в поиске).
  • Доступ к топику — m2m ACL Kafka (crm, notifications).
  • Событие НЕ содержит secrets, tokens, raw credentials, вложений.

Доставка, retry/DLQ и восстановление

  • Гарантия. At-least-once после атомарной публикации из outbox; ключ = AppointmentId — порядок событий одной записи в топике сохранён. Дубликаты возможны (переотправка) — консюмеры обязаны быть идемпотентны.
  • Идемпотентность консюмеров. crm — по MessageId (unique-ключ записи ленты) + get-or-create по InvestorUserId (повторная доставка не создаёт второго клиента/лида и дубля в ленте); notifications — по MessageId.
  • Retry. Транзиентный сбой консюмера (недоступность своей БД/реплики identity) → повтор с backoff; партиция не продвигается дальше застрявшего оффсета, порядок по ключу сохраняется. Отсутствие пользователя в реплике identity на момент обработки — retriable (реплика догоняется событием pin.identity.users.v1).
  • DLQ. Неразбираемое/несовместимое сообщение (ошибка десериализации, отсутствуют обязательные поля) после конечного числа попыток уходит в DLQ с алертом; записи других инвесторов не блокируются.
  • Кросс-топик порядок. С appointment-confirmed (auto-confirm публикует оба события в одном изменении, но в разных топиках) межтопиковый порядок не гарантирован: каждый контракт самодостаточен — appointment-confirmed несёт все поля для get-or-create, поэтому обработка «confirmed раньше created» корректна (лента дополнится задним числом, warning не требуется).
  • Промежуточное состояние. Запись и событие в календаре агента видны сразу (данные calendar); лид/лента в CRM и уведомления — после обработки событий (обычно < 1 мин / < 5 мин). Это штатно: источник истины записи — calendar, CRM — downstream-проекция воронки.

Acceptance-критерии (контракт потребления)

  • crm — новый клиент. Дано: аноним забронировал слот (lazy-регистрация создала пользователя), клиента в CRM нет. Когда crm обрабатывает событие. Тогда созданы клиент и лид, в ленте лида — запись о встрече с Comment инвестора.
  • crm — существующий клиент. Дано: у инвестора уже есть клиент/лид у этого агентства. Когда crm обрабатывает событие. Тогда второй клиент/лид не создаётся, лента существующего лида пополняется.
  • crm — идемпотентность. Дано: событие доставлено дважды. Когда обрабатывается повторно. Тогда дублей клиента/лида/записи ленты нет (no-op).
  • crm — перенос. Дано: событие с заполненным RescheduledFromAppointmentId. Когда crm обрабатывает. Тогда в ленте — запись о переносе встречи, новая заявка не заводится.
  • notifications — Pending. Дано: запись создана со Status = Pending. Когда notifications обрабатывает. Тогда агенту уходит уведомление о новой записи с призывом подтвердить, инвестору — подтверждение заявки.
  • Несовместимый payload. Дано: сообщение без обязательного поля. Когда консюмер повторно не смог его обработать. Тогда сообщение уходит в DLQ, остальные записи обрабатываются.

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

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

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