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

POST /api/v1/favorites — Добавить объект недвижимости в избранное

Создаёт запись Favorite («лайк») для текущего владельца (пользователь по JWT или гость по X-Anonymous-Id) с необязательной личной заметкой (≤ 4000 символов). Операция идемпотентна по паре «владелец + объект недвижимости» (INV-FAV-002): повторный вызов не создаёт дубль и возвращает существующую запись (обновляя заметку, если она передана и отличается).

Назначение

Кнопка «сердечко» на карточке объекта недвижимости и странице объекта недвижимости. Владелец (авторизованный пользователь или гость) отмечает объект недвижимости как избранный и может приложить личную заметку до 4000 символов. Возвращается идентификатор записи и её актуальное состояние.

Metadata

ПолеЗначение
Сервис/контекстfavorites
API areapublic
Feature groupfavorites
Статусdraft
Документdocs/06-services/favorites/api/public/add-favorite.md

Актор и доступ

ПроверкаЗначение
Actor typeAnonymous (гость по X-Anonymous-Id) или User (JWT)
Auth policy / Permissionsnone (self-scope): владелец создаёт только собственную запись
Scope (RBAC)не применяется
Record-levelновая запись получает владельца из контекста запроса; чужого владельца задать невозможно (поля UserId/AnonymousId не принимаются из body)
Tenant isolationне применяется — сущности вне tenant (ADR-0052 п.2)
Auditcreate: CreatedAt/UpdatedAt; факт add уходит в user-activity событием pin.favorites.favorite-added.v1

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/favorites
Success status201 (создана новая запись) / 200 (идемпотентный повтор — запись уже существовала)
Idempotency headerне применяется — естественная идемпотентность по бизнес-ключу (owner, PropertyObjectId), INV-FAV-002
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity (JWT claim sub)Guid?Условнозаполнен при авторизованном запросе; приоритет над AnonymousIdвладелец — только сам актор
AnonymousIdheader X-Anonymous-IdGuid?Условновалидный Guid ≠ Guid.Empty; используется при отсутствии JWTнедоверенный вход; запись создаётся только в эту сессию
PropertyObjectIdbody propertyObjectIdGuidДа!= Guid.Empty; существование объекта недвижимости не проверяется синхронно (INV-FAV-004, ADR-0056)недоверенный вход — мёртвые ссылки фильтрует выдача каталога
Notebody notestring?Нетпосле trim — null или 1..4000 символов; пустая строка нормализуется в null (INV-FAV-003)приватные данные: не логируются, не попадают в события

Модель ответа FavoriteResult

ПолеТипОбязательностьИсточникMaskingОписание
FavoriteIdGuidДаFavorite.IdИдентификатор записи (новой или существующей).
PropertyObjectIdGuidДаFavorite.PropertyObjectIdОбъект недвижимости.
Notestring?НетFavorite.Noteвидна только владельцуАктуальная заметка после операции.
CreatedAtDateTimeOffsetДаFavorite.CreatedAtДата добавления.
UpdatedAtDateTimeOffsetДаFavorite.UpdatedAtДата последнего изменения.
WasCreatedboolДаcomputedtrue — создана новая запись (HTTP 201); false — идемпотентный повтор (HTTP 200).

Алгоритм

  1. Контекст и доступ. Разрешить владельца: если запрос авторизован — UserId из JWT; иначе — гостевая сессия AnonymousId из заголовка X-Anonymous-Id. Если не разрешён ни тот, ни другой — отказ FAVORITES_OWNER_UNRESOLVED.
  2. Проверка входа. PropertyObjectId != Guid.Empty; Notenull или 1..4000 символов после trim, иначе отказ FAVORITES_NOTE_TOO_LONG (INV-FAV-003). Существование PropertyObjectId синхронно не проверяется (INV-FAV-004, ADR-0056). Статуса у Favorite нет — проверка состояния не выполняется.
  3. Что читаем. По бизнес-ключу «владелец + объект недвижимости» ищется существующая запись (идемпотентность INV-FAV-002); её отсутствие допустимо.
  4. Бизнес-правила и маппинг. Ветвление:
    • запись уже есть (идемпотентный повтор): если Note передана и отличается — заметка обновляется (INV-FAV-003); иначе изменений нет. WasCreated = false.
    • записи нет: создаётся новая запись Favorite с ровно одним владельцем (INV-FAV-001, страховка — DB check chk_favorites_owner_xor). Поля: PropertyObjectId из body, Note — нормализованное значение, CreatedAt/UpdatedAt — момент создания. Поднимается доменное событие FavoriteAddedEvent. WasCreated = true.
  5. Что меняется. Новая ветка — добавляется запись. Гонка двойного add (две вкладки) отбивается partial-unique ux_favorites_user_property_object / ux_favorites_anonymous_property_object: проигравший запрос перечитывает запись и возвращает идемпотентный результат (WasCreated = false).
  6. Какие факты/события фиксируются. Только для WasCreated = true публикуется FavoriteAddedKafkaEvent (ContractVersion = 1, topic pin.favorites.favorite-added.v1, AggregateId = FavoriteId, AggregateType = "Favorite"); payload: FavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAt. Без Note (приватные данные — ../../domain/favorite.md). Потребитель — user-activity (ActionEvent «добавил в избранное» → карточка лида CRM, ADR-0054). Идемпотентный повтор события не публикует.
  7. Согласованность. Добавление/правка записи и публикация события фиксируются в одном согласованном изменении.
  8. Результат. Модель FavoriteResult (маппинг 1:1 из сохранённой записи + WasCreated). HTTP: 201 Created при создании (заголовок Location: /api/v1/favorites), 200 OK при повторе.

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияFavoriteAddedKafkaEvent / pin.favorites.favorite-added.v1 / key = FavoriteId; только при создании
Проекции / поискnone (ADR-0056)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
FAVORITES_OWNER_UNRESOLVED400Нет ни JWT, ни валидного X-Anonymous-Id«Не удалось определить владельца избранного»no retry
FAVORITES_NOTE_TOO_LONG400Note после trim длиннее 4000 символов (INV-FAV-003)«Заметка не может превышать 4000 символов»no retry
ValidationError400PropertyObjectId = Guid.Empty; невалидный формат bodyдетали полейno retry
FAVORITES_OWNER_AMBIGUOUS500Нарушение XOR владельца (INV-FAV-001) — программная ошибка, недостижимо через APIgeneric messageno retry, алерт
InternalError500Отказ хранилища / некэтчнутая гонкаgeneric messageretry — операция идемпотентна

Предусловия и постусловия

  • Предусловие. Разрешён ровно один владелец (JWT-пользователь или гость по X-Anonymous-Id); существование PropertyObjectId и статус объекта недвижимости роли не играют (INV-FAV-004).
  • Постусловие (создание). В хранилище есть ровно одна запись Favorite пары «владелец + объект недвижимости» (INV-FAV-002); опубликован факт pin.favorites.favorite-added.v1; счётчик избранного владельца вырос на 1.
  • Постусловие (идемпотентный повтор). Число записей не изменилось; заметка равна последнему переданному непустому значению (или прежней, если note не прислан); события не публиковались.

Acceptance-критерии

  • Given авторизованный пользователь, у которого объекта X ещё нет в избранном, When POST /favorites {propertyObjectId: X}, Then создаётся запись с владельцем UserId, ответ 201, WasCreated = true, публикуется FavoriteAddedKafkaEvent (key = FavoriteId, без Note).
  • Given гость с валидным X-Anonymous-Id и объект X не в избранном, When POST /favorites {propertyObjectId: X, note: "N"}, Then запись создаётся с владельцем AnonymousId, заметка N сохранена, ответ 201, событие содержит AnonymousId и не содержит Note.
  • Given запись владельца на объект X уже существует, When повторный POST {propertyObjectId: X}, Then ответ 200, WasCreated = false, дубль не создан, событие не публикуется.
  • Given запись владельца на X с заметкой N1, When POST {propertyObjectId: X, note: N2} (N2 ≠ N1), Then заметка обновлена на N2, ответ 200, WasCreated = false.
  • Given note длиннее 4000 символов после trim, When POST, Then отказ FAVORITES_NOTE_TOO_LONG (400), запись не создана.
  • Given запрос без JWT и без валидного X-Anonymous-Id, When POST, Then отказ FAVORITES_OWNER_UNRESOLVED (400).
  • Given переданы одновременно JWT и X-Anonymous-Id, When POST, Then владельцем становится UserId (приоритет JWT), гостевая запись не создаётся.
  • Given две вкладки шлют POST одной пары «владелец + объект» одновременно, When обе доходят до вставки, Then ровно одна создаёт запись и публикует событие, вторая упирается в partial-unique и возвращает идемпотентный WasCreated = false без второго события.

Совместимость и наблюдаемость

  • Body-поля additive-only; смена семантики note (например, форматирование) — breaking.
  • Logs: info FavoriteAdded {FavoriteId} {owner_type}без Note и без сырого X-Anonymous-Id на уровнях выше debug.
  • Metrics: favorites_add_total{owner_type, was_created}, latency histogram (SLO p99 < 200 мс).
  • Traces: сквозной correlation через traceparent.
  • Dashboard/alerts/runbook — ../../README.md.

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

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

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