PUT /api/v1/agent/calendar/events/{id} — Изменение события календаря
Назначение
Владелец календаря правит содержимое своего ручного события: заголовок, описание, место, ссылку на встречу,
приоритет, привязки к CRM/объектам, конец интервала и офсеты напоминаний. Операция доступна только для
событий Origin = Manual в статусе Planned/InProgress; при изменении офсетов Pending-строки
напоминаний пересоздаются. Перенос начала — отдельная операция
(reschedule-event.md); статусы меняются операциями
start/complete/cancel.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | calendar |
| API area | agent |
| Feature group | calendar-events |
| Статус | draft |
| Документ | docs/06-services/calendar/api/agent/update-event.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (агент агентства / консультант ПИН — владелец календаря) |
| Auth policy / Permissions | calendar.events.manage |
| Scope (RBAC) | организация: компания актора (агентство / ПИН) |
| Record-level | только владелец: OwnerUserId = ActorUserId; чужое событие того же tenant → CAL_EVENT_ACCESS_DENIED (право calendar.team.view мутаций не даёт) |
| Tenant isolation | TenantId из токена; событие чужого tenant → CAL_EVENT_NOT_FOUND |
| Audit | update: UpdatedByUserId = ActorUserId, UpdatedAt; Version инкрементируется |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/v1/agent/calendar/events/{id} |
| Success status | 200 |
| Idempotency header | нет (естественная идемпотентность PUT: повтор с той же Version после успеха → CONCURRENCY_CONFLICT, состояние не портится) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
Семантика PUT — полная замена редактируемых полей: не переданное (или null) опциональное поле
сбрасывается. Kind, Origin, StartAt, Status, OwnerUserId через эту операцию не меняются.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | токен (sub) | Guid | Да | Required | record-level проверка владельца |
TenantId | токен (tenant_id) | Guid | Да | Required | Tenant isolation |
Id | route | Guid | Да | валидный Guid | — |
Version | body | int | Да | версия из последнего чтения (get-event.md); расхождение → CONCURRENCY_CONFLICT | оптимистическая блокировка |
EndAt | body | DateTimeOffset? | Условно | UTC; EndAt > StartAt события; null — только для Kind = Task (INV-CAL-1; Kind берётся из события — сменить вид нельзя) | — |
TimeZone | body | string | Да | валидный IANA-идентификатор, ≤ 64 символа | — |
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 | — |
ClientUserId | body | Guid? | Нет | логическая ссылка (identity) | — |
DealId | body | Guid? | Нет | логическая ссылка (CRM) | — |
LeadId | body | Guid? | Нет | логическая ссылка (CRM) | — |
PropertyObjectId | body | Guid? | Нет | логическая ссылка (chessboard) | — |
ProjectId | body | Guid? | Нет | логическая ссылка (chessboard) | — |
ReminderOffsetsMinutes | body | int[] | Да | пустой массив = без напоминаний; ≤ 5 элементов, каждый в 5..40320, без дублей — иначе CAL_EVENT_REMINDER_OFFSETS_INVALID | — |
Модель ответа — CalendarEventModel
200 с обновлённым событием — единичная CalendarEventModel (дом — list-events.md):
инкрементированная Version, пересчитанный HasConflicts (изменение EndAt может создать или убрать
пересечение — soft-conflict, не блокирует).
Алгоритм
- Контекст и доступ. Актор — владелец; требуется
calendar.events.manage. - Загрузка и принадлежность. Событие по
Idв tenant актора: не найдено/чужой tenant →CAL_EVENT_NOT_FOUND; другой владелец →CAL_EVENT_ACCESS_DENIED. - Guards происхождения и статуса.
Origin = Appointment→CAL_EVENT_MANAGED_BY_APPOINTMENT(INV-CAL-3: править нужно запись);Origin = CrmBooking→CAL_EVENT_MANAGED_BY_CRM_BOOKING(INV-CAL-4). СтатусCompleted/Cancelled→CAL_EVENT_ALREADY_TERMINAL(INV-CAL-7). - Валидация входа. Длины/формат полей, enum
Priority→ValidationError. INV-CAL-1 поEndAtотносительно неизменногоStartAtи хранимогоKind→CAL_EVENT_INVALID_INTERVAL. INV-CAL-5 поMeetingLinkи хранимомуKind→CAL_EVENT_KIND_FIELDS_MISMATCH. IANA-валидностьTimeZone→CAL_EVENT_TIMEZONE_UNKNOWN. Офсеты →CAL_EVENT_REMINDER_OFFSETS_INVALID. - Конкурентность.
Versionиз body не совпадает с текущей (параллельная правка, каскад записи или consumer успели раньше) →CONCURRENCY_CONFLICT; клиент перечитывает карточку и повторяет поверх актуального состояния. - Применение. В одном согласованном изменении: замена редактируемых полей; если итоговый набор офсетов
отличается от текущего — все
Pending-reminder-строки пересоздаются от неизменногоStartAtпо новым офсетам (правило 2 event-reminder.md; прошедшиеDueAt→Skipped;Sent/Skipped-строки не трогаются). ИзменениеEndAt/TimeZoneнаDueAtнапоминаний не влияет. - Результат.
200+ обновлённаяCalendarEventModel. Интеграционное событие не публикуется: правка содержимого не входит в топикиpin.calendar.*(перенос и смена статуса — отдельные операции со своими событиями).
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | длины/формат полей, enum Priority вне значений, Version отсутствует | «Некорректные данные события» | no retry |
CAL_EVENT_INVALID_INTERVAL | 400 | EndAt ≤ StartAt; EndAt = null у интервального Kind (INV-CAL-1) | «Некорректный интервал события» | no retry |
CAL_EVENT_KIND_FIELDS_MISMATCH | 400 | MeetingLink при Kind ∈ {Showing, Task} (INV-CAL-5) | «Поля не соответствуют виду события» | no retry |
CAL_EVENT_TIMEZONE_UNKNOWN | 400 | TimeZone — не валидный IANA-идентификатор | «Неизвестная таймзона» | no retry |
CAL_EVENT_REMINDER_OFFSETS_INVALID | 400 | офсеты вне ограничений (количество/диапазон/дубли) | «Некорректные офсеты напоминаний» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | — | no retry |
FORBIDDEN | 403 | нет права calendar.events.manage | — | no retry |
CAL_EVENT_ACCESS_DENIED | 403 | событие другого владельца в tenant актора | «Нет доступа к событию» | no retry |
CAL_EVENT_NOT_FOUND | 404 | событие не существует / чужой tenant | «Событие не найдено» | no retry |
CAL_EVENT_MANAGED_BY_APPOINTMENT | 409 | Origin = Appointment (INV-CAL-3) — управлять нужно записью | «Событие управляется записью» | no retry |
CAL_EVENT_MANAGED_BY_CRM_BOOKING | 409 | Origin = CrmBooking (INV-CAL-4) | «Событие управляется бронью CRM» | no retry |
CAL_EVENT_ALREADY_TERMINAL | 409 | статус Completed/Cancelled (INV-CAL-7) | «Событие завершено или отменено» | no retry |
CONCURRENCY_CONFLICT | 409 | Version не совпала (параллельная правка) | «Событие изменено параллельно — обновите карточку» | 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. Тогда:409CAL_EVENT_MANAGED_BY_APPOINTMENT, ни одно поле не изменено. - Дедлайн брони. Дано: событие
Origin = CrmBooking. Тогда:409CAL_EVENT_MANAGED_BY_CRM_BOOKING. - Терминальное. Дано: событие
Cancelled. Тогда:409CAL_EVENT_ALREADY_TERMINAL. - Конкурентная правка. Дано: два
PUTс однойVersion. Тогда: первый —200, второй —409CONCURRENCY_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.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (4): GET /api/v1/agent/calendar/events — Список событий календаря агента, GET /api/v1/agent/calendar/events/{id} — Карточка события календаря, POST /api/v1/agent/calendar/events — Создание события календаря, POST /api/v1/agent/calendar/events/{id}/reschedule — Перенос события календаря
- registries (1): Реестр API-методов PIN
Связанные документы
- Агрегат, INV-CAL-1/3/4/5/7: calendar-event.md; правила пересоздания напоминаний: event-reminder.md.
- Дом модели: list-events.md; чтение перед правкой: get-event.md.
- Перенос начала: reschedule-event.md; статусы: start-event.md, complete-event.md, cancel-event.md.
- Управление событием записи — операции записи: appointment.md.