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

POST /api/public/calendar/appointments — Самозапись на слот (бронирование)

Назначение

Публичная точка входа «инвестор записывается к агенту на конкретное время»: аноним или авторизованный инвестор выбирает свободный слот на витрине (list-available-slots.md) и оставляет форму «Фамилия, Имя, Отчество (опционально), Телефон» + комментарий. Результат — lazy-регистрация инвестора по телефону (как в create-consultation-request) и атомарная связка: слот Available → Booked + запись (appointment.md) + календарное событие агента + напоминания. CRM узнаёт о записи асинхронно по событию appointment-created (get-or-create клиента/лида + лента) — календарь чужих таблиц не пишет.

Metadata

ПолеЗначение
Сервис/контекстcalendar
API areapublic
Feature groupappointments
Статусdraft
Документdocs/06-services/calendar/api/public/book-slot.md

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

ПроверкаЗначение
Actor typeAnonymous (lazy-регистрация по телефону) или User (Инвестор с JWT)
Auth policy / Permissionsnone (публичный метод); rate limit по IP+телефону
Scope (RBAC)не применяется (актор — вне tenant)
Record-levelсоздаваемая запись принадлежит владельцу телефона (InvestorUserId) — далее управляется через investor-область (list-my-appointments.md)
Tenant isolationTenantId записи/события = компания агента слота; запрос выполняется в system-scope (ADR-0052)
Auditcreate (CreatedByUserId = null для анонима / UserId инвестора); PII формы в логи не пишется, телефон — маской

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/public/calendar/appointments
Success status201 (запись создана)
Idempotency headerне применяется: повтор тем же инвестором на тот же слот/пару отклоняется структурно (INV-APT-1/2 → SLOT_ALREADY_BOOKED / APPOINTMENT_DUPLICATE), дублей не возникает
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
SlotIdbodyGuidДаRequired; существующий слот (availability-slot.md)
LastNamebodystringДа1..128, не пустое после trimPII: в логи не пишется
FirstNamebodystringДа1..128, не пустое после trimPII
MiddleNamebodystring?Нет0..128 (отчество опционально)PII
PhoneNumberbodystringДанормализация и формат E.164 (^\+[1-9]\d{9,14}$), max 32PII; маска +7***NNNN в логах
Commentbodystring?Нет0..1024; комментарий инвестора агенту («интересует 2-комнатная…»)класс Internal — маскирование в логах; попадает в payload appointment-created (нужен ленте лида CRM)
ActorUserIdidentity claimsGuid?НетJWT инвестора, если авторизован

Модель ответа BookSlotResult

ПолеТипОбязательностьИсточникMaskingОписание
AppointmentIdGuidДасозданная записьИдентификатор записи.
StatusAppointmentStatusДазаписьConfirmed (слот с AutoConfirm = true) или Pending (ждёт решения агента).
KindEventKindДаснапшот слотаВид встречи: Showing / Consultation.
StartAtDateTimeOffsetДаснапшот слотаНачало встречи, UTC.
EndAtDateTimeOffsetДаснапшот слотаКонец встречи, UTC.
TimeZonestringДаснапшот слотаIANA-таймзона — локальное отображение.
AgentDisplayNamestring?Нетdisplay-реплика identityбез контактовИмя агента для экрана подтверждения.
CancellationCutoffMinutesintДапараметр слотаEcho правила отмены (INV-APT-3).
CancellableUntilDateTimeOffsetДаcomputed: StartAt − CancellationCutoffMinutesДо какого момента инвестор может отменить/перенести запись.

Идентификаторы лида/клиента CRM наружу не возвращаются (на момент ответа они ещё не созданы — eventual-consistency, см. Постусловия).

Алгоритм

  1. Контекст и доступ. Публичный метод; актор Anonymous/User (JWT опционален); rate limit по IP+телефону — превышение → CAL_RATE_LIMITED.
  2. Проверка входа. Формат ФИО (1..128, MiddleName ≤ 128), телефон E.164, Comment ≤ 1024, SlotId задан — иначе VALIDATION_FAILED.
  3. Слот и его пригодность. Найти слот по SlotId; нет → CAL_SLOT_NOT_FOUND. Далее по статусу: BookedSLOT_ALREADY_BOOKED; Expired/ArchivedCAL_SLOT_NOT_AVAILABLE. Lead time: now > StartAt − MinLeadTimeMinutesCAL_SLOT_LEAD_TIME_VIOLATION (INV-SLT-5).
  4. Lazy-регистрация инвестора. Вызов identity «get-or-create user by phone» (RegistrationSource = LazyRegistration; имя записывается, если у пользователя пустое) → InvestorUserId. Если JWT-инвестор авторизован и его телефон совпадает с телефоном формы — используется ActorUserId без вызова identity. identity недоступен → IDENTITY_UNAVAILABLE (503, запись не создаётся).
  5. Антиспам (INV-APT-2). У инвестора уже есть активная (Pending/Confirmed) запись на пару (InvestorUserId, AgentUserId слота, Kind слота) или активных записей уже 3 → APPOINTMENT_DUPLICATE. Гонка параллельных бронирований — дозакрывается уникальностью активной пары.
  6. Атомарное бронирование. В одном согласованном изменении (при отказе любой части — ничего):
    • условный переход слота Available → Booked под построчной блокировкой (INV-SLT-3); при конкуренции выигрывает ровно один запрос — проигравший получает SLOT_ALREADY_BOOKED и выбирает другой слот;
    • создаётся запись со снапшотом слота (StartAt/EndAt/TimeZone/Kind, INV-APT-5): при AutoConfirm = true — сразу Confirmed (ConfirmedAt = now); иначе Pending с PendingExpiresAt = min(now + 24 ч, StartAt − MinLeadTimeMinutes);
    • создаётся календарное событие агента Planned (Origin = Appointment, AppointmentId — связь 1:1; время/вид/место — из слота; ClientUserId = InvestorUserId) и материализуются напоминания по default-офсетам (event-reminder.md);
    • публикуется appointment-created (+ appointment-confirmed при auto-confirm) и event-created — атомарно со сменой состояния.
  7. Результат. BookSlotResult → HTTP 201.

Диаграмма

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

ТипДетали
Интеграционные событияAppointmentCreatedEventpin.calendar.appointment-created.v1 (key = AppointmentId; payload включает Comment — класс Internal); при auto-confirm дополнительно AppointmentConfirmedEventpin.calendar.appointment-confirmed.v1 (AutoConfirmed = true); CalendarEventCreatedEventpin.calendar.event-created.v1 (event-created.md)
Внешние вызовыidentity get-or-create по телефону (timeout 3 c, retry 1); chessboard не вызывается (ProjectId/PropertyObjectId слота — логические ссылки)
Проекции / поискматериализованы reminder-строки события (event-reminder.md)
Уведомленияопосредованно: notifications — консюмер pin.calendar.* (агенту — «новая запись», инвестору — подтверждение/ожидание)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400формат ФИО/телефона/Comment; SlotId не задан«Проверьте корректность полей формы»no retry
CAL_SLOT_NOT_FOUND404слот по SlotId не существует«Слот не найден»no retry
SLOT_ALREADY_BOOKED409слот уже Booked (в т.ч. проигранная гонка на шаге 6)«Это время уже занято, выберите другой слот»no retry (выбрать другой слот)
CAL_SLOT_NOT_AVAILABLE409слот Expired/Archived«Это время больше недоступно»no retry
CAL_SLOT_LEAD_TIME_VIOLATION409now > StartAt − MinLeadTimeMinutes (INV-SLT-5)«До начала осталось слишком мало времени»no retry
APPOINTMENT_DUPLICATE409антиспам INV-APT-2: активная запись на пару инвестор+агент+вид уже есть, либо активных записей уже 3«У вас уже есть активная запись»no retry
CAL_RATE_LIMITED429превышен rate limit по IP+телефону«Слишком много запросов, повторите позже»retry после окна лимита
IDENTITY_UNAVAILABLE503identity недоступен на шаге 4«Сервис временно недоступен»retry

Совместимость и наблюдаемость

  • Обязательные поля формы (ФИО, телефон, SlotId) и коды 409 — стабильный публичный контракт; Comment — additive optional. Удаление/переименование полей или смена типа — breaking. Новые значения AppointmentStatus в ответе публичный клиент обязан переживать.
  • Logs: book-slot received/slot-conflict/duplicate/created (SlotId, AppointmentId, TenantId; телефон — маской, ФИО и Comment не пишутся); metrics calendar_book_slot_total{result} (отдельно доля SLOT_ALREADY_BOOKED — сигнал дефицита слотов), latency p95 < 500 мс; traces — сквозной traceparent.

Постусловия

После 201 гарантированно: слот Booked; запись PendingPendingExpiresAt) или Confirmed; в календаре агента — событие Planned (Origin = Appointment) с материализованными напоминаниями; опубликованы appointment-created (+ appointment-confirmed); существует зарегистрированный (lazy) пользователь-инвестор с этим телефоном — аноним увидит и сможет отменить/перенести запись после входа по этому же телефону (record-level по InvestorUserId). Асинхронно (обычно < 1 мин): CRM делает get-or-create клиента/лида и пишет ленту лида (консюмер ../../../crm/consumers/calendar-appointments.md), notifications уведомляет агента и инвестора. Календарь агента актуален сразу — окно eventual-consistency касается только CRM-воронки.

Acceptance Criteria

  • AC-1 (happy, auto-confirm). Дано: слот Available с AutoConfirm = true, у инвестора нет активных записей. Когда: самозапись анонима. Тогда: lazy-регистрация, слот Booked, запись Confirmed, событие Planned у агента, опубликованы appointment-created и appointment-confirmed, ответ 201 (Status = Confirmed, CancellableUntil заполнен).
  • AC-2 (happy, ручное подтверждение). Дано: слот с AutoConfirm = false. Когда: самозапись. Тогда: запись Pending с PendingExpiresAt = min(now + 24 ч, StartAt − MinLeadTimeMinutes), ответ 201 (Status = Pending).
  • AC-3 (гонка). Дано: два одновременных бронирования одного слота. Тогда: ровно одна запись создана; второй запрос — 409 SLOT_ALREADY_BOOKED, у проигравшего никаких сущностей не создано.
  • AC-4 (дубль на пару). Дано: у инвестора активная запись Consultation к этому агенту. Когда: бронь второго консультационного слота того же агента. Тогда: 409 APPOINTMENT_DUPLICATE.
  • AC-5 (лимит 3). Дано: у инвестора 3 активных записи к разным агентам. Когда: четвёртая бронь. Тогда: 409 APPOINTMENT_DUPLICATE.
  • AC-6 (lead time). Дано: слот с StartAt через 30 минут, MinLeadTimeMinutes = 60. Тогда: 409 CAL_SLOT_LEAD_TIME_VIOLATION.
  • AC-7 (identity недоступен). Дано: identity не отвечает. Тогда: 503 IDENTITY_UNAVAILABLE, слот остался Available, запись не создана.
  • AC-8 (JWT-инвестор). Дано: авторизованный инвестор, телефон формы совпадает с телефоном учётки. Тогда: identity не вызывается, запись привязана к ActorUserId.
  • AC-9 (CRM догоняет). Дано: успешная самозапись анонима с Comment. Тогда: после обработки appointment-created в CRM существует клиент/лид (get-or-create по телефону), в ленте лида — событие записи с комментарием.

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

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

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

  • Сущности: appointment.md (INV-APT-1/2/5, каскады, события), availability-slot.md (INV-SLT-3/5, гонка), calendar-event.md (Origin = Appointment, INV-CAL-3), event-reminder.md (материализация напоминаний).
  • Витрина слотов: list-available-slots.md; управление записью инвестором: list-my-appointments.md, cancel-my-appointment.md, reschedule-my-appointment.md; решения агента — ../agent/confirm-appointment.md, ../agent/decline-appointment.md.
  • События: event-created.md; контракты записей — ../../events/appointment-created.md, ../../events/appointment-confirmed.md; CRM-сторона — ../../../crm/consumers/calendar-appointments.md.
  • No-slot путь воронки («время обсудим»): create-consultation-request — источник паттерна lazy-регистрации и rate limit; оба входа сходятся в одном клиенте/лиде CRM.
  • ADR-0064 п.3/4 — гонка без hold'ов, запись как отдельный агрегат; ADR-0052 — инвестор вне tenant.