POST /api/v1/investor/calendar/appointments/{appointmentId}/reschedule — Перенос моей записи
Назначение
Инвестор переносит свою активную запись на другой свободный слот того же агента и того же вида встречи:
выбирает новое время на витрине (list-available-slots.md) и вызывает
перенос. Операция атомарна — «отмена текущей записи + бронирование нового слота» происходят вместе или не
происходят вовсе: если новый слот успели занять, старая запись остаётся нетронутой. Пара записей связывается
взаимными ссылками RescheduledFrom/RescheduledTo (INV-APT-5).
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | investor |
| Feature group | appointments |
| Статус | draft |
| Документ | docs/06-services/calendar/api/investor/reschedule-my-appointment.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Инвестор с JWT) |
| Auth policy / Permissions | none (достаточно аутентификации инвестора) |
| Scope (RBAC) | не применяется — инвестор вне tenant (ADR-0052) |
| Record-level | переносить можно только свою запись: InvestorUserId = ActorUserId из токена; чужая → 404 |
| Tenant isolation | TenantId новой записи = компания агента (та же — агент не меняется) |
| Audit | update + create: у старой записи CancelledBy = Investor и RescheduledToAppointmentId; у новой — RescheduledFromAppointmentId; актор фиксируется |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/investor/calendar/appointments/{appointmentId}/reschedule |
| Success status | 201 (создана новая запись; старая отменена) |
| Idempotency header | не применяется: повтор переноса уже отменённой записи → 409 APPOINTMENT_INVALID_STATE; повтор на тот же новый слот → слот уже Booked |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | токен (sub) | Guid | Да | Required | record-level фильтр InvestorUserId |
AppointmentId | route | Guid | Да | Required; переносимая (старая) запись | — |
NewSlotId | body | Guid | Да | Required; ≠ слоту текущей записи (перенос «на то же время» бессмыслен → VALIDATION_FAILED) | — |
Комментарий инвестора (Comment) отдельно не передаётся — он копируется из старой записи в новую.
Модель ответа MyAppointmentModel
Новая запись — модель по дому list-my-appointments.md:
RescheduledFromAppointmentId = {AppointmentId} заполнен; Status — по AutoConfirm нового слота
(Confirmed или Pending с PendingExpiresAt). Отменённая старая запись доступна через
get-my-appointment.md (Status = Cancelled, RescheduledToAppointmentId — id
новой).
Алгоритм
- Контекст и доступ. Актор — аутентифицированный инвестор; старая запись ищется с условием
InvestorUserId = actor.
- Проверка входа.
NewSlotId задан и отличается от слота текущей записи — иначе VALIDATION_FAILED.
- Guards старой половины. Запись не найдена/чужая →
APPOINTMENT_NOT_FOUND. Статус ∉ {Pending,
Confirmed} → APPOINTMENT_INVALID_STATE. Cutoff (INV-APT-3):
now > StartAt − CancellationCutoffMinutes → APPOINTMENT_CUTOFF_PASSED.
- Guards новой половины. Новый слот не найден →
CAL_SLOT_NOT_FOUND. Слот принадлежит другому агенту
(AgentUserId ≠ агента записи) или имеет другой Kind → APPOINTMENT_RESCHEDULE_SLOT_MISMATCH
(перенос не меняет ни агента, ни вид встречи). Статус слота: Booked → SLOT_ALREADY_BOOKED;
Expired/Archived → CAL_SLOT_NOT_AVAILABLE. Lead time нового слота:
now > StartAt − MinLeadTimeMinutes → CAL_SLOT_LEAD_TIME_VIOLATION (INV-SLT-5).
- Атомарная пара (INV-APT-5). В одном согласованном изменении — при отказе любой части не происходит
ничего:
- условный захват нового слота
Available → Booked под построчной блокировкой (INV-SLT-3); гонка
проиграна → SLOT_ALREADY_BOOKED, старая запись не тронута;
- старая запись →
Cancelled (CancelledBy = Investor, CancelReason = «Перенос записи»,
RescheduledToAppointmentId = id новой); её слот освобождается по правилу каскадов
(Available, если now ≤ StartAt − MinLeadTimeMinutes, иначе Expired); её календарное событие →
Cancelled, Pending-напоминания → Skipped;
- создаётся новая запись со снапшотом нового слота (
StartAt/EndAt/TimeZone/Kind),
Comment копируется, RescheduledFromAppointmentId = id старой; статус — Confirmed
(ConfirmedAt = now) при AutoConfirm нового слота, иначе Pending с
PendingExpiresAt = min(now + 24 ч, StartAt − MinLeadTimeMinutes);
- создаётся новое календарное событие
Planned (Origin = Appointment) с напоминаниями;
- публикуются события (см. Побочные эффекты) атомарно со сменой состояния.
Антиспам INV-APT-2 переносом не нарушается по построению: пара (инвестор, агент, вид) освобождается
отменой старой записи в том же изменении, суммарное число активных записей не растёт.
Конкуренция с действиями агента/worker-а над старой записью — арбитраж версией: проигравший получает
APPOINTMENT_INVALID_STATE.
- Результат.
MyAppointmentModel новой записи → HTTP 201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | AppointmentCancelledEvent → pin.calendar.appointment-cancelled.v1 (Reason = CancelledByInvestor, RescheduledToAppointmentId заполнен — потребители различают перенос и «чистую» отмену); AppointmentCreatedEvent → pin.calendar.appointment-created.v1 (RescheduledFromAppointmentId заполнен); при auto-confirm — AppointmentConfirmedEvent; по событиям календаря — event-status-changed (старое → Cancelled) и event-created (новое) |
| Проекции / поиск | старый слот возвращается на витрину (Available) либо списывается (Expired); напоминания старого события → Skipped, у нового — материализованы |
| Уведомления | опосредованно: notifications — агенту «запись перенесена»; CRM — лента лида (../../../crm/consumers/calendar-appointments.md) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | NewSlotId не задан / совпадает со слотом текущей записи / невалидные Guid | «Проверьте корректность полей» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | — | no retry |
APPOINTMENT_NOT_FOUND | 404 | старая запись не существует или принадлежит другому инвестору | «Запись не найдена» | no retry |
CAL_SLOT_NOT_FOUND | 404 | новый слот по NewSlotId не существует | «Слот не найден» | no retry |
APPOINTMENT_INVALID_STATE | 409 | старая запись терминальна (в т.ч. проигранная гонка) | «Запись уже не активна» | no retry (перечитать карточку) |
APPOINTMENT_CUTOFF_PASSED | 409 | now > StartAt − CancellationCutoffMinutes старой записи (INV-APT-3) | «Срок переноса истёк — свяжитесь с агентом» | no retry |
APPOINTMENT_RESCHEDULE_SLOT_MISMATCH | 409 | новый слот принадлежит другому агенту или имеет другой вид встречи | «Слот не подходит для переноса этой записи» | no retry |
SLOT_ALREADY_BOOKED | 409 | новый слот уже занят (гонка); старая запись не изменена | «Это время уже занято, выберите другой слот» | no retry (выбрать другой слот) |
CAL_SLOT_NOT_AVAILABLE | 409 | новый слот Expired/Archived | «Это время больше недоступно» | no retry |
CAL_SLOT_LEAD_TIME_VIOLATION | 409 | до начала нового слота меньше его MinLeadTimeMinutes (INV-SLT-5) | «До начала осталось слишком мало времени» | no retry |
Совместимость и наблюдаемость
- Код
APPOINTMENT_RESCHEDULE_SLOT_MISMATCH объявлен этой операцией (в домене записи не используется —
специфичен для переноса). Новые optional поля — additive; смена семантики атомарной пары — breaking.
- Logs:
reschedule received/old-guard-rejected/new-slot-rejected/done (AppointmentId старой и новой,
NewSlotId; без Comment); metrics: calendar_appointment_reschedules_total{result}; traces —
сквозной traceparent.
Постусловия
После 201 гарантированно: старая запись Cancelled (CancelledBy = Investor,
RescheduledToAppointmentId = новая), её слот освобождён/списан, её событие Cancelled; новая запись
активна (Pending/Confirmed) на новом слоте (Booked) с новым событием Planned и напоминаниями;
ссылки RescheduledFrom/RescheduledTo взаимны. Суммарное число активных записей инвестора не изменилось.
При любом отказе — состояние в точности до вызова.
Acceptance Criteria
- AC-1 (happy, auto-confirm). Дано:
Confirmed-запись в пределах cutoff; свободный слот того же
агента/вида с AutoConfirm = true. Когда: перенос. Тогда: 201; старая — Cancelled с
RescheduledToAppointmentId; новая — Confirmed с RescheduledFromAppointmentId; старый слот снова на
витрине; опубликованы appointment-cancelled + appointment-created + appointment-confirmed.
- AC-2 (happy, ручное подтверждение). Дано: новый слот с
AutoConfirm = false. Тогда: новая запись
Pending с PendingExpiresAt; агент решает заново.
- AC-3 (новый слот занят). Дано: гонка — новый слот заняли первым. Тогда:
409 SLOT_ALREADY_BOOKED;
старая запись и её слот не изменились.
- AC-4 (cutoff). Дано: до начала старой записи меньше cutoff. Тогда:
409 APPOINTMENT_CUTOFF_PASSED;
ничего не изменилось.
- AC-5 (чужой агент). Дано:
NewSlotId слота другого агента. Тогда:
409 APPOINTMENT_RESCHEDULE_SLOT_MISMATCH.
- AC-6 (другой вид). Дано: запись
Consultation, новый слот Showing того же агента. Тогда:
409 APPOINTMENT_RESCHEDULE_SLOT_MISMATCH.
- AC-7 (комментарий сохраняется). Дано: у старой записи
Comment. Тогда: у новой записи тот же
Comment; в payload appointment-created он присутствует.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы