POST /api/v1/agent/calendar/events/{id}/reschedule — Перенос события календаря
Назначение
Владелец календаря переносит своё ручное событие на новое время: задаётся только новый StartAt,
длительность сохраняется (EndAt сдвигается на ту же дельту; точечное событие остаётся точечным). Перенос —
не переход статуса (calendar-event.md): событие остаётся в
Planned/InProgress, Pending-напоминания пересоздаются от нового начала, публикуется
pin.calendar.event-rescheduled.v1. Пересечение с другими событиями владельца перенос не блокирует —
ответ несёт конфликт-warning HasConflicts (INV-CAL-6).
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | agent |
| Feature group | calendar-events |
| Статус | draft |
| Документ | docs/06-services/calendar/api/agent/reschedule-event.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (агент агентства / консультант ПИН — владелец календаря) |
| Auth policy / Permissions | calendar.events.manage |
| Scope (RBAC) | организация: компания актора (агентство / ПИН) |
| Record-level | только владелец: OwnerUserId = ActorUserId; чужое событие того же tenant → CAL_EVENT_ACCESS_DENIED |
| Tenant isolation | TenantId из токена; чужой tenant → CAL_EVENT_NOT_FOUND |
| Audit | update: UpdatedByUserId = ActorUserId, UpdatedAt; Version инкрементируется; история переносов — события pin.calendar.event-rescheduled.v1 |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agent/calendar/events/{id}/reschedule |
| Success status | 200 |
| Idempotency header | нет (повтор с той же Version после успеха → CONCURRENCY_CONFLICT) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | токен (sub) | Guid | Да | Required | record-level проверка владельца |
TenantId | токен (tenant_id) | Guid | Да | Required | Tenant isolation |
Id | route | Guid | Да | валидный Guid | — |
NewStartAt | body | DateTimeOffset | Да | UTC; прошлое допустимо (перенос задним числом — напоминания с прошедшим DueAt материализуются как Skipped) | — |
Version | body | int | Да | версия из последнего чтения; расхождение → CONCURRENCY_CONFLICT | оптимистическая блокировка |
EndAt в запросе нет — он вычисляется: NewEndAt = NewStartAt + (EndAt − StartAt); изменение длительности —
через update-event.md (EndAt).
Модель ответа — CalendarEventModel
200 с перенесённым событием — единичная CalendarEventModel (дом — list-events.md):
новые StartAt/EndAt, прежние Kind/Status/TimeZone, инкрементированная Version, пересчитанный
HasConflicts по новому интервалу.
Алгоритм
- Контекст и доступ. Актор — владелец; требуется
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: время встречи по записи меняется только переносом записи —
../investor/reschedule-my-appointment.md); Origin = CrmBooking → CAL_EVENT_MANAGED_BY_CRM_BOOKING
(INV-CAL-4: дедлайн двигает только consumer при продлении брони). Статус Completed/Cancelled →
CAL_EVENT_ALREADY_TERMINAL (INV-CAL-7).
- Конкурентность.
Version не совпала → CONCURRENCY_CONFLICT; клиент перечитывает и повторяет.
- Перенос. В одном согласованном изменении:
StartAt = NewStartAt; EndAt = NewStartAt + прежняя длительность (для точечного события EndAt остаётся null — INV-CAL-1 нарушиться не может, интервал
сохраняет знак); все Pending-reminder-строки пересоздаются от нового StartAt по актуальным офсетам
(правило 2 event-reminder.md; прошедшие DueAt → Skipped;
Sent/Skipped — не трогаются); публикация pin.calendar.event-rescheduled.v1 с новыми значениями.
- Конфликт-warning. Новый интервал может пересечься с активными событиями владельца — перенос не
блокируется (INV-CAL-6), в ответе
HasConflicts.
- Результат.
200 + обновлённая CalendarEventModel.
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | pin.calendar.event-rescheduled.v1, key = EventId, атомарно с переносом (контракт — event-rescheduled.md) |
| Уведомления | downstream: notifications консюмит event-rescheduled (CalendarEventRescheduled) — участники узнают о новом времени |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | NewStartAt/Version отсутствуют; Id не Guid | «Некорректные параметры переноса» | 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.
- Постусловия.
StartAt/EndAt сдвинуты на одну дельту (длительность и EndAt = null сохранены);
статус, Kind, TimeZone и офсеты не изменились; Pending-напоминания соответствуют новому StartAt;
опубликовано ровно одно pin.calendar.event-rescheduled.v1 на успешный перенос.
Acceptance-критерии
- Длительность сохраняется. Дано:
Planned-событие 14:00–15:30 (90 мин). Когда: перенос на
NewStartAt = завтра 10:00. Тогда: 200, StartAt = 10:00, EndAt = 11:30, статус не изменился,
опубликовано event-rescheduled.
- Точечное остаётся точечным. Дано: задача (
Task, EndAt = null). Когда: перенос. Тогда: 200,
EndAt = null.
- Напоминания пересозданы. Дано: событие с офсетом
60 и Pending-строкой. Когда: перенос на неделю
позже. Тогда: старая Pending-строка удалена, новая — DueAt = новый StartAt − 60 мин; ранее
отправленные Sent-строки нетронуты.
- Перенос в ближайшее время. Дано: перенос на
NewStartAt = now + 10 мин при офсете 60. Тогда: 200;
строка офсета 60 создана сразу в Skipped (её DueAt в прошлом), reminder-due по ней не публикуется.
- Конфликт-warning. Дано: новый интервал пересекается с другим активным событием. Тогда:
200,
HasConflicts = true, ничего не блокируется.
- Событие записи. Дано: событие
Origin = Appointment. Тогда: 409 CAL_EVENT_MANAGED_BY_APPOINTMENT.
- Дедлайн брони. Дано: событие
Origin = CrmBooking. Тогда: 409 CAL_EVENT_MANAGED_BY_CRM_BOOKING.
- Терминальное. Дано: событие
Completed. Тогда: 409 CAL_EVENT_ALREADY_TERMINAL.
- Гонка. Дано: параллельные reschedule и cancel с одной
Version. Тогда: побеждает один; проигравший —
409 CONCURRENCY_CONFLICT (или CAL_EVENT_ALREADY_TERMINAL после перечитки, если отмена успела первой).
Совместимость и наблюдаемость
- Контракт запроса минимален (
NewStartAt + Version); новые optional поля — additive. Контракт
event-rescheduled — ContractVersion = 1, additive-эволюция.
- Logs:
TenantId, OwnerUserId, EventId, старый/новый StartAt, HasConflicts; metrics:
calendar_api_request_duration_seconds{route}, счётчик переносов; traces — сквозной traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы