Entity / Aggregate: CalendarEventEntity
Дом полей агрегата «Календарное событие» контекста calendar — единая плоская модель всех видов событий
рабочего календаря агента (показ, консультация, встреча, задача, дедлайн брони). Вид события — enum Kind,
а не тип-наследник (ADR-0064, п.2:
TPH-иерархия черновика отброшена). Все проекции (модели ответов календаря, payload событий
pin.calendar.event-*) ссылаются сюда и описывают только дельту.
Назначение
CalendarEventEntity — элемент рабочего календаря владельца (агента, консультанта ПИН): что, когда и с кем
происходит. Событие появляется тремя путями (Origin): агент создаёт его вручную; запись инвестора на слот
порождает событие автоматически (appointment.md); booking-события CRM порождают
автособытия «дедлайн брони» (consumer ../consumers/crm-booking-events.md). Из офсетов события
материализуются напоминания (event-reminder.md), которые доставляет контекст
notifications.
Классификация
| Поле | Значение |
|---|---|
| Контекст | calendar |
| Kind | AggregateRoot |
| Source of truth | calendar |
| Таблица | calendar.calendar_events |
| Tenant isolation | TenantId — компания владельца календаря (агентство агента / компания ПИН для консультанта), ADR-0052 |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK; Guid v7 (упорядочен по времени) | app | Идентификатор события. |
TenantId | Guid | Да | index ix_calendar_events_tenant_id | из владельца календаря | Компания владельца (агентство / ПИН). |
OwnerUserId | Guid | Да | index ix_calendar_events_owner_start (OwnerUserId, StartAt) | app / запись / consumer | Владелец календаря — агент или консультант ПИН. Может переназначаться консюмером deal-событий CRM при смене исполнителя / уходе агента сделки (INV-CAL-8, ../consumers/crm-deal-events.md). |
CreatedByUserId | Guid? | Нет | — | app | Кто создал вручную; null для событий, порождённых записью или consumer-ом. |
Kind | EventKind | Да | enum (хранится как text), varchar(32) | app / запись / consumer | Вид события, см. enum ниже. |
Origin | EventOrigin | Да | enum (хранится как text), varchar(32); иммутабелен | app / запись / consumer | Происхождение — определяет, кто управляет жизненным циклом (INV-CAL-3/4). |
Status | EventStatus | Да | enum (хранится как text), varchar(32), index ix_calendar_events_status | app / запись / consumer | Текущий статус, см. stateDiagram. |
Priority | EventPriority | Да | enum (хранится как text), varchar(32), default Medium | app | Приоритет для сортировки/подсветки в календаре. |
StartAt | DateTimeOffset | Да | index (в составе ix_calendar_events_owner_start) | app / слот / consumer | Начало, UTC (ADR-0064 п.6). |
EndAt | DateTimeOffset? | Условно | check EndAt > StartAt (если задан) | app / слот / consumer | Конец, UTC. null = точечное событие — допустимо только для Task/BookingDeadline (матрица kind-обязательностей). |
TimeZone | string | Да | varchar(64), IANA (например Europe/Moscow) | app / слот / consumer | Таймзона, в которой событие имеет смысл; отображение локального времени — забота клиента. |
Title | string? | Нет | varchar(256) | app / запись / consumer | Заголовок. Для Origin ≠ Manual формируется источником автоматически; при отсутствии клиент отображает производный заголовок по Kind. |
Description | string? | Нет | varchar(8192) | app | Описание/заметки владельца. |
Location | string? | Нет | varchar(512) | app / слот | Место встречи — свободный текст (без привязки к справочнику городов, ADR-0064 п.7). |
MeetingLink | string? | Условно | varchar(256), абсолютный URL | app | Ссылка на онлайн-встречу; допустима только для Consultation/Meeting (матрица). |
ClientUserId | Guid? | Нет | index partial where not null | app / запись | Клиент-инвестор (пользователь identity, вне tenant). Для Origin = Appointment заполняется записью. |
AppointmentId | Guid? | Условно | unique partial ux_calendar_events_appointment_id where not null | запись | Порождающая запись (appointment.md); обязателен и заполняется только при Origin = Appointment (связь 1:1). |
DealId | Guid? | Условно | index partial ix_calendar_events_deal_id where not null | app / consumer | Сделка CRM (логическая ссылка); обязателен для BookingDeadline. По индексу консюмер deal-событий находит открытые события сделки для переназначения владельца (INV-CAL-8). |
LeadId | Guid? | Нет | — | app / consumer | Лид CRM (логическая ссылка). |
BookingId | Guid? | Условно | index partial ix_calendar_events_booking_id where not null | consumer | Бронь CRM (booking.md); обязателен для BookingDeadline. |
PropertyObjectId | Guid? | Нет | — | app / слот / consumer | Объект недвижимости (chessboard, логическая ссылка). |
ProjectId | Guid? | Нет | — | app / слот | Проект застройщика (chessboard, логическая ссылка). |
ReminderOffsetsMinutes | int[] | Да | default {1440, 60}; ≤ 5 элементов; каждый в диапазоне 5..40320; значения уникальны; пустой массив = без напоминаний | app | Офсеты напоминаний «за N минут до StartAt» — источник материализации reminder-строк (event-reminder.md). |
CancelReason | string? | Условно | varchar(512); обязателен при Status = Cancelled (check) | app / запись / consumer | Причина отмены. |
CreatedAt | DateTimeOffset | Да | — | app | Создание. |
UpdatedAt | DateTimeOffset | Да | — | app | Последнее изменение. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший (null — worker/consumer). |
Version | int | Да | оптимистическая блокировка | app | Версия строки; гонка «агент ↔ worker/consumer» разрешается перечиткой проигравшим. |
Enum EventKind
| Значение | Описание |
|---|---|
Showing = 1 | Показ объекта недвижимости клиенту. |
Consultation = 2 | Консультация клиента (очная или онлайн). |
Meeting = 3 | Прочая встреча (внутренняя, с застройщиком и т.п.). |
Task = 4 | Задача владельца календаря (позвонить, подготовить документы). |
BookingDeadline = 5 | Дедлайн резерва/брони из CRM — автособытие, порождается consumer-ом booking-событий. |
Enum EventOrigin
| Значение | Описание |
|---|---|
Manual = 1 | Создано владельцем календаря вручную; полный ручной жизненный цикл. |
Appointment = 2 | Порождено записью инвестора на слот; управляется только каскадами переходов записи (INV-CAL-3). |
CrmBooking = 3 | Порождено booking-событием CRM; управляется только consumer-ом ../consumers/crm-booking-events.md (INV-CAL-4). |
Enum EventStatus
| Значение | Описание |
|---|---|
Planned = 1 | Запланировано (начальный статус). |
InProgress = 2 | В работе — владелец начал событие. Именно InProgress, а не Cancelled: баг черновика «start → Cancelled» исправлен (ADR-0064 п.2). |
Completed = 3 | Завершено. Терминальный. |
Cancelled = 4 | Отменено (CancelReason обязателен). Терминальный. |
Enum EventPriority
| Значение | Описание |
|---|---|
Low = 1 | Низкий. |
Medium = 2 | Обычный (default). |
High = 3 | Высокий. |
Critical = 4 | Критический (например, дедлайн брони с оплатой). |
Матрица kind-условных обязательностей
Проверяется при создании и каждом изменении события (нарушение → CAL_EVENT_KIND_FIELDS_MISMATCH).
Kind | EndAt | MeetingLink | BookingId + DealId | Примечание |
|---|---|---|---|---|
Showing | обязателен | запрещён | опциональны | обычно заполнены PropertyObjectId/ProjectId и ClientUserId. |
Consultation | обязателен | опционален | опциональны | онлайн-консультация — с MeetingLink. |
Meeting | обязателен | опционален | опциональны | — |
Task | опционален (null = точечная задача) | запрещён | опциональны | — |
BookingDeadline | опционален (обычно null — точечный дедлайн) | запрещён | обязательны оба | создаёт только consumer (Origin = CrmBooking); StartAt = ReserveExpiresAt/BookingExpiresAt брони. |
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
EventReminderEntity | owned (часть агрегата) | CalendarEventId | cascade (жизненный цикл — правила материализации, event-reminder.md) |
AppointmentEntity | reference 1:1 (при Origin = Appointment) | AppointmentId | restrict |
Бронь CRM (crm) | внешняя логическая | BookingId | — (реплики нет; consumer правит событие по booking-событиям) |
Сделка / лид CRM (crm) | внешняя логическая | DealId / LeadId | — |
Объект / проект (chessboard) | внешняя логическая | PropertyObjectId / ProjectId | — |
Инварианты и переходы состояний
| Инвариант | Условие | Когда проверяется | Ошибка |
|---|---|---|---|
| INV-CAL-1 | Интервал корректен: EndAt > StartAt, если EndAt задан; EndAt = null (точечное событие) допустим только для Kind ∈ {Task, BookingDeadline} | создание, изменение, перенос | CAL_EVENT_INVALID_INTERVAL |
| INV-CAL-2 | Переходы статуса — только по матрице stateDiagram; start переводит в InProgress (фикс бага черновика, ADR-0064 п.2) | start/complete/cancel-операции, каскады | CAL_EVENT_INVALID_STATE |
| INV-CAL-3 | Событие Origin = Appointment изменяется/переносится/отменяется только каскадами переходов записи (таблица каскадов — appointment.md); прямые операции агента над таким событием отклоняются | все операции изменения события | CAL_EVENT_MANAGED_BY_APPOINTMENT |
| INV-CAL-4 | Событие Origin = CrmBooking изменяется только consumer-ами CRM: booking-событий (перенос дедлайна при продлении, отмена при завершении/отмене брони) и deal-событий (переназначение OwnerUserId при смене исполнителя, INV-CAL-8); прямые операции агента отклоняются | все операции изменения события | CAL_EVENT_MANAGED_BY_CRM_BOOKING |
| INV-CAL-5 | Kind-условные обязательности полей соблюдены (матрица выше): BookingDeadline ⇒ BookingId + DealId; MeetingLink — только Consultation/Meeting | создание, изменение | CAL_EVENT_KIND_FIELDS_MISMATCH |
| INV-CAL-6 | Пересечение интервалов событий одного OwnerUserId — soft-conflict: создание/перенос не блокируется, но выборки календаря возвращают признак HasConflicts = true по каждому пересекающемуся событию (Planned/InProgress) | выборки календаря (../api/agent/list-events.md) | — (не ошибка) |
| INV-CAL-7 | Терминальные статусы (Completed, Cancelled) иммутабельны; при переходе в Cancelled обязателен CancelReason | все операции изменения события | CAL_EVENT_ALREADY_TERMINAL / CAL_EVENT_CANCEL_REASON_REQUIRED |
| INV-CAL-8 | Санкционированное переназначение владельца. Консюмер deal-событий CRM (../consumers/crm-deal-events.md) переназначает OwnerUserId открытых (Planned/InProgress) deal-привязанных событий (Kind = BookingDeadline + Origin ∈ {CrmBooking, Manual} встречи/задачи с DealId) на нового исполнителя при смене исполнителя (deal-members-changed ExecutorChanged) или уходе агента (Removed/увольнение). Меняется только OwnerUserId (в пределах того же TenantId = AgencyCompanyId); Origin = Appointment не переназначается (INV-CAL-3); публикуется pin.calendar.event-owner-changed.v1 | переназначение консюмером deal-событий | — (санкционированный автоматический канал) |
Перенос (reschedule) — не переход статуса: StartAt/EndAt меняются у события в Planned/InProgress
с сохранением длительности (контракт — ../api/agent/reschedule-event.md), напоминания пересоздаются
(event-reminder.md), публикуется pin.calendar.event-rescheduled.v1.
Ошибки
| Код | HTTP | Условие |
|---|---|---|
CAL_EVENT_NOT_FOUND | 404 | Событие не найдено или недоступно в tenant актора. |
CAL_EVENT_ACCESS_DENIED | 403 | Актор — не владелец события и не имеет calendar.team.view (read-only) в своём tenant. |
CAL_EVENT_INVALID_INTERVAL | 400 | EndAt ≤ StartAt, либо EndAt отсутствует у интервального Kind (INV-CAL-1). |
CAL_EVENT_KIND_FIELDS_MISMATCH | 400 | Нарушена матрица kind-обязательностей (INV-CAL-5). |
CAL_EVENT_TIMEZONE_UNKNOWN | 400 | TimeZone — не валидный IANA-идентификатор. |
CAL_EVENT_REMINDER_OFFSETS_INVALID | 400 | Офсеты напоминаний вне ограничений (количество/диапазон/дубли). |
CAL_EVENT_CANCEL_REASON_REQUIRED | 400 | Отмена без CancelReason (INV-CAL-7). |
CAL_EVENT_INVALID_STATE | 409 | Переход вне матрицы статусов (INV-CAL-2). |
CAL_EVENT_ALREADY_TERMINAL | 409 | Операция над Completed/Cancelled событием (INV-CAL-7). |
CAL_EVENT_MANAGED_BY_APPOINTMENT | 409 | Прямая операция над событием Origin = Appointment (INV-CAL-3); управлять нужно записью. |
CAL_EVENT_MANAGED_BY_CRM_BOOKING | 409 | Прямая операция над событием Origin = CrmBooking (INV-CAL-4). |
Доменные и интеграционные события
Payload всех событий pin.calendar.* — без PII (только идентификаторы, вид/статус/время); ключ
партиционирования — EventId (порядок событий одного календарного события гарантирован).
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
CalendarEventCreatedEvent | интеграционное, topic pin.calendar.event-created.v1, ContractVersion = 1 | создание события (любой Origin) | EventId, TenantId, OwnerUserId, Kind, Origin, Priority, StartAt, EndAt?, TimeZone, ClientUserId?, AppointmentId?, DealId?, BookingId?; консюмер: notifications; контракт — ../events/event-created.md |
CalendarEventRescheduledEvent | интеграционное, topic pin.calendar.event-rescheduled.v1, ContractVersion = 1 | перенос (StartAt/EndAt изменены) | EventId, TenantId, OwnerUserId, Kind, StartAt, EndAt? (новые значения); консюмер: notifications (CalendarEventRescheduled); контракт — ../events/event-rescheduled.md |
CalendarEventStatusChangedEvent | интеграционное, topic pin.calendar.event-status-changed.v1, ContractVersion = 1 | каждый переход статуса | EventId, TenantId, OwnerUserId, Kind, Origin, Status (новый), CancelReason?, ClientUserId?, StartAt, TimeZone; консюмер: notifications; контракт — ../events/event-status-changed.md |
CalendarEventOwnerChangedEvent | интеграционное, topic pin.calendar.event-owner-changed.v1, ContractVersion = 1 | переназначение владельца открытого события консюмером deal-событий CRM (INV-CAL-8) | EventId, TenantId, OwnerUserId (новый), PreviousOwnerUserId, Kind, Origin, StartAt, EndAt?, TimeZone, DealId?; консюмер: notifications; контракт — ../events/event-owner-changed.md |
Интеграционные события публикуются атомарно со сменой состояния агрегата. Напоминания публикуют собственное
событие pin.calendar.reminder-due.v1 — см. event-reminder.md.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK; ix_calendar_events_tenant_id; ix_calendar_events_owner_start (OwnerUserId, StartAt) — range-выборки календаря; ix_calendar_events_status; unique partial ux_calendar_events_appointment_id (AppointmentId) where not null — 1:1 с записью; partial ix_calendar_events_booking_id (BookingId) where not null — выборки consumer-а booking-событий; partial ix_calendar_events_deal_id (DealId) where not null — переназначение владельца открытых событий сделки consumer-ом deal-событий (INV-CAL-8) |
| Партиционирование / retention | none (ADR-0056) |
| Concurrency | оптимистическая блокировка (Version): гонка «владелец ↔ worker/consumer» — проигравший перечитывает состояние |
| Permissions / PII | calendar.events.view (владелец), calendar.events.manage (владелец — создание/изменение/статусы), calendar.team.view (руководитель агентства — read-only календарь команды в своём tenant, ../api/agency/list-team-events.md). Description/Location/Title могут содержать персональные данные клиента → в интеграционные события не попадают, в логах маскируются; ClientUserId — только идентификатор, display-данные — из реплики identity |
| Audit | отметки создания/изменения (кто и когда); Origin/CreatedByUserId — иммутабельный след происхождения; история статусов — через pin.calendar.event-status-changed.v1 |
Acceptance-критерии
- Создание вручную. Дано: агент с
calendar.events.manage. Когда создаётConsultationс корректным интервалом иMeetingLink. Тогда событиеPlanned(Origin = Manual), материализованы reminder-строки по офсетам, опубликованpin.calendar.event-created.v1. - Start → InProgress. Дано: событие
Planned. Когда владелец начинает событие. Тогда статусInProgress(неCancelled), опубликованpin.calendar.event-status-changed.v1. - Точечное событие не для Task. Дано: создание
ShowingбезEndAt. Тогда отказCAL_EVENT_INVALID_INTERVAL. - MeetingLink у показа. Дано: создание
ShowingсMeetingLink. Тогда отказCAL_EVENT_KIND_FIELDS_MISMATCH. - Событие записи защищено. Дано: событие
Origin = Appointment. Когда агент вызывает прямой cancel-event. Тогда отказCAL_EVENT_MANAGED_BY_APPOINTMENT; отмена возможна только операциями записи. - Дедлайн брони защищён. Дано: событие
Origin = CrmBooking. Когда агент редактирует его. Тогда отказCAL_EVENT_MANAGED_BY_CRM_BOOKING; событие меняет только consumer booking-/deal-событий. - Переназначение владельца при смене исполнителя. Дано: открытый
BookingDeadlineи открытая встреча сделки у агентаA. Когда consumer deal-событий обрабатывает смену исполнителя наB(INV-CAL-8). ТогдаOwnerUserIdобоих событий =B,TenantIdне изменён, по каждому опубликованpin.calendar.event-owner-changed.v1; событиеOrigin = Appointmentпри этом остаётся уA. - Soft-conflict. Дано: два
Planned-события владельца пересекаются. Когда агент запрашивает календарь. Тогда оба события возвращаются сHasConflicts = true; создание второго не блокировалось. - Отмена без причины. Дано: событие
Planned. Когда отмена безCancelReason. Тогда отказCAL_EVENT_CANCEL_REASON_REQUIRED. - Терминальная иммутабельность. Дано: событие
Completed. Когда любой переход/правка. Тогда отказCAL_EVENT_ALREADY_TERMINAL.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: AppointmentEntity, Entity / Aggregate: AvailabilitySlotEntity, Entity: EventReminderEntity
- API (16): GET /api/v1/agency/calendar/events — Календарь команды (read-only), GET /api/v1/agent/calendar/events — Список событий календаря агента, GET /api/v1/agent/calendar/events/{id} — Карточка события календаря, GET /api/v1/agent/calendar/slots — Список слотов доступности агента, GET /api/v1/pin-manager/calendar/events — Календари консультантов ПИН (read-only), POST /api/public/calendar/appointments — Самозапись на слот (бронирование), POST /api/v1/agent/calendar/appointments/{appointmentId}/complete — Фиксация исхода встречи, 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) (+4)
- Use Cases (11): UC-CAL-001. Агент ведёт рабочий календарь, UC-CAL-003. Инвестор бронирует слот с витрины, UC-CAL-004. Агент подтверждает или отклоняет запись, UC-CAL-005. Отмена и перенос записи, UC-CAL-006. Агент фиксирует исход встречи, UC-CAL-007. Дедлайны брони CRM в календаре агента, UC-CAL-008. Напоминания о событиях календаря, UC-CAL-009. Руководитель смотрит календарь команды, UC-CAL-010. Руководитель ПИН смотрит календари консультантов, UC-CAL-011. Переназначение открытых дедлайнов и встреч при смене исполнителя, US-реестр контекста calendar (US-CAL)
- События (8): Event Contract: pin.calendar.appointment-cancelled (AppointmentCancelledEvent), Event Contract: pin.calendar.appointment-completed (AppointmentCompletedEvent), Event Contract: pin.calendar.appointment-created (AppointmentCreatedEvent), 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), Event Contract: pin.calendar.reminder-due (ReminderDueEvent)
- Консюмеры (3): Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events), Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events), Consumer: Calendar Identity User Sync (calendar-identity-users)
- Фоновые задачи (2): Background Job: AppointmentTimeoutJob, Background Job: ReminderDispatchJob
- index.md (1): Calendar Service
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- event-reminder.md — напоминания (owned); appointment.md — запись, порождающая событие; availability-slot.md — слот самозаписи.
- ADR-0064 — границы контекста, маппинг черновика, фикс «start → InProgress», таймзоны; ADR-0052 — tenancy; ADR-0056 — polling-worker-ы.
- Источник дедлайнов брони: booking.md,
booking-created.md, booking-expired.md;
consumer —
../consumers/crm-booking-events.md. - Контракты API:
../api/agent/list-events.md,create-event.md,update-event.md,reschedule-event.md,start-event.md,complete-event.md,cancel-event.md.