UC-FAV-001. Гость добавляет объект недвижимости в избранное (гостевая сессия)
Назначение
Описывает сценарий добавления объекта недвижимости в избранное неавторизованным гостем: даже без
входа пользователь может отмечать объекты, а владельцем записи становится гостевая сессия
(AnonymousId, выданный контекстом user-activity), а не пользователь. Сценарий покрывает и зеркальные
операции гостя — просмотр списка и удаление из избранного (альтернативные потоки A2/A3). Связан с
контекстом favorites.
| Поле | Значение |
|---|
| ID | UC-FAV-001 |
| Контекст | favorites (см. docs/06-services/favorites/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Гость (аноним); тот же поток работает для Инвестора/Агента — тогда владелец определяется JWT (приоритет у JWT над заголовком, ../README.md «API») |
| Permissions / scope | self-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 |
Основной Поток
- Гость нажимает «сердечко» на карточке объекта недвижимости.
- Интерфейс отправляет
POST /favorites с телом AddFavoriteForm { propertyObjectId: Guid (обязателен, != Guid.Empty), note?: string (null | 1..4000 символов после trim) } и заголовком
X-Anonymous-Id: <Guid сессии> (JWT отсутствует — гость).
- Система определяет владельца записи: при наличии JWT владельцем становится
UserId актора (JWT
приоритетнее заголовка — X-Anonymous-Id в этом случае игнорируется); при отсутствии JWT владельцем
становится AnonymousId из заголовка. Если нет ни JWT, ни X-Anonymous-Id — владельца определить
нельзя → ValidationError (поток A5).
- Проверка входа:
propertyObjectId != Guid.Empty (иначе ValidationError); заметка нормализуется
(trim, пустая строка → null) и не длиннее 4000 символов (INV-FAV-003, иначе FAVORITES_NOTE_TOO_LONG).
Существование объекта недвижимости синхронно не проверяется (INV-FAV-004, ADR-0056 — нет обращения
в catalog на каждый лайк); гостевые записи изолированы самим значением AnonymousId.
- Система ищет запись избранного по паре (
AnonymousId, PropertyObjectId). Отсутствие записи
допустимо; если запись уже есть — идемпотентная ветка A1 (INV-FAV-002).
- Если записи нет — создаётся запись избранного сессии:
UserId = null (XOR-инвариант INV-FAV-001),
AnonymousId из заголовка, PropertyObjectId и нормализованная заметка из формы; фиксируется
доменный факт FavoriteAddedEvent.
- Гонка двойного клика невозможна: уникальность пары (
AnonymousId, PropertyObjectId) гарантирует
partial-unique индекс ux_favorites_anonymous_property_object (поток A4).
- Факт добавления публикуется интеграционным событием
FavoriteAddedKafkaEvent в топик
pin.favorites.favorite-added.v1 (payload: FavoriteId, UserId = null, AnonymousId,
PropertyObjectId, OccurredAt — без Note, приватные данные, ../domain/favorite.md «События»).
- Возвращается
FavoriteResult: FavoriteId, PropertyObjectId, HasNote (текст заметки отдаётся
только в GET /favorites владельцу), CreatedAt.
- Интерфейс помечает «сердечко» активным и обновляет список
/favorites.
- Consumer
user-activity (модуль actions, ADR-0054) получает favorite-added и создаёт
ActionEvent «добавил в избранное» в истории действий гостевой сессии; после будущей авторизации и
связывания сессии действия попадут в карточку лида (user-activity → crm). Публикация асинхронна:
ответ POST /favorites («сердечко» активно) пользователь видит сразу, а ActionEvent в истории и
карточке лида появляется < 1 мин (at-least-once, дедупликация по MessageId); недоступность шины
добавление не блокирует — лента действий отстаёт до восстановления (criticality low).
Альтернативные Потоки
A1. Повторное добавление того же объекта недвижимости (идемпотентность INV-FAV-002)
- Шаг 5 нашёл существующую запись пары (
AnonymousId, PropertyObjectId).
- Если
note в форме передана и отличается — заметка записи обновляется (UpdatedAt := now,
конкурентная правка разрешается оптимистической блокировкой), поднимается доменный факт
FavoriteNoteUpdatedEvent; событие favorite-added повторно не публикуется (факт добавления уже
был; изменение заметки в Kafka не уходит).
- Если
note не передана (null в форме) — изменений нет.
- Ответ —
FavoriteResult существующей записи (200, не 409). Завершается.
A2. Гость удаляет объект недвижимости из избранного
- Гость снимает «сердечко» →
DELETE /favorites/{propertyObjectId} + X-Anonymous-Id.
- Система ищет запись пары (
AnonymousId, PropertyObjectId). Записи нет → идемпотентный успех
(204; удаление отсутствующего — не ошибка). Запись есть → запись физически удаляется (soft delete не
применяется — ../domain/favorite.md), факт удаления публикуется событием
FavoriteRemovedKafkaEvent (pin.favorites.favorite-removed.v1, payload без Note). Заметка удаляется
вместе с записью (интерфейс предупреждает, если HasNote). Завершается.
A3. Гость открывает список избранного
GET /favorites + X-Anonymous-Id → система возвращает записи владельца (фильтр по AnonymousId,
сортировка по CreatedAt desc). Это чтение — изменений и событий нет.
- Ответ: список
{ favoriteId, propertyObjectId, note, createdAt } — заметки видны только владельцу.
- Интерфейс добирает display-данные объектов недвижимости батч-запросом в
catalog по списку
PropertyObjectId (граница — ../README.md «НЕ делает»); мёртвые/скрытые объекты каталог
помечает признаком недоступности. Завершается.
A4. Гонка двойного добавления (double click / две вкладки)
- Оба запроса не нашли запись до вставки первого; второй наталкивается на уникальный индекс
ux_favorites_anonymous_property_object (конфликт уникальности).
- Система перехватывает конфликт, перечитывает запись и возвращает идемпотентный успех по ветке A1.
Дубликат невозможен. Завершается.
A5. Заголовок X-Anonymous-Id отсутствует (и нет JWT)
- Владельца определить нельзя →
ValidationError (400). Интерфейс запрашивает/восстанавливает сессию
в user-activity и повторяет запрос. Завершается.
A6. Позже гость авторизуется
- Гостевые записи (
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 | Условие | Поведение клиента |
|---|
ValidationError | 400 | propertyObjectId == Guid.Empty; нет ни JWT, ни X-Anonymous-Id | Восстановить сессию, повторить |
FAVORITES_NOTE_TOO_LONG | 400 | note длиннее 4000 символов после trim (INV-FAV-003) | Показать счётчик символов, не отправлять |
5xx | 500/503 | Хранилище недоступно | «Сердечко» откатывается; retry с backoff; каталог продолжает работать (criticality low) |
Ошибок «объект недвижимости не существует» нет — существование объекта недвижимости не проверяется (INV-FAV-004).
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
FavoriteAddedEvent | Успешное создание новой записи | Внутренние (метрики; построение Kafka-события) |
FavoriteNoteUpdatedEvent | A1: переданная note изменила значение | Внутренние (метрики; без Kafka) |
FavoriteRemovedEvent | A2: успешное удаление | Внутренние |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
FavoriteAddedKafkaEvent (ContractVersion = 1) | pin.favorites.favorite-added.v1 | Создание записи (не при идемпотентном повторе A1) | user-activity (actions → ActionEvent → карточка лида CRM) |
FavoriteRemovedKafkaEvent (ContractVersion = 1) | pin.favorites.favorite-removed.v1 | A2: удаление записи | 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 изоляции |
| AC6 | favorite-added не содержит Note | контрактный тест события |
| AC7 | Удаление отсутствующего объекта недвижимости — 204 без ошибки (A2) | интеграционный тест |
| AC8 | GET /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 (простота нагрузки).