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

UC-FAV-001. Гость добавляет объект недвижимости в избранное (гостевая сессия)

Назначение

Описывает сценарий добавления объекта недвижимости в избранное неавторизованным гостем: даже без входа пользователь может отмечать объекты, а владельцем записи становится гостевая сессия (AnonymousId, выданный контекстом user-activity), а не пользователь. Сценарий покрывает и зеркальные операции гостя — просмотр списка и удаление из избранного (альтернативные потоки A2/A3). Связан с контекстом favorites.

Metadata

ПолеЗначение
IDUC-FAV-001
Контекстfavorites (см. docs/06-services/favorites/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольГость (аноним); тот же поток работает для Инвестора/Агента — тогда владелец определяется JWT (приоритет у JWT над заголовком, ../README.md «API»)
Permissions / scopeself-scope без явных permissions: гость владеет записями своей сессии по заголовку X-Anonymous-Id
Tenantне tenanted — данные гостя/инвестора вне tenant (ADR-0052 п.2)
Record-levelобязательный фильтр AnonymousId = X-Anonymous-Id (для пользователя — UserId актора) в каждой операции — INV-FAV-005

Предусловие

  • У гостя есть активная гостевая сессия: user-activity выдал AnonymousId, интерфейс шлёт его в каждом запросе заголовком X-Anonymous-Id (контракт — docs/06-services/user-activity/README.md).
  • Гость видит каталог/карточку объекта недвижимости и знает PropertyObjectId объекта.

Триггер

ПолеЗначение
ИсточникUI button
Триггерящий элементКнопка «сердечко» на карточке объекта недвижимости (каталог /lots, карточка /lots/[id]/info, шахматка) и страница /favorites

Основной Поток

  1. Гость нажимает «сердечко» на карточке объекта недвижимости.
  2. Интерфейс отправляет POST /favorites с телом AddFavoriteForm { propertyObjectId: Guid (обязателен, != Guid.Empty), note?: string (null | 1..4000 символов после trim) } и заголовком X-Anonymous-Id: <Guid сессии> (JWT отсутствует — гость).
  3. Система определяет владельца записи: при наличии JWT владельцем становится UserId актора (JWT приоритетнее заголовка — X-Anonymous-Id в этом случае игнорируется); при отсутствии JWT владельцем становится AnonymousId из заголовка. Если нет ни JWT, ни X-Anonymous-Id — владельца определить нельзя → ValidationError (поток A5).
  4. Проверка входа: propertyObjectId != Guid.Empty (иначе ValidationError); заметка нормализуется (trim, пустая строка → null) и не длиннее 4000 символов (INV-FAV-003, иначе FAVORITES_NOTE_TOO_LONG). Существование объекта недвижимости синхронно не проверяется (INV-FAV-004, ADR-0056 — нет обращения в catalog на каждый лайк); гостевые записи изолированы самим значением AnonymousId.
  5. Система ищет запись избранного по паре (AnonymousId, PropertyObjectId). Отсутствие записи допустимо; если запись уже есть — идемпотентная ветка A1 (INV-FAV-002).
  6. Если записи нет — создаётся запись избранного сессии: UserId = null (XOR-инвариант INV-FAV-001), AnonymousId из заголовка, PropertyObjectId и нормализованная заметка из формы; фиксируется доменный факт FavoriteAddedEvent.
  7. Гонка двойного клика невозможна: уникальность пары (AnonymousId, PropertyObjectId) гарантирует partial-unique индекс ux_favorites_anonymous_property_object (поток A4).
  8. Факт добавления публикуется интеграционным событием FavoriteAddedKafkaEvent в топик pin.favorites.favorite-added.v1 (payload: FavoriteId, UserId = null, AnonymousId, PropertyObjectId, OccurredAtбез Note, приватные данные, ../domain/favorite.md «События»).
  9. Возвращается FavoriteResult: FavoriteId, PropertyObjectId, HasNote (текст заметки отдаётся только в GET /favorites владельцу), CreatedAt.
  10. Интерфейс помечает «сердечко» активным и обновляет список /favorites.
  11. Consumer user-activity (модуль actions, ADR-0054) получает favorite-added и создаёт ActionEvent «добавил в избранное» в истории действий гостевой сессии; после будущей авторизации и связывания сессии действия попадут в карточку лида (user-activitycrm). Публикация асинхронна: ответ POST /favorites («сердечко» активно) пользователь видит сразу, а ActionEvent в истории и карточке лида появляется < 1 мин (at-least-once, дедупликация по MessageId); недоступность шины добавление не блокирует — лента действий отстаёт до восстановления (criticality low).

Альтернативные Потоки

A1. Повторное добавление того же объекта недвижимости (идемпотентность INV-FAV-002)

  1. Шаг 5 нашёл существующую запись пары (AnonymousId, PropertyObjectId).
  2. Если note в форме передана и отличается — заметка записи обновляется (UpdatedAt := now, конкурентная правка разрешается оптимистической блокировкой), поднимается доменный факт FavoriteNoteUpdatedEvent; событие favorite-added повторно не публикуется (факт добавления уже был; изменение заметки в Kafka не уходит).
  3. Если note не передана (null в форме) — изменений нет.
  4. Ответ — FavoriteResult существующей записи (200, не 409). Завершается.

A2. Гость удаляет объект недвижимости из избранного

  1. Гость снимает «сердечко» → DELETE /favorites/{propertyObjectId} + X-Anonymous-Id.
  2. Система ищет запись пары (AnonymousId, PropertyObjectId). Записи нет → идемпотентный успех (204; удаление отсутствующего — не ошибка). Запись есть → запись физически удаляется (soft delete не применяется — ../domain/favorite.md), факт удаления публикуется событием FavoriteRemovedKafkaEvent (pin.favorites.favorite-removed.v1, payload без Note). Заметка удаляется вместе с записью (интерфейс предупреждает, если HasNote). Завершается.

A3. Гость открывает список избранного

  1. GET /favorites + X-Anonymous-Id → система возвращает записи владельца (фильтр по AnonymousId, сортировка по CreatedAt desc). Это чтение — изменений и событий нет.
  2. Ответ: список { favoriteId, propertyObjectId, note, createdAt } — заметки видны только владельцу.
  3. Интерфейс добирает display-данные объектов недвижимости батч-запросом в catalog по списку PropertyObjectId (граница — ../README.md «НЕ делает»); мёртвые/скрытые объекты каталог помечает признаком недоступности. Завершается.

A4. Гонка двойного добавления (double click / две вкладки)

  1. Оба запроса не нашли запись до вставки первого; второй наталкивается на уникальный индекс ux_favorites_anonymous_property_object (конфликт уникальности).
  2. Система перехватывает конфликт, перечитывает запись и возвращает идемпотентный успех по ветке A1. Дубликат невозможен. Завершается.

A5. Заголовок X-Anonymous-Id отсутствует (и нет JWT)

  1. Владельца определить нельзя → ValidationError (400). Интерфейс запрашивает/восстанавливает сессию в user-activity и повторяет запрос. Завершается.

A6. Позже гость авторизуется

  1. Гостевые записи (AnonymousId) участвуют в слиянии с избранным аккаунта — UC-FAV-002. Смена владельца записи возможна только операцией слияния/импорта (INV-FAV-006).

Постусловие

  • В favorites.favorites существует ровно одна запись пары (AnonymousId, PropertyObjectId) с заполненными Id, PropertyObjectId, Note?, CreatedAt, UpdatedAt; UserId = null (INV-FAV-001).
  • Опубликовано pin.favorites.favorite-added.v1 (при фактическом создании); user-activity записал ActionEvent в историю гостевой сессии.
  • Запись переживёт до 365 дней неактивности (retention-job guest-favorites-cleanup, ../README.md) либо станет аккаунтной при слиянии.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
ValidationError400propertyObjectId == Guid.Empty; нет ни JWT, ни X-Anonymous-IdВосстановить сессию, повторить
FAVORITES_NOTE_TOO_LONG400note длиннее 4000 символов после trim (INV-FAV-003)Показать счётчик символов, не отправлять
5xx500/503Хранилище недоступно«Сердечко» откатывается; retry с backoff; каталог продолжает работать (criticality low)

Ошибок «объект недвижимости не существует» нет — существование объекта недвижимости не проверяется (INV-FAV-004).

Доменные События

СобытиеКогда поднимаетсяSubscribers
FavoriteAddedEventУспешное создание новой записиВнутренние (метрики; построение Kafka-события)
FavoriteNoteUpdatedEventA1: переданная note изменила значениеВнутренние (метрики; без Kafka)
FavoriteRemovedEventA2: успешное удалениеВнутренние

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
FavoriteAddedKafkaEvent (ContractVersion = 1)pin.favorites.favorite-added.v1Создание записи (не при идемпотентном повторе A1)user-activity (actions → ActionEvent → карточка лида CRM)
FavoriteRemovedKafkaEvent (ContractVersion = 1)pin.favorites.favorite-removed.v1A2: удаление записиuser-activity

Payload обоих — без Note (приватные данные владельца).

Acceptance Criteria

#КритерийПроверка
AC1Гость без JWT добавляет объект недвижимости: создаётся запись с AnonymousId = заголовок, UserId = nullинтеграционный тест без токена
AC2Повторный POST /favorites того же объекта недвижимости — 200, вторая запись не создаётся, событие не дублируется (A1)тест идемпотентности + проверка outbox
AC3Гонка двух параллельных add — ровно одна запись (unique-индекс, A4)конкурентный тест
AC4Заметка 4001 символ → FAVORITES_NOTE_TOO_LONG; ровно 4000 — успехпараметризованный тест границы
AC5Гость видит только записи своего AnonymousId; чужой AnonymousId в заголовке не отдаёт чужие заметки иначе как по знанию Guid сессии (INV-FAV-005)тест record-level изоляции
AC6favorite-added не содержит Noteконтрактный тест события
AC7Удаление отсутствующего объекта недвижимости — 204 без ошибки (A2)интеграционный тест
AC8GET /favorites отсортирован по CreatedAt descинтеграционный тест

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

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

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

  • API методы: ../api/public/add-favorite.md, ../api/public/remove-favorite.md, ../api/public/get-my-favorites.md.
  • Domain entity: ../domain/favorite.md — дом полей, INV-FAV-001..006, события.
  • Слияние после входа: UC-FAV-002, ../domain/merge-strategy.md.
  • Импорт localStorage: UC-FAV-004.
  • Границы: ../README.md; сессия — docs/06-services/user-activity/README.md (ADR-0054).
  • Архитектурные решения: ADR-0052 (tenant), ADR-0056 (простота нагрузки).