apps/backend-service + apps/bid-chat-service · Техническая спецификация
Draft — согласовано, до разбивки на задачи

Booking lifecycle: модель, права, state machine

Заменяет модель BOOKING/BID — переопределяет user_id/вводит client_id, добавляет WAIT_PAYMENT/REJECTED, историю переходов и разделение точек входа создания. Код под это ещё не написан — только дизайн-документы.

Принципы

  1. Три источника истины: сам пользователь (ЛК), гость через confirm, OTA (синхронизация с внешними площадками — Avito, Суточно.ру и т.д., будущая итерация, сейчас только точка расширения). Онлайн-оплата аналогично — переход WAIT_PAYMENT → BOOKED закладывается как системное действие по колбэку, сам шлюз не подключается.
  2. Клиент — только чтение, кроме самоотмены своей заявки, пока статус BID или WAIT_PAYMENT.
  3. Платформа не несёт ответственности за действия владельца после оплаты — эскроу/ автовозврата/разбора споров нет, гарантия — только полный аудиторский след.
  4. Права — через владение (housing.user_id), не через роли.

Модель данных

Поле BookingТипСмысл
user_idintПереопределяется: владелец брони, всегда = housing.user_id, проставляется бэкендом.
client_idint?Новое. Реальный аккаунт клиента, если известен.
reason_rejectedtext?Новое. Причина отмены.
statusenumНабор значений полностью заменяется.
sourceenumПереосмысливается как «кто создал» (MANUAL/GUEST/OTA).
ota_platformenum?Новое. Конкретная внешняя площадка (AVITO/SUTOCHNO/...), заполняется только при source === OTA.

BookingStatusType — новый набор

idНазваниеБлокирует даты в поиске
BIDЗаявкаНет
WAIT_PAYMENTОжидание оплатыНет
BOOKEDБроньДа
REJECTEDОтмененаНет

Старое значение BOOKING переименовывается в BOOKED везде по коду. Default в БД остаётся BID.

BookingSourceType — «кто создал», не «какая площадка»

Старый вариант (MANUAL/AVITO/SUTOCHNO в одном enum) смешивал два разных вопроса — кто инициировал заявку и через какую конкретно внешнюю площадку она пришла. Разделено на два поля.

idНазваниеСмысл
MANUALВручнуюВладелец создал сам в ЛК
GUESTОт гостяГость создал через confirm (см. «Гостевой флоу»)
OTAС площадкиСинхронизировано с внешней площадкой — какой именно, см. ota_platform

OtaPlatformType — какая именно площадка

Новый catalog-файл shared/catalogs/booking/ota-platform.ts, тот же паттерн createType():

idНазвание
AVITOАвито
SUTOCHNOСуточно

Заполняется в Booking.ota_platform только когда source === OTA. Расширяется добавлением строки в этот список — не трогает BookingSourceType. Каждая площадка в будущем получит свой класс-адаптер синхронизации, подключаемый по значению ota_platform (например реестр Record<OtaPlatformType, OtaAdapter>) — сам интерфейс адаптера не проектируется сейчас.

BookingStatusHistory — новая сущность

booking_status_history
  id
  booking_id           FK -> booking, CASCADE
  from_status           nullable enum   (null для создания)
  to_status              enum
  changed_by_user_id   nullable int    (null = системное действие)
  reason                 nullable text
  created_at             timestamp

Пишется при каждом изменении статуса, включая создание. Экспонируется как Booking.history (join, только если запрошено — паттерн unavailable_reasons).

State machine

BID → WAIT_PAYMENT → BOOKED → REJECTED — последовательно вперёд, «назад» нет, кроме прыжка в REJECTED из любого статуса. Прыжок через ступень вперёд (BID → BOOKED) разрешён. REJECTED — терминальный.

ПереходВладелецКлиентСистема
BID → WAIT_PAYMENT
BID → BOOKED
WAIT_PAYMENT → BOOKEDoverrideосновной путь
* → REJECTEDвсегда*пока BID/WAIT_PAYMENTкаскад

*OTA-защита: если booking.source === BookingSourceType.OTA, владелец не может перевести бронь в REJECTED вообще — только сама OTA-интеграция.

Права владельца по source: редактирование vs отмена

Раньше правило было плоским — «владелец может всё, кроме OTA». На практике владелец различает три типа заявок на своём объекте по-разному:

sourceeditBookingОтмена (→ REJECTED)
MANUAL
GUEST
OTA

Прямое сравнение booking.source, без нового поля прав: editBookingcaller.id === housing.user_id AND source === MANUAL; отмена владельцем — caller.id === housing.user_id AND source !== OTA. Заявку, которую владелец завёл сам, он полностью контролирует; заявку от гостя не может подменить (исказило бы то, что гость реально запросил), но может отклонить; бронь с OTA не может тронуть вообще.

editBooking (даты, цена, контакты, заметка): только владелец, и только когда source === MANUAL (см. выше), в любом статусе кроме REJECTED. Поле status из BookingEditDto убирается — смена статуса только через отдельную мутацию.

UX подтверждения

Владелец не выбирает статус вручную — одно действие «Подтвердить» на заявке в BID:

Оплата есть

BID → WAIT_PAYMENT. Клиент платит → система по колбэку сама переводит → BOOKED.

Оплаты нет

BID → BOOKED напрямую, WAIT_PAYMENT полностью пропускается.

Каскадная авто-отмена

  1. Любая бронь на объекте переходит в BOOKED.
  2. Сервис ищет на том же housing_id все другие брони не BOOKED/REJECTED, чьи даты пересекаются (exclusive-end, как в checkRangeOverlap).
  3. Переводит их в REJECTED, reason_rejected = системный текст, changed_by_user_id = null.
Реализация

Должно идти в одной транзакции с переходом в BOOKED (SELECT ... FOR UPDATE на бронях объекта в диапазоне дат) — иначе гонка при параллельном подтверждении двух пересекающихся заявок.

Мутации

МутацияКтоПравило
addBooking(data)ownerТолько свой объект (housing.user_id === caller.id — сейчас проверки нет, добавляется). Статус на выбор. source: MANUAL.
addBid(data)гостьЛюбой housing_id. Статус всегда BID. user_id = housing.user_id, client_id = caller, source: GUEST.
changeBookingStatus(id, status, reason?)owner/clientЕдиная точка для всех переходов — граф, OTA-блокировка, история, каскад.
editBooking(id, data)ownerПрочие поля. Только при source === MANUAL (см. «Права владельца по source»). status убран из DTO.
deleteBookingУбирается совсем — REJECTED с историей покрывает смысл.
Найденный пробел (независимо от этой задачи)

Backend сейчас вообще не проверяет владение housing_id при addBooking — любой авторизованный пользователь может прямым GraphQL-запросом создать бронь на чужой объект. Для гостя (addBid) это ожидаемо; для владельца, создающего вручную, — дыра, закрывается проверкой из раздела «Мутации».

Гостевой флоу — переписывается

Форма выбора дат на самой странице объекта (лендинг) в этот флоу не входит и не меняется — важно только то, что происходит после перехода на confirm.vue.

Было

Переход на confirm.vue → редактируемая форма, live-цена → «Оплатить» → addBooking(status: BOOKING) → список броней.

Станет

На confirm.vue гость попадает одним из трёх путей — только что зарегистрировался, залогинился существующим аккаунтом, или уже был авторизован. Во всех случаях: сразу, без формы, addBid с параметрами из query → редирект в чат (popup_show_booking). confirm.vue в текущем виде не нужен.

Интеграция с чатом

BookingService.getBookingInfo сейчас фильтрует status: BOOKING — фильтр убирается, чат открывается с момента BID, доступен и после REJECTED. Проверка допуска в bid-chat-service's chat.service.ts меняется:

housing.user_id === user.id || booking.client_id === user.id

Права на списки

bookings.user_id = :userId
OR housing.user_id = :userId
OR bookings.client_id = :userId

Карта файлов

ФайлИзменение
shared/catalogs/booking/status.tsНовый набор статусов
shared/catalogs/booking/source.tsПереопределить на MANUAL/GUEST/OTA
shared/catalogs/booking/ota-platform.tsНовый catalog: AVITO/SUTOCHNO
backend/.../booking.entity.tsclient_id, reason_rejected, ota_platform
backend/.../booking-status-history.entity.tsНовая сущность + модуль/сервис
backend/.../booking.service.tsadd/addBid/переход статуса/каскад/addUserFilter/проверка владения/проверка source в edit и отмене/убрать delete()
backend/.../booking.resolver.tsНовые мутации, убрать deleteBooking
backend/.../booking.controller.tsСнять фильтр по статусу в getBookingInfo
bid-chat-service/.../chat.service.tsПроверка допуска owner/client
front/.../confirm.vueПереписать: авто-создание без формы + редирект в чат
front/.../add.vue, api/booking/delete.jsУбрать кнопку/хелпер удаления → действие «Отменить»
shared/ui/booking/form/booking-status.vueЗаменить raw-статус-пикер на кнопки-действия
backend/test/booking.e2e-spec.tsМассовая правка BOOKING→BOOKED + новые кейсы

Переименование BOOKING → BOOKED — 10–12 мест только в TS-сравнениях статуса; pricing.sql не затронут.

Открытые вопросы

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