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

PUT /api/v1/agent/calendar/events/{id} — Изменение события календаря

Назначение

Владелец календаря правит содержимое своего ручного события: заголовок, описание, место, ссылку на встречу, приоритет, привязки к CRM/объектам, конец интервала и офсеты напоминаний. Операция доступна только для событий Origin = Manual в статусе Planned/InProgress; при изменении офсетов Pending-строки напоминаний пересоздаются. Перенос начала — отдельная операция (reschedule-event.md); статусы меняются операциями start/complete/cancel.

Metadata

ПолеЗначение
Сервис/контекстcalendar
API areaagent
Feature groupcalendar-events
Статусdraft
Документdocs/06-services/calendar/api/agent/update-event.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (агент агентства / консультант ПИН — владелец календаря)
Auth policy / Permissionscalendar.events.manage
Scope (RBAC)организация: компания актора (агентство / ПИН)
Record-levelтолько владелец: OwnerUserId = ActorUserId; чужое событие того же tenant → CAL_EVENT_ACCESS_DENIED (право calendar.team.view мутаций не даёт)
Tenant isolationTenantId из токена; событие чужого tenant → CAL_EVENT_NOT_FOUND
Auditupdate: UpdatedByUserId = ActorUserId, UpdatedAt; Version инкрементируется

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/v1/agent/calendar/events/{id}
Success status200
Idempotency headerнет (естественная идемпотентность PUT: повтор с той же Version после успеха → CONCURRENCY_CONFLICT, состояние не портится)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

Семантика PUT — полная замена редактируемых полей: не переданное (или null) опциональное поле сбрасывается. Kind, Origin, StartAt, Status, OwnerUserId через эту операцию не меняются.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdтокен (sub)GuidДаRequiredrecord-level проверка владельца
TenantIdтокен (tenant_id)GuidДаRequiredTenant isolation
IdrouteGuidДавалидный Guid
VersionbodyintДаверсия из последнего чтения (get-event.md); расхождение → CONCURRENCY_CONFLICTоптимистическая блокировка
EndAtbodyDateTimeOffset?УсловноUTC; EndAt > StartAt события; null — только для Kind = Task (INV-CAL-1; Kind берётся из события — сменить вид нельзя)
TimeZonebodystringДавалидный IANA-идентификатор, ≤ 64 символа
Titlebodystring?Нет≤ 256 символовПДн клиента: в логи не пишется
Descriptionbodystring?Нет≤ 8192 символаПДн клиента: в логи не пишется
Locationbodystring?Нет≤ 512 символовПДн клиента: в логи не пишется
MeetingLinkbodystring?Условно≤ 256 символов, абсолютный URL; только для Consultation/Meeting (INV-CAL-5)
PrioritybodyEventPriorityДаenum Low/Medium/High/Critical
ClientUserIdbodyGuid?Нетлогическая ссылка (identity)
DealIdbodyGuid?Нетлогическая ссылка (CRM)
LeadIdbodyGuid?Нетлогическая ссылка (CRM)
PropertyObjectIdbodyGuid?Нетлогическая ссылка (chessboard)
ProjectIdbodyGuid?Нетлогическая ссылка (chessboard)
ReminderOffsetsMinutesbodyint[]Дапустой массив = без напоминаний; ≤ 5 элементов, каждый в 5..40320, без дублей — иначе CAL_EVENT_REMINDER_OFFSETS_INVALID

Модель ответа — CalendarEventModel

200 с обновлённым событием — единичная CalendarEventModel (дом — list-events.md): инкрементированная Version, пересчитанный HasConflicts (изменение EndAt может создать или убрать пересечение — soft-conflict, не блокирует).

Алгоритм

  1. Контекст и доступ. Актор — владелец; требуется calendar.events.manage.
  2. Загрузка и принадлежность. Событие по Id в tenant актора: не найдено/чужой tenant → CAL_EVENT_NOT_FOUND; другой владелец → CAL_EVENT_ACCESS_DENIED.
  3. Guards происхождения и статуса. Origin = AppointmentCAL_EVENT_MANAGED_BY_APPOINTMENT (INV-CAL-3: править нужно запись); Origin = CrmBookingCAL_EVENT_MANAGED_BY_CRM_BOOKING (INV-CAL-4). Статус Completed/CancelledCAL_EVENT_ALREADY_TERMINAL (INV-CAL-7).
  4. Валидация входа. Длины/формат полей, enum PriorityValidationError. INV-CAL-1 по EndAt относительно неизменного StartAt и хранимого KindCAL_EVENT_INVALID_INTERVAL. INV-CAL-5 по MeetingLink и хранимому KindCAL_EVENT_KIND_FIELDS_MISMATCH. IANA-валидность TimeZoneCAL_EVENT_TIMEZONE_UNKNOWN. Офсеты → CAL_EVENT_REMINDER_OFFSETS_INVALID.
  5. Конкурентность. Version из body не совпадает с текущей (параллельная правка, каскад записи или consumer успели раньше) → CONCURRENCY_CONFLICT; клиент перечитывает карточку и повторяет поверх актуального состояния.
  6. Применение. В одном согласованном изменении: замена редактируемых полей; если итоговый набор офсетов отличается от текущего — все Pending-reminder-строки пересоздаются от неизменного StartAt по новым офсетам (правило 2 event-reminder.md; прошедшие DueAtSkipped; Sent/Skipped-строки не трогаются). Изменение EndAt/TimeZone на DueAt напоминаний не влияет.
  7. Результат. 200 + обновлённая CalendarEventModel. Интеграционное событие не публикуется: правка содержимого не входит в топики pin.calendar.* (перенос и смена статуса — отдельные операции со своими событиями).

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400длины/формат полей, enum Priority вне значений, Version отсутствует«Некорректные данные события»no retry
CAL_EVENT_INVALID_INTERVAL400EndAt ≤ StartAt; EndAt = null у интервального Kind (INV-CAL-1)«Некорректный интервал события»no retry
CAL_EVENT_KIND_FIELDS_MISMATCH400MeetingLink при Kind ∈ {Showing, Task} (INV-CAL-5)«Поля не соответствуют виду события»no retry
CAL_EVENT_TIMEZONE_UNKNOWN400TimeZone — не валидный IANA-идентификатор«Неизвестная таймзона»no retry
CAL_EVENT_REMINDER_OFFSETS_INVALID400офсеты вне ограничений (количество/диапазон/дубли)«Некорректные офсеты напоминаний»no retry
UNAUTHORIZED401нет/просрочен JWTno retry
FORBIDDEN403нет права calendar.events.manageno retry
CAL_EVENT_ACCESS_DENIED403событие другого владельца в tenant актора«Нет доступа к событию»no retry
CAL_EVENT_NOT_FOUND404событие не существует / чужой tenant«Событие не найдено»no retry
CAL_EVENT_MANAGED_BY_APPOINTMENT409Origin = Appointment (INV-CAL-3) — управлять нужно записью«Событие управляется записью»no retry
CAL_EVENT_MANAGED_BY_CRM_BOOKING409Origin = CrmBooking (INV-CAL-4)«Событие управляется бронью CRM»no retry
CAL_EVENT_ALREADY_TERMINAL409статус Completed/Cancelled (INV-CAL-7)«Событие завершено или отменено»no retry
CONCURRENCY_CONFLICT409Version не совпала (параллельная правка)«Событие изменено параллельно — обновите карточку»retry после перечитки

Предусловия и постусловия

  • Предусловия. Событие существует, принадлежит актору, Origin = Manual, статус Planned или InProgress; у клиента есть актуальная Version из чтения.
  • Постусловия. Редактируемые поля заменены значениями запроса; Version инкрементирована; при изменении офсетов набор Pending-напоминаний соответствует новым офсетам (от прежнего StartAt), исторические Sent/Skipped-строки не изменены; Kind/Origin/StartAt/Status не изменились; события pin.calendar.* не публиковались.

Acceptance-критерии

  • Happy path. Дано: Planned-событие Meeting владельца, актуальная Version. Когда: PUT с новым Title и Priority = High. Тогда: 200, поля заменены, Version увеличена.
  • Пересоздание напоминаний. Дано: событие с офсетами {1440, 60} (две Pending-строки). Когда: PUT с ReminderOffsetsMinutes = {120}. Тогда: прежние Pending-строки удалены, создана одна с DueAt = StartAt − 120 мин; ранее отправленные Sent-строки нетронуты.
  • Офсеты не менялись. Дано: PUT с тем же набором офсетов. Тогда: reminder-строки не пересоздаются.
  • Сброс поля. Дано: у события заполнен Location. Когда: PUT без Location. Тогда: 200, Location = null (полная замена PUT).
  • Событие записи. Дано: событие Origin = Appointment. Тогда: 409 CAL_EVENT_MANAGED_BY_APPOINTMENT, ни одно поле не изменено.
  • Дедлайн брони. Дано: событие Origin = CrmBooking. Тогда: 409 CAL_EVENT_MANAGED_BY_CRM_BOOKING.
  • Терминальное. Дано: событие Cancelled. Тогда: 409 CAL_EVENT_ALREADY_TERMINAL.
  • Конкурентная правка. Дано: два PUT с одной Version. Тогда: первый — 200, второй — 409 CONCURRENCY_CONFLICT; после перечитки повтор проходит.
  • Смена длительности с конфликтом. Дано: PUT удлиняет EndAt до пересечения с другим активным событием. Тогда: 200 (не блокируется), в ответе HasConflicts = true.

Совместимость и наблюдаемость

  • Новые редактируемые поля — additive nullable; изменение семантики полной замены — breaking. Неизвестное enum-значение в body → ValidationError.
  • Logs: TenantId, OwnerUserId, EventId, Version до/после, факт пересоздания напоминаний (без содержимого Title/Description/Location); metrics: calendar_api_request_duration_seconds{route}, счётчик CONCURRENCY_CONFLICT; traces — сквозной traceparent.

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

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

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