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 type | pin.calendar.appointment-created |
| Topic | pin.calendar.appointment-created.v1 |
| Partitions / key | 3 (ADR-0056) / key = AppointmentId (порядок событий одной записи во всех топиках pin.calendar.appointment-*) |
| ContractVersion | 1 |
| Producer | calendar: бронирование слота (../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 | Описание |
|---|---|---|---|---|---|
AppointmentId | Guid | Да | AppointmentEntity.Id | Immutable | Ключ агрегата (= Kafka key). |
TenantId | Guid | Да | AppointmentEntity.TenantId | Immutable | Компания агента (агентство / ПИН, ADR-0052). |
SlotId | Guid | Да | AppointmentEntity.SlotId | Immutable | Забронированный слот. |
AgentUserId | Guid | Да | AppointmentEntity.AgentUserId | Immutable | Агент/консультант — вторая сторона встречи. |
InvestorUserId | Guid | Да | AppointmentEntity.InvestorUserId | Immutable | Инвестор (identity; для анонима — созданный lazy-регистрацией пользователь); ключ get-or-create клиента/лида в CRM. |
Kind | string (enum EventKind) | Да | AppointmentEntity.Kind | Additive only (новые значения) | Вид встречи: Showing / Consultation (подмножество EventKind). |
StartAt | DateTimeOffset | Да | AppointmentEntity.StartAt | Immutable (снапшот слота) | Начало встречи, UTC. |
EndAt | DateTimeOffset | Да | AppointmentEntity.EndAt | Immutable (снапшот слота) | Конец встречи, UTC. |
TimeZone | string | Да | AppointmentEntity.TimeZone | Additive only | IANA-таймзона слота — для отображения локального времени. |
Status | string (enum AppointmentStatus) | Да | AppointmentEntity.Status | Additive only (новые значения) | Начальный статус: Pending (AutoConfirm = false) / Confirmed (AutoConfirm = true). |
Comment | string? | Нет | AppointmentEntity.Comment | Additive only | Комментарий инвестора при записи (≤ 1024); класс Internal, маскирование в логах — нужен ленте лида CRM. |
ProjectId | Guid? | Нет | AvailabilitySlotEntity.ProjectId (снапшот на момент бронирования) | Additive only | Проект застройщика (chessboard, логическая ссылка) — контекст показа. |
PropertyObjectId | Guid? | Нет | AvailabilitySlotEntity.PropertyObjectId (снапшот на момент бронирования) | Additive only | Объект недвижимости (chessboard, логическая ссылка). |
CalendarEventId | Guid | Да | AppointmentEntity.CalendarEventId | Immutable | Порождённое календарное событие (Origin = Appointment, связь 1:1). |
RescheduledFromAppointmentId | Guid? | Нет | AppointmentEntity.RescheduledFromAppointmentId | Immutable | Предыдущая запись — заполнен, если запись создана переносом (атомарная пара, 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) |
|---|---|---|---|
crm | Get-or-create клиента/лида по InvestorUserId (display-данные и телефон — из реплики identity) + запись в ленту лида о созданной встрече: вид, время, слот, Comment инвестора, привязка ProjectId/PropertyObjectId; при заполненном RescheduledFromAppointmentId — запись о переносе, а не новая заявка. Консюмер — ../../crm/consumers/calendar-appointments.md; лента — lead.md | MessageId (unique-ключ записи ленты) + business key AppointmentId | < 1 мин (лид должен появиться в CRM до реакции агента) |
notifications | NotificationKind = AppointmentCreated: агенту — «новая запись» (при Status = Pending — с призывом подтвердить до дедлайна); инвестору — подтверждение создания заявки на встречу. Консюмер — ../../notifications/consumers/calendar-events.md | MessageId | < 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, RescheduledFromAppointmentId | Internal | include (только идентификаторы и время) |
Comment | Internal (свободный текст инвестора) | 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, остальные записи обрабатываются.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: NotificationEntity
- Use Cases (1): US-реестр контекста calendar (US-CAL)
- События (5): Event Contract: pin.calendar.appointment-cancelled (AppointmentCancelledEvent), Event Contract: pin.calendar.appointment-completed (AppointmentCompletedEvent), Event Contract: pin.calendar.appointment-confirmed (AppointmentConfirmedEvent), Event Contract: pin.calendar.event-created (CalendarEventCreatedEvent), Event Contract: pin.calendar.event-rescheduled (CalendarEventRescheduledEvent)
- Консюмеры (2): Consumer: Calendar Events → Notifications (notifications-calendar-events), Consumer: CRM Calendar Appointments Sync (crm-calendar-appointments)
- scenarios (3): UC-OVR-001. Запрос консультации у агента недвижимости, UC-OVR-002. Запрос консультации у инвест-консультанта ПИН (+2a), UC-OVR-011. Самозапись инвестора: от слота до исхода встречи и лида в воронке
- registries (1): Реестр событий PIN
Связанные документы
- Сущность/дом полей: appointment.md (матрица переходов, каскады, INV-APT-1..6); слот: availability-slot.md; порождённое событие: calendar-event.md.
- Соседние контракты семейства: appointment-confirmed.md, appointment-cancelled.md, appointment-completed.md; параллельное событие календаря: event-created.md.
- CRM-сторона: consumer —
../../crm/consumers/calendar-appointments.md; лента лида — lead.md; no-slot путь воронки — create-consultation-request. - Producer-операции:
../api/public/book-slot.md,../api/investor/reschedule-my-appointment.md; consumer уведомлений:../../notifications/consumers/calendar-events.md. - ADR: 0064 calendar-контекст, 0052 tenancy, 0056 simplicity-first.