POST /api/v1/favorites — Добавить объект недвижимости в избранное
Создаёт запись Favorite («лайк») для текущего владельца (пользователь по JWT или гость по
X-Anonymous-Id) с необязательной личной заметкой (≤ 4000 символов). Операция идемпотентна по паре
«владелец + объект недвижимости» (INV-FAV-002): повторный вызов не создаёт дубль и возвращает существующую запись
(обновляя заметку, если она передана и отличается).
Назначение
Кнопка «сердечко» на карточке объекта недвижимости и странице объекта недвижимости. Владелец (авторизованный
пользователь или гость) отмечает объект недвижимости как избранный и может приложить личную заметку до 4000
символов. Возвращается идентификатор записи и её актуальное состояние.
| Поле | Значение |
|---|
| Сервис/контекст | favorites |
| API area | public |
| Feature group | favorites |
| Статус | draft |
| Документ | docs/06-services/favorites/api/public/add-favorite.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (гость по X-Anonymous-Id) или User (JWT) |
| Auth policy / Permissions | none (self-scope): владелец создаёт только собственную запись |
| Scope (RBAC) | не применяется |
| Record-level | новая запись получает владельца из контекста запроса; чужого владельца задать невозможно (поля UserId/AnonymousId не принимаются из body) |
| Tenant isolation | не применяется — сущности вне tenant (ADR-0052 п.2) |
| Audit | create: CreatedAt/UpdatedAt; факт add уходит в user-activity событием pin.favorites.favorite-added.v1 |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/favorites |
| Success status | 201 (создана новая запись) / 200 (идемпотентный повтор — запись уже существовала) |
| Idempotency header | не применяется — естественная идемпотентность по бизнес-ключу (owner, PropertyObjectId), INV-FAV-002 |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity (JWT claim sub) | Guid? | Условно | заполнен при авторизованном запросе; приоритет над AnonymousId | владелец — только сам актор |
AnonymousId | header X-Anonymous-Id | Guid? | Условно | валидный Guid ≠ Guid.Empty; используется при отсутствии JWT | недоверенный вход; запись создаётся только в эту сессию |
PropertyObjectId | body propertyObjectId | Guid | Да | != Guid.Empty; существование объекта недвижимости не проверяется синхронно (INV-FAV-004, ADR-0056) | недоверенный вход — мёртвые ссылки фильтрует выдача каталога |
Note | body note | string? | Нет | после trim — null или 1..4000 символов; пустая строка нормализуется в null (INV-FAV-003) | приватные данные: не логируются, не попадают в события |
Модель ответа FavoriteResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
FavoriteId | Guid | Да | Favorite.Id | — | Идентификатор записи (новой или существующей). |
PropertyObjectId | Guid | Да | Favorite.PropertyObjectId | — | Объект недвижимости. |
Note | string? | Нет | Favorite.Note | видна только владельцу | Актуальная заметка после операции. |
CreatedAt | DateTimeOffset | Да | Favorite.CreatedAt | — | Дата добавления. |
UpdatedAt | DateTimeOffset | Да | Favorite.UpdatedAt | — | Дата последнего изменения. |
WasCreated | bool | Да | computed | — | true — создана новая запись (HTTP 201); false — идемпотентный повтор (HTTP 200). |
Алгоритм
- Контекст и доступ. Разрешить владельца: если запрос авторизован —
UserId из JWT; иначе — гостевая
сессия AnonymousId из заголовка X-Anonymous-Id. Если не разрешён ни тот, ни другой — отказ
FAVORITES_OWNER_UNRESOLVED.
- Проверка входа.
PropertyObjectId != Guid.Empty; Note — null или 1..4000 символов после trim,
иначе отказ FAVORITES_NOTE_TOO_LONG (INV-FAV-003). Существование PropertyObjectId синхронно не
проверяется (INV-FAV-004, ADR-0056). Статуса у Favorite нет — проверка состояния не выполняется.
- Что читаем. По бизнес-ключу «владелец + объект недвижимости» ищется существующая запись
(идемпотентность INV-FAV-002); её отсутствие допустимо.
- Бизнес-правила и маппинг. Ветвление:
- запись уже есть (идемпотентный повтор): если
Note передана и отличается — заметка обновляется
(INV-FAV-003); иначе изменений нет. WasCreated = false.
- записи нет: создаётся новая запись
Favorite с ровно одним владельцем (INV-FAV-001, страховка — DB
check chk_favorites_owner_xor). Поля: PropertyObjectId из body, Note — нормализованное значение,
CreatedAt/UpdatedAt — момент создания. Поднимается доменное событие FavoriteAddedEvent.
WasCreated = true.
- Что меняется. Новая ветка — добавляется запись. Гонка двойного add (две вкладки) отбивается
partial-unique
ux_favorites_user_property_object / ux_favorites_anonymous_property_object:
проигравший запрос перечитывает запись и возвращает идемпотентный результат (WasCreated = false).
- Какие факты/события фиксируются. Только для
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). Идемпотентный повтор события не публикует.
- Согласованность. Добавление/правка записи и публикация события фиксируются в одном согласованном
изменении.
- Результат. Модель
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_UNRESOLVED | 400 | Нет ни JWT, ни валидного X-Anonymous-Id | «Не удалось определить владельца избранного» | no retry |
FAVORITES_NOTE_TOO_LONG | 400 | Note после trim длиннее 4000 символов (INV-FAV-003) | «Заметка не может превышать 4000 символов» | no retry |
ValidationError | 400 | PropertyObjectId = Guid.Empty; невалидный формат body | детали полей | no retry |
FAVORITES_OWNER_AMBIGUOUS | 500 | Нарушение XOR владельца (INV-FAV-001) — программная ошибка, недостижимо через API | generic message | no retry, алерт |
InternalError | 500 | Отказ хранилища / некэтчнутая гонка | generic message | retry — операция идемпотентна |
Предусловия и постусловия
- Предусловие. Разрешён ровно один владелец (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.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы