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

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).

Metadata

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

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

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

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdтокен (sub)GuidДаRequiredrecord-level проверка владельца
TenantIdтокен (tenant_id)GuidДаRequiredTenant isolation
IdrouteGuidДавалидный Guid
NewStartAtbodyDateTimeOffsetДаUTC; прошлое допустимо (перенос задним числом — напоминания с прошедшим DueAt материализуются как Skipped)
VersionbodyintДаверсия из последнего чтения; расхождение → CONCURRENCY_CONFLICTоптимистическая блокировка

EndAt в запросе нет — он вычисляется: NewEndAt = NewStartAt + (EndAt − StartAt); изменение длительности — через update-event.md (EndAt).

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

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

Алгоритм

  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: время встречи по записи меняется только переносом записи — ../investor/reschedule-my-appointment.md); Origin = CrmBookingCAL_EVENT_MANAGED_BY_CRM_BOOKING (INV-CAL-4: дедлайн двигает только consumer при продлении брони). Статус Completed/CancelledCAL_EVENT_ALREADY_TERMINAL (INV-CAL-7).
  4. Конкурентность. Version не совпала → CONCURRENCY_CONFLICT; клиент перечитывает и повторяет.
  5. Перенос. В одном согласованном изменении: StartAt = NewStartAt; EndAt = NewStartAt + прежняя длительность (для точечного события EndAt остаётся null — INV-CAL-1 нарушиться не может, интервал сохраняет знак); все Pending-reminder-строки пересоздаются от нового StartAt по актуальным офсетам (правило 2 event-reminder.md; прошедшие DueAtSkipped; Sent/Skipped — не трогаются); публикация pin.calendar.event-rescheduled.v1 с новыми значениями.
  6. Конфликт-warning. Новый интервал может пересечься с активными событиями владельца — перенос не блокируется (INV-CAL-6), в ответе HasConflicts.
  7. Результат. 200 + обновлённая CalendarEventModel.

Побочные эффекты

ТипДетали
Интеграционные событияpin.calendar.event-rescheduled.v1, key = EventId, атомарно с переносом (контракт — event-rescheduled.md)
Уведомленияdownstream: notifications консюмит event-rescheduled (CalendarEventRescheduled) — участники узнают о новом времени

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400NewStartAt/Version отсутствуют; Id не Guid«Некорректные параметры переноса»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.
  • Постусловия. 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-rescheduledContractVersion = 1, additive-эволюция.
  • Logs: TenantId, OwnerUserId, EventId, старый/новый StartAt, HasConflicts; metrics: calendar_api_request_duration_seconds{route}, счётчик переносов; traces — сквозной traceparent.

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

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

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

  • Агрегат и правило «перенос — не переход статуса»: calendar-event.md; пересоздание напоминаний: event-reminder.md.
  • Дом модели и правило HasConflicts: list-events.md; чтение перед переносом: get-event.md; изменение длительности: update-event.md.
  • Контракт события: event-rescheduled.md.
  • Перенос встречи по записи (инвестором): appointment.md, ../investor/reschedule-my-appointment.md.