POST /api/v1/agent/calendar/events — Создание события календаря
Назначение
Владелец календаря (агент агентства или консультант ПИН) вручную создаёт событие своего рабочего календаря:
показ, консультацию, встречу или задачу. Событие создаётся в статусе Planned с Origin = Manual, по
офсетам напоминаний материализуются reminder-строки, публикуется pin.calendar.event-created.v1.
Пересечение с другими событиями владельца создание не блокирует — ответ несёт конфликт-warning
(HasConflicts = true, INV-CAL-6).
Автособытия BookingDeadline через эту операцию не создаются — их порождает только consumer booking-событий
CRM (../../consumers/crm-booking-events.md); события записей порождает бронирование слота
(../public/book-slot.md).
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | agent |
| Feature group | calendar-events |
| Статус | draft |
| Документ | docs/06-services/calendar/api/agent/create-event.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (агент агентства / консультант ПИН — владелец календаря) |
| Auth policy / Permissions | calendar.events.manage |
| Scope (RBAC) | организация: компания актора (агентство / ПИН) |
| Record-level | событие создаётся только в собственном календаре: OwnerUserId = ActorUserId из токена (sub), не из body — создать событие другому агенту нельзя |
| Tenant isolation | TenantId из токена (tenant_id) |
| Audit | create: CreatedByUserId = ActorUserId — иммутабельный след происхождения (вместе с Origin = Manual) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agent/calendar/events |
| Success status | 201 |
| Idempotency header | Idempotency-Key (опционально; повтор с тем же ключом возвращает первый результат, дубль события не создаётся) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | токен (sub) | Guid | Да | Required | становится OwnerUserId и CreatedByUserId |
TenantId | токен (tenant_id) | Guid | Да | Required | Tenant isolation |
Kind | body | EventKind | Да | ∈ {Showing, Consultation, Meeting, Task}; значение вне enum → ValidationError; BookingDeadline → CAL_EVENT_KIND_FIELDS_MISMATCH | — |
StartAt | body | DateTimeOffset | Да | UTC; прошлое допустимо (фиксация задним числом — напоминания с прошедшим DueAt материализуются как Skipped) | — |
EndAt | body | DateTimeOffset? | Условно | UTC; EndAt > StartAt; обязателен для Showing/Consultation/Meeting; null (точечное событие) — только Task (INV-CAL-1) | — |
TimeZone | body | string? | Нет | IANA-идентификатор, ≤ 64 символа; при отсутствии — Timezone профиля владельца (profile.md); не определилась ни из body, ни из профиля / не валидный IANA → CAL_EVENT_TIMEZONE_UNKNOWN | — |
Title | body | string? | Нет | ≤ 256 символов | ПДн клиента: в логи не пишется |
Description | body | string? | Нет | ≤ 8192 символа | ПДн клиента: в логи не пишется |
Location | body | string? | Нет | ≤ 512 символов, свободный текст | ПДн клиента: в логи не пишется |
MeetingLink | body | string? | Условно | ≤ 256 символов, абсолютный URL; допустим только для Consultation/Meeting (INV-CAL-5) | — |
Priority | body | EventPriority? | Нет | enum Low/Medium/High/Critical; default Medium | — |
ClientUserId | body | Guid? | Нет | логическая ссылка на пользователя identity; существование не валидируется | — |
DealId | body | Guid? | Нет | логическая ссылка на сделку CRM | — |
LeadId | body | Guid? | Нет | логическая ссылка на лид CRM | — |
PropertyObjectId | body | Guid? | Нет | логическая ссылка на объект chessboard | — |
ProjectId | body | Guid? | Нет | логическая ссылка на проект chessboard | — |
ReminderOffsetsMinutes | body | int[]? | Нет | при отсутствии — default {1440, 60}; явный пустой массив = без напоминаний; ≤ 5 элементов, каждый в 5..40320, без дублей — иначе CAL_EVENT_REMINDER_OFFSETS_INVALID | — |
BookingId в модели запроса нет: поле заполняет только consumer booking-событий (Origin = CrmBooking).
Модель ответа — CalendarEventModel
201 с созданным событием — единичная CalendarEventModel (дом модели и правило HasConflicts —
list-events.md). Ключевые значения: Status = Planned, Origin = Manual,
OwnerUserId = ActorUserId, Version = 1; HasConflicts вычислен на момент создания — это и есть
конфликт-warning для клиента.
Алгоритм
- Контекст и доступ. Актор — владелец календаря; tenant — его компания; требуется
calendar.events.manage. OwnerUserId/TenantId — из токена.
- Проверка входа.
Kind ∈ допустимого подмножества: значение вне enum → ValidationError;
Kind = BookingDeadline → CAL_EVENT_KIND_FIELDS_MISMATCH (вручную не создаётся). Длины
Title/Description/Location/MeetingLink, формат URL, enum Priority — нарушение →
ValidationError. Офсеты напоминаний в ограничениях (≤ 5, диапазон 5..40320, уникальны) — иначе
CAL_EVENT_REMINDER_OFFSETS_INVALID.
- Таймзона. Если
TimeZone не передана — взять Timezone профиля владельца; при недоступности
профиля или пустой таймзоне, а также при не валидном IANA-идентификаторе →
CAL_EVENT_TIMEZONE_UNKNOWN.
- Интервал и kind-матрица. INV-CAL-1:
EndAt > StartAt; EndAt = null допустим только для Task —
иначе CAL_EVENT_INVALID_INTERVAL. INV-CAL-5: MeetingLink присутствует при Kind ∈ {Showing, Task} →
CAL_EVENT_KIND_FIELDS_MISMATCH.
- Создание. В одном согласованном изменении: событие
Planned (Origin = Manual,
CreatedByUserId = ActorUserId, Priority default Medium); материализация reminder-строк по офсетам
(правило 1 event-reminder.md: DueAt = StartAt − Offset; прошедший
DueAt → строка сразу Skipped); публикация pin.calendar.event-created.v1.
- Конфликт-warning. Пересечение с активными (
Planned/InProgress) интервальными событиями владельца
не блокирует создание (INV-CAL-6) — в ответе HasConflicts = true; решение (перенести/оставить) — за
владельцем.
- Результат.
201 + CalendarEventModel созданного события.
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | pin.calendar.event-created.v1, key = EventId, атомарно с созданием (контракт — event-created.md) |
| Уведомления | downstream: notifications консюмит event-created (../../../notifications/consumers/calendar-events.md) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Kind вне enum; длины/формат полей; enum Priority вне значений; StartAt отсутствует | «Некорректные данные события» | no retry |
CAL_EVENT_INVALID_INTERVAL | 400 | EndAt ≤ StartAt; EndAt = null при Kind ∈ {Showing, Consultation, Meeting} (INV-CAL-1) | «Некорректный интервал события» | no retry |
CAL_EVENT_KIND_FIELDS_MISMATCH | 400 | Kind = BookingDeadline; MeetingLink при Showing/Task (INV-CAL-5) | «Поля не соответствуют виду события» | no retry |
CAL_EVENT_TIMEZONE_UNKNOWN | 400 | TimeZone не валидный IANA-идентификатор либо не передан и default профиля недоступен/пуст | «Не удалось определить таймзону» | no retry |
CAL_EVENT_REMINDER_OFFSETS_INVALID | 400 | офсеты вне ограничений: > 5 элементов, значение вне 5..40320, дубли | «Некорректные офсеты напоминаний» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | — | no retry |
FORBIDDEN | 403 | нет права calendar.events.manage | — | no retry |
Предусловия и постусловия
- Предусловия. Актор авторизован, имеет
calendar.events.manage. Профиль владельца может содержать
default-таймзону (иначе TimeZone обязателен в запросе — деградация без блокировки, недоступность
profile создание с явной таймзоной не ломает).
- Постусловия. Создано ровно одно событие
Planned (Origin = Manual); материализованы reminder-строки
по актуальным офсетам (прошедшие — Skipped); опубликовано ровно одно pin.calendar.event-created.v1.
Никакие другие события владельца не изменены (soft-conflict ничего не блокирует и не двигает).
Acceptance-критерии
- Happy path. Дано: агент с
calendar.events.manage. Когда: POST Consultation с корректным
интервалом, MeetingLink и офсетами по умолчанию. Тогда: 201, событие Planned/Origin = Manual,
две Pending-reminder-строки (1440, 60), опубликовано event-created.
- Конфликт-warning. Дано: у владельца активное событие 14:00–15:00. Когда: создаётся
Meeting
14:30–15:30. Тогда: 201 (не блокируется), в ответе HasConflicts = true.
- BookingDeadline вручную. Дано:
Kind = BookingDeadline. Тогда: 400
CAL_EVENT_KIND_FIELDS_MISMATCH.
- MeetingLink у показа. Дано:
Kind = Showing с MeetingLink. Тогда: 400
CAL_EVENT_KIND_FIELDS_MISMATCH.
- Точечное не-Task. Дано:
Kind = Showing, EndAt = null. Тогда: 400 CAL_EVENT_INVALID_INTERVAL.
- Таймзона из профиля. Дано:
TimeZone не передана, в профиле владельца Europe/Moscow. Тогда: 201,
TimeZone = Europe/Moscow. Если и профиль пуст — 400 CAL_EVENT_TIMEZONE_UNKNOWN.
- Событие задним числом. Дано:
StartAt — вчера. Тогда: 201; reminder-строки созданы сразу в
Skipped, reminder-due не публикуется.
- Без напоминаний. Дано:
ReminderOffsetsMinutes = []. Тогда: 201, reminder-строк нет.
- Повтор с Idempotency-Key. Дано: повторный
POST с тем же ключом (ретрай по таймауту). Тогда: тот же
201 с первым результатом; второе событие и второе event-created не появляются.
Совместимость и наблюдаемость
- Новые optional поля запроса/ответа — additive (без breaking change); неизвестное enum-значение в body →
ValidationError. Контракт event-created — ContractVersion = 1, additive-эволюция.
- Logs:
TenantId, OwnerUserId, EventId, Kind, HasConflicts (без Title/Description/Location);
metrics: calendar_api_request_duration_seconds{route}, счётчик созданий по Kind; traces — сквозной
traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы