POST /api/public/calendar/appointments — Самозапись на слот (бронирование)
Назначение
Публичная точка входа «инвестор записывается к агенту на конкретное время»: аноним или авторизованный
инвестор выбирает свободный слот на витрине (list-available-slots.md) и оставляет
форму «Фамилия, Имя, Отчество (опционально), Телефон» + комментарий. Результат — lazy-регистрация инвестора
по телефону (как в create-consultation-request) и
атомарная связка: слот Available → Booked + запись (appointment.md) +
календарное событие агента + напоминания. CRM узнаёт о записи асинхронно по событию appointment-created
(get-or-create клиента/лида + лента) — календарь чужих таблиц не пишет.
| Поле | Значение |
|---|
| Сервис/контекст | calendar |
| API area | public |
| Feature group | appointments |
| Статус | draft |
| Документ | docs/06-services/calendar/api/public/book-slot.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (lazy-регистрация по телефону) или User (Инвестор с JWT) |
| Auth policy / Permissions | none (публичный метод); rate limit по IP+телефону |
| Scope (RBAC) | не применяется (актор — вне tenant) |
| Record-level | создаваемая запись принадлежит владельцу телефона (InvestorUserId) — далее управляется через investor-область (list-my-appointments.md) |
| Tenant isolation | TenantId записи/события = компания агента слота; запрос выполняется в system-scope (ADR-0052) |
| Audit | create (CreatedByUserId = null для анонима / UserId инвестора); PII формы в логи не пишется, телефон — маской |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/public/calendar/appointments |
| Success status | 201 (запись создана) |
| Idempotency header | не применяется: повтор тем же инвестором на тот же слот/пару отклоняется структурно (INV-APT-1/2 → SLOT_ALREADY_BOOKED / APPOINTMENT_DUPLICATE), дублей не возникает |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
SlotId | body | Guid | Да | Required; существующий слот (availability-slot.md) | — |
LastName | body | string | Да | 1..128, не пустое после trim | PII: в логи не пишется |
FirstName | body | string | Да | 1..128, не пустое после trim | PII |
MiddleName | body | string? | Нет | 0..128 (отчество опционально) | PII |
PhoneNumber | body | string | Да | нормализация и формат E.164 (^\+[1-9]\d{9,14}$), max 32 | PII; маска +7***NNNN в логах |
Comment | body | string? | Нет | 0..1024; комментарий инвестора агенту («интересует 2-комнатная…») | класс Internal — маскирование в логах; попадает в payload appointment-created (нужен ленте лида CRM) |
ActorUserId | identity claims | Guid? | Нет | JWT инвестора, если авторизован | — |
Модель ответа BookSlotResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
AppointmentId | Guid | Да | созданная запись | — | Идентификатор записи. |
Status | AppointmentStatus | Да | запись | — | Confirmed (слот с AutoConfirm = true) или Pending (ждёт решения агента). |
Kind | EventKind | Да | снапшот слота | — | Вид встречи: Showing / Consultation. |
StartAt | DateTimeOffset | Да | снапшот слота | — | Начало встречи, UTC. |
EndAt | DateTimeOffset | Да | снапшот слота | — | Конец встречи, UTC. |
TimeZone | string | Да | снапшот слота | — | IANA-таймзона — локальное отображение. |
AgentDisplayName | string? | Нет | display-реплика identity | без контактов | Имя агента для экрана подтверждения. |
CancellationCutoffMinutes | int | Да | параметр слота | — | Echo правила отмены (INV-APT-3). |
CancellableUntil | DateTimeOffset | Да | computed: StartAt − CancellationCutoffMinutes | — | До какого момента инвестор может отменить/перенести запись. |
Идентификаторы лида/клиента CRM наружу не возвращаются (на момент ответа они ещё не созданы —
eventual-consistency, см. Постусловия).
Алгоритм
- Контекст и доступ. Публичный метод; актор
Anonymous/User (JWT опционален); rate limit по
IP+телефону — превышение → CAL_RATE_LIMITED.
- Проверка входа. Формат ФИО (
1..128, MiddleName ≤ 128), телефон E.164, Comment ≤ 1024,
SlotId задан — иначе VALIDATION_FAILED.
- Слот и его пригодность. Найти слот по
SlotId; нет → CAL_SLOT_NOT_FOUND. Далее по статусу:
Booked → SLOT_ALREADY_BOOKED; Expired/Archived → CAL_SLOT_NOT_AVAILABLE. Lead time:
now > StartAt − MinLeadTimeMinutes → CAL_SLOT_LEAD_TIME_VIOLATION (INV-SLT-5).
- Lazy-регистрация инвестора. Вызов
identity «get-or-create user by phone»
(RegistrationSource = LazyRegistration; имя записывается, если у пользователя пустое) →
InvestorUserId. Если JWT-инвестор авторизован и его телефон совпадает с телефоном формы —
используется ActorUserId без вызова identity. identity недоступен → IDENTITY_UNAVAILABLE
(503, запись не создаётся).
- Антиспам (INV-APT-2). У инвестора уже есть активная (
Pending/Confirmed) запись на пару
(InvestorUserId, AgentUserId слота, Kind слота) или активных записей уже 3 →
APPOINTMENT_DUPLICATE. Гонка параллельных бронирований — дозакрывается уникальностью активной пары.
- Атомарное бронирование. В одном согласованном изменении (при отказе любой части — ничего):
- условный переход слота
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 —
атомарно со сменой состояния.
- Результат.
BookSlotResult → HTTP 201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | AppointmentCreatedEvent → pin.calendar.appointment-created.v1 (key = AppointmentId; payload включает Comment — класс Internal); при auto-confirm дополнительно AppointmentConfirmedEvent → pin.calendar.appointment-confirmed.v1 (AutoConfirmed = true); CalendarEventCreatedEvent → pin.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_FAILED | 400 | формат ФИО/телефона/Comment; SlotId не задан | «Проверьте корректность полей формы» | no retry |
CAL_SLOT_NOT_FOUND | 404 | слот по SlotId не существует | «Слот не найден» | no retry |
SLOT_ALREADY_BOOKED | 409 | слот уже Booked (в т.ч. проигранная гонка на шаге 6) | «Это время уже занято, выберите другой слот» | no retry (выбрать другой слот) |
CAL_SLOT_NOT_AVAILABLE | 409 | слот Expired/Archived | «Это время больше недоступно» | no retry |
CAL_SLOT_LEAD_TIME_VIOLATION | 409 | now > StartAt − MinLeadTimeMinutes (INV-SLT-5) | «До начала осталось слишком мало времени» | no retry |
APPOINTMENT_DUPLICATE | 409 | антиспам INV-APT-2: активная запись на пару инвестор+агент+вид уже есть, либо активных записей уже 3 | «У вас уже есть активная запись» | no retry |
CAL_RATE_LIMITED | 429 | превышен rate limit по IP+телефону | «Слишком много запросов, повторите позже» | retry после окна лимита |
IDENTITY_UNAVAILABLE | 503 | identity недоступен на шаге 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; запись Pending (с PendingExpiresAt) или 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.