POST /api/v1/investor/calendar/appointments/{appointmentId}/cancel — Отмена моей записи
Назначение
Инвестор отменяет свою активную запись к агенту — не позже cutoff слота (за
CancellationCutoffMinutes до начала, INV-APT-3). Отмена каскадно освобождает слот (он возвращается на
витрину, если ещё пригоден для повторного бронирования) и отменяет календарное событие агента; агент
узнаёт об отмене из уведомления, CRM — из события appointment-cancelled. После cutoff отмена инвестору
недоступна — остаётся связаться с агентом (агент cutoff-ом не ограничен).
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | investor |
| Feature group | appointments |
| Статус | draft |
| Документ | docs/06-services/calendar/api/investor/cancel-my-appointment.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Инвестор с JWT) |
| Auth policy / Permissions | none (достаточно аутентификации инвестора) |
| Scope (RBAC) | не применяется — инвестор вне tenant (ADR-0052) |
| Record-level | отменить можно только свою запись: InvestorUserId = ActorUserId из токена; чужая → 404 |
| Audit | update: CancelledBy = Investor, CancelReason, UpdatedByUserId = actor — иммутабельный след отмены |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/investor/calendar/appointments/{appointmentId}/cancel |
| Success status | 200 |
| Idempotency header | не применяется: повторная отмена уже отменённой записи → 409 APPOINTMENT_INVALID_STATE (терминальный статус) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | токен (sub) | Guid | Да | Required | record-level фильтр InvestorUserId |
AppointmentId | route | Guid | Да | Required, валидный Guid | — |
CancelReason | body | string? | Нет | 0..512; не задан → записывается стандартная причина «Отменено клиентом» (хранение требует непустой причины при Cancelled) | может содержать ПДн — в логи не пишется |
Модель ответа MyAppointmentModel
Обновлённая карточка записи — модель по дому list-my-appointments.md:
Status = Cancelled, CancelledBy = Investor, CancelReason заполнен.
Алгоритм
- Контекст и доступ. Актор — аутентифицированный инвестор; запись ищется с условием
InvestorUserId = actor.
- Проверка входа и предусловий.
CancelReason ≤ 512 — иначе VALIDATION_FAILED. Запись не найдена
или чужая → APPOINTMENT_NOT_FOUND (fail-closed). Статус ∉ {Pending, Confirmed} (терминальная) →
APPOINTMENT_INVALID_STATE.
- Cutoff (INV-APT-3).
now > StartAt − CancellationCutoffMinutes (параметр слота) →
APPOINTMENT_CUTOFF_PASSED; запись не меняется.
- Что меняется (атомарный каскад, INV-APT-6). В одном согласованном изменении:
- запись →
Cancelled (CancelledBy = Investor, CancelReason — из запроса или стандартная);
- слот
Booked → Available, если ещё пригоден для повторного бронирования
(now ≤ StartAt − MinLeadTimeMinutes), иначе Booked → Expired (правило каскадов —
appointment.md);
- календарное событие агента →
Cancelled (единственный легальный канал — каскад записи, INV-CAL-3),
его Pending-напоминания → Skipped (event-reminder.md);
- публикуются
appointment-cancelled (Reason = CancelledByInvestor) и event-status-changed —
атомарно со сменой состояния.
Конкуренция «инвестор ↔ агент ↔ worker» (cancel vs confirm vs timeout) разрешается версией записи:
победитель фиксирует переход, проигравший перечитывает состояние и получает
APPOINTMENT_INVALID_STATE.
- Результат. Обновлённая
MyAppointmentModel → HTTP 200.
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | AppointmentCancelledEvent → pin.calendar.appointment-cancelled.v1 (key = AppointmentId, Reason = CancelledByInvestor); CalendarEventStatusChangedEvent → pin.calendar.event-status-changed.v1 (событие агента → Cancelled) |
| Проекции / поиск | освобождённый слот (Available) снова виден на витрине list-available-slots.md; Pending-напоминания события → Skipped |
| Уведомления | опосредованно: notifications — агенту «клиент отменил запись»; CRM — событие в ленту лида (../../../crm/consumers/calendar-appointments.md) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | AppointmentId невалиден; CancelReason > 512 | «Проверьте корректность полей» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | — | no retry |
APPOINTMENT_NOT_FOUND | 404 | запись не существует или принадлежит другому инвестору | «Запись не найдена» | no retry |
APPOINTMENT_CUTOFF_PASSED | 409 | now > StartAt − CancellationCutoffMinutes (INV-APT-3) | «Срок отмены истёк — свяжитесь с агентом» | no retry |
APPOINTMENT_INVALID_STATE | 409 | запись уже терминальна (Declined/Cancelled/Expired/Completed/NoShow), в т.ч. проигранная гонка | «Запись уже не активна» | no retry (перечитать карточку) |
Совместимость и наблюдаемость
- Контракт стабилен:
CancelReason — additive optional; коды 409 — часть публичного поведения клиента
(по ним различаются «поздно» и «уже не активна»).
- Logs:
cancel-appointment received/cutoff-rejected/cancelled (AppointmentId, InvestorUserId,
освобождение слота Available/Expired; CancelReason не пишется); metrics:
calendar_appointment_cancellations_total{by="investor", result}; traces — сквозной traceparent.
Постусловия
После 200 гарантированно: запись Cancelled (CancelledBy = Investor); слот освобождён (Available)
либо списан (Expired) по правилу lead-time; календарное событие агента Cancelled, напоминания по нему
не уйдут; опубликованы appointment-cancelled и event-status-changed. Асинхронно: агент получает
уведомление, CRM дописывает ленту лида (обычно < 1 мин).
Acceptance Criteria
- AC-1 (happy). Дано:
Confirmed-запись, до StartAt 3 часа, cutoff 120 мин. Когда: инвестор
отменяет. Тогда: 200, запись Cancelled (CancelledBy = Investor), слот снова Available на витрине,
событие агента Cancelled, опубликовано appointment-cancelled (Reason = CancelledByInvestor).
- AC-2 (cutoff). Дано: до
StartAt 60 минут, cutoff 120 мин. Тогда: 409 APPOINTMENT_CUTOFF_PASSED,
запись и слот не изменились.
- AC-3 (слот уже не продать). Дано: отмена в пределах cutoff, но
now > StartAt − MinLeadTimeMinutes.
Тогда: запись Cancelled, слот → Expired (на витрину не возвращается).
- AC-4 (без причины). Дано: отмена без
CancelReason. Тогда: 200, в записи — стандартная причина
«Отменено клиентом».
- AC-5 (повторная отмена). Дано: запись уже
Cancelled. Тогда: 409 APPOINTMENT_INVALID_STATE.
- AC-6 (гонка с worker-ом). Дано: одновременно отмена инвестора и таймаут
Pending (worker). Тогда:
проходит ровно один переход; проигравший получает APPOINTMENT_INVALID_STATE; опубликовано ровно одно
appointment-cancelled.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы