POST /api/v1/agent/calendar/appointments/{appointmentId}/decline — Отклонение записи
Назначение
Агент отклоняет ожидающую запись инвестора с обязательной причиной (Pending → Declined): встреча не
состоится, инвестор получает уведомление с причиной. Каскадом слот освобождается и возвращается на витрину
(если до начала ещё остаётся lead time), а порождённое календарное событие отменяется. Отклонить можно
только Pending-запись; для подтверждённой используется отмена
(cancel-appointment.md).
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | agent |
| Feature group | appointments |
| Статус | draft |
| Документ | docs/06-services/calendar/api/agent/decline-appointment.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (агент агентства / консультант ПИН — сторона записи) |
| Auth policy / Permissions | calendar.appointments.decide |
| Scope (RBAC) | организация — компания актора (агентство / ПИН) |
| Record-level | только собственные записи (AgentUserId = actor); чужая — APPOINTMENT_NOT_FOUND (fail-closed) |
| Tenant isolation | TenantId из токена (ADR-0052) |
| Audit | update: CancelReason, UpdatedByUserId = actor; история — событие appointment-cancelled |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/agent/calendar/appointments/{appointmentId}/decline |
| Success status | 200 (обновлённая запись) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | токен (sub) | Guid | Да | Required | record-level |
TenantId | токен (tenant_id) | Guid | Да | Required | Tenant isolation |
AppointmentId | route | Guid | Да | Required; валидный Guid | — |
Reason | body | string | Да | 1..512 символов после trim; отсутствие/пустая строка → VALIDATION_FAILED | может содержать ПДн — в логи не пишется |
Модель ответа
Обновлённая запись — AgentAppointmentModel (дом — list-appointments.md):
Status = Declined, CancelReason = Reason, инкрементирован Version.
Алгоритм
- Контекст и доступ. Актор с permission
calendar.appointments.decide (иначе
APPOINTMENT_ACCESS_DENIED); запись ищется среди записей актора в его tenant — не найдена/чужая →
APPOINTMENT_NOT_FOUND.
- Проверка входа.
Reason непустой, ≤ 512 символов — иначе VALIDATION_FAILED (причина обязательна
при Declined, appointment.md).
- Проверка состояния. Допустимый исходный статус — только
Pending. Confirmed → отказ
APPOINTMENT_INVALID_STATE (использовать cancel-appointment.md); терминальные —
тот же отказ.
- Переход и каскады. В одном согласованном изменении (INV-APT-6):
- запись
Pending → Declined, CancelReason = Reason;
- слот:
Booked → Available, если он ещё пригоден для повторного бронирования
(now ≤ StartAt − MinLeadTimeMinutes) — возвращается на витрину; иначе Booked → Expired
(правило освобождения — appointment.md);
- календарное событие (
Origin = Appointment): → Cancelled с CancelReason из записи (INV-CAL-3),
его Pending-напоминания переводятся в Skipped (event-reminder.md);
- публикуется
appointment-cancelled (Reason = DeclinedByAgent).
- Конкуренция. Гонка «decline ↔ отмена инвестором ↔ таймаут worker-а» — арбитраж версией записи;
проигравший получает
APPOINTMENT_INVALID_STATE и перечитывает состояние.
- Результат.
200 + обновлённая AgentAppointmentModel.
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | pin.calendar.appointment-cancelled.v1 (Reason = DeclinedByAgent), key = AppointmentId (../../events/appointment-cancelled.md); атомарно с переходом |
| Проекции downstream | crm — запись в ленту лида (../../../crm/consumers/calendar-appointments.md) |
| Уведомления | notifications уведомляет инвестора об отклонении с причиной (AppointmentCancelled, ../../../notifications/consumers/calendar-events.md) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | Reason отсутствует, пустой или длиннее 512 | «Укажите причину отклонения» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
APPOINTMENT_ACCESS_DENIED | 403 | нет permission calendar.appointments.decide | «Недостаточно прав» | no retry |
APPOINTMENT_NOT_FOUND | 404 | запись не существует или принадлежит другому агенту/tenant | «Запись не найдена» | no retry |
APPOINTMENT_INVALID_STATE | 409 | запись не Pending (в т.ч. Confirmed или терминальная), либо проигрыш конкурентной гонки | «Запись уже в другом состоянии» | no retry (перечитать состояние) |
Постусловия
После 200: запись Declined (терминальна, INV-APT-6) с CancelReason; слот Available (снова на
витрине) либо Expired; календарное событие Cancelled, его напоминания Skipped; опубликовано ровно
одно appointment-cancelled (Reason = DeclinedByAgent). Лента лида CRM и уведомление инвестору догоняют
асинхронно.
Acceptance Criteria
- AC-1 (happy path). Given
Pending-запись, до StartAt больше MinLeadTimeMinutes слота. When агент
отклоняет с причиной. Then 200: запись Declined, слот Available (виден на витрине), событие
Cancelled, напоминания Skipped; опубликовано appointment-cancelled (Reason = DeclinedByAgent).
- AC-2 (поздний lead time). Given
Pending-запись, до StartAt меньше MinLeadTimeMinutes. When
отклонение. Then запись Declined, слот Expired (на витрину не возвращается).
- AC-3 (без причины). Given
Pending-запись. When decline с пустым Reason. Then
400 VALIDATION_FAILED; запись не изменилась.
- AC-4 (подтверждённая). Given
Confirmed-запись. When decline. Then 409 APPOINTMENT_INVALID_STATE
(для подтверждённой — cancel-appointment.md).
- AC-5 (гонка с таймаутом). Given
Pending-запись на грани PendingExpiresAt, worker и decline
конкурируют. Then побеждает ровно один; итог — либо Declined (Reason = DeclinedByAgent), либо
Expired (Reason = Expired), но не оба; событий appointment-cancelled — ровно одно.
- AC-6 (чужая запись). Given запись к другому агенту. When decline. Then
404 APPOINTMENT_NOT_FOUND.
Совместимость и наблюдаемость
- Контракт стабилен; расширение причин отклонения (структурированный код причины) — additive optional поле.
- Logs:
appointment declined (TenantId, AgentUserId, AppointmentId, судьба слота; без Reason —
может содержать ПДн); metrics: calendar_appointment_decisions_total{decision=declined},
calendar_slots_released_total{outcome}; traces — сквозной traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы