ADR 001 Tour Model - PlutoGr/TravelAgency GitHub Wiki

ADR-001. Модель тура, этап 1

Статус: принято, реализовано в #36, #37 и #38 (PR #58, main 591be7d). Эпик #43. Автор: Вирт (тимлид и архитектор).

1. Статусы и источник

  • Status принимает значения Draft, Published и Unpublished. Значение PendingReview зарезервировано под премодерацию и на этапе 1 не используется.
  • Source принимает значения Manager и Automation, на этапе 1 всегда Manager. OwnerId указывает на менеджера и может быть пустым только у туров из автоматизации.
  • Удалить можно только Draft, на остальные статусы DELETE отвечает 409. Премодерации нет.
  • Опубликованный тур всегда проходит проверку публикации. Правка, которая её ломает, получает 422, и тур сам с публикации не снимается.

2. Поля и таблицы (Catalog)

  • В Tour добавляются поля ShortDescription (до 300 символов), Description, DepartureCity, MealPlan (RO, BB, HB, FB, AI или UAI), AccommodationText и PublishedAt.
  • Конкурентные правки ловим через столбец Tours.Version (bigint, concurrency token в EF). Каждое изменение тура, включая шаги мастера, которые меняют только дочерние таблицы, увеличивает его на 1 (Touch). Версия уходит клиенту как ETag, проверяется по If-Match (W/ и кавычки допускаются), при расхождении приходит 409. Если версия сменилась между чтением и сохранением, EF ловит это условием WHERE Version = <старая> в UPDATE, и ответ тоже 409. Если If-Match нет, приходит 428 (на всех PUT шагов, publish, unpublish и DELETE). POST черновика If-Match не требует.
  • TourPrice переименовывается в TourOffer (Предложение из Product-Vision: тур на конкретные даты с ценой; опции добавятся на этапе 2). Имя Proposal занято сущностью Booking. Переименование делается через RenameTable и RenameColumn, без DropTable.
  • Новые дочерние таблицы:
    • TourDay: программа по дням;
    • TourInclusion: пункты «включено» и «не включено»;
    • TourImage: MediaFileId, порядок, IsCover, alt. Обложка одна, это обеспечивает уникальный индекс.
  • Задел на этап 2 (составные туры): таблица TourComponents и необязательный ComponentId у дней и включений. В ответе страницы тура поле components: [].

3. Проверка публикации

Коды лежат в Catalog.Domain (TourPublishRequirementCodes), это единственный источник: title, shortDescription, description, program.dayCount (дней ровно DurationDays), inclusions.included (не меньше одного), mealPlan, accommodation, offers.future (хотя бы одно предложение с будущей датой), images.cover, images.minCount (не меньше 3). Ответ 422 в формате ProblemDetails: type: "https://travelagency/errors/tour-not-publishable", коды в расширении missing. На фронте один файл «код → текст» и тест vitest на полноту.

4. API

Публичное (только Published):

  • GET /api/v1/catalog/tours: фильтры, сортировка, цена «от», ближайшая дата, до 5 превью;
  • GET /api/v1/catalog/tours/{id}: для Draft и Unpublished отвечает 404;
  • GET /api/v1/catalog/tours/cards?ids= (до 50 id): для Published полная карточка, для Unpublished минимальная (id, title, обложка, available: false), черновики и несуществующие id не попадают в ответ.

Управление, /api/v1/catalog/manage/tours (роли менеджер и админ):

  • список ?status&source, GET {id} с чек-листом готовности, POST (черновик);
  • PUT {id}/basics|description|program|conditions|prices|images (шаги мастера 3 и 4 сохраняются одним conditions);
  • POST {id}/publish, POST {id}/unpublish, DELETE {id}. Менеджер видит только свои туры и на чужой получает 403. Админ видит все туры и может снять с публикации любой. Ошибки 403, 409, 422 и 428 приходят в формате ProblemDetails через CatalogExceptionMapper.

Gateway:

  • catalog-manage-read-route пускает к /catalog/manage/** только менеджеров и админов и проверяется раньше публичного GET-маршрута, гость получает 401;
  • media-files-public-route анонимный, только GET;
  • media/manage/files закрыт ролями менеджера и админа. Старые POST, PUT, PATCH и DELETE на /catalog/tours удаляются в одном PR с задачей 5.

5. Фото (Media), вариант Б

  • На PUT images с id файла, которого нет у владельца тура, ответ 422. Ширина обложки на publish берётся из Media (width), код images.coverMinWidth, ширина 0 не проходит.
  • Загрузка POST /api/v1/media/upload?purpose=tour-image: jpeg, png и webp, до 10 МБ. Превью w200, w800 и w1600.
  • У файла есть флаг IsPublic, по умолчанию false. media.proto: MarkMediaFilesPublic(ids) работает только в одну сторону. Catalog вызывает его при publish и при PUT images у опубликованного тура, сначала вызов и только при успехе сохранение статуса. Если Media недоступен, приходит 503, статус не меняется, повтор идемпотентен.
  • Публичная отдача GET /api/v1/media/files/{id}/{w200|w800|w1600}: потоком из хранилища, Cache-Control: public, max-age=31536000, immutable. Для непубличного файла 404. После снятия тура с публикации фото остаётся публичным, поэтому избранное не ломается.
  • GET /api/v1/media/manage/files/{id}/{size} доступен владельцу файла и админу, Cache-Control: private, no-store.
  • Ссылки http://minio:9000 больше не используются. Bucket закрыт, замена MinIO (#11) затрагивает только IStorageService.
  • media.proto GetMediaFiles(ids) работает по сервисному токену GRPC_INTERNAL_SERVICE_TOKEN, наружу через Gateway не открывается. REST Media слушает на 8080, gRPC на 8081 (HTTP/2, #50).
  • nginx не меняется: location /api/ с client_max_body_size 12m. На 413 фронт показывает понятный текст.

6. Контракты Booking (catalog.proto, обратно совместимо)

  • GetTourSnapshot отдаёт found = true только для Published. Его используют создание заявки и избранное.
  • Новый GetTourSnapshotForExistingBooking отдаёт снимок для Published и Unpublished, а для Draft отвечает found = false. Его вызывает только CreateProposalCommandHandler.
  • В TourSnapshotResponse добавляется string status = 9. Поля цены в снимке не переименовываются.
  • На недоступный тур Booking отвечает 422 tour-unavailable в формате ProblemDetails.

7. Миграция и сидер

  • Тур с IsActive=true получает статус Published, с false статус Unpublished. Демо-туры получают владельца [email protected] и источник Manager. ImageUrl удаляется позже, отдельной миграцией.
  • Перед мержем задачи 1 DevOps прогоняет efbundle (linux-x64, self-contained, строка подключения через --connection) на дампе во временном контейнере и сверяет агрегаты по tour-offer-migration-check.sql до и после.
  • Сидер запускается по Seeding:DemoCatalog или ASPNETCORE_SEED_DATA в Catalog и Media, поэтому на сервере ничего менять не нужно. Он идемпотентно дозаполняет 5 демо-туров, публикует их через доменный Publish() и кладёт демо-фото с фиксированными id. Фото со свободной лицензией, WebP до 400 КБ, источники записаны в README.
  • В compose добавляются токен для media-service и GrpcClients__MediaServiceUrl для catalog-service, это делается в PR задачи 3.
  • Ограничение сидеров (#70). Identity находит демо-аккаунты по email и сохраняет уже существующий id. Catalog и Media ставят владельцем константу DemoSeedIds.ManagerId. На чистой базе это один и тот же id, а на базе, созданной до фиксированных id, ссылки расходятся: тур и фото принадлежат пользователю, которого нет. Код не меняем, потому что прод не сидируется. На старой dev-базе это исправляется разовой правкой OwnerId в Tours и MediaFiles, так сделали 2026-10-08.
  • Учебный кейс. У сервисов нет общей базы, они ссылаются на пользователя только по id. Поэтому все сидеры должны брать id из одного источника. Тесты на чистой базе такое расхождение не ловят, его видно только на базе с историей.

8. Принятые решения

  1. Лимиты фото: до 20 на тур, до 10 МБ на файл, обложка шириной от 1280 px.
  2. Проживание на этапе 1 хранится текстом, отель как сущность на этапе 2.
  3. Публикация без премодерации.

9. Почему Version, а не xmin

Сначала версию собирались брать из системного столбца Postgres xmin. Но xmin меняется только когда переписывается сама строка Tours. Если шаг мастера меняет одни фото или дни программы, строка тура остаётся прежней, xmin не растёт, и устаревший If-Match прошёл бы проверку. Явный Version увеличивает доменный метод Touch при любом изменении агрегата, поэтому ETag описывает тур целиком. Миграция AddTourVersion добавила столбец со значением 0, новые черновики начинаются с 1.

⚠️ **GitHub.com Fallback** ⚠️