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

POST /internal/favorites/status — Пакетный статус избранного (3-state, m2m для CRM)

Internal-операция: контекст crm при показе агенту его клиентской базы / карточек объектов недвижимости «в контексте инвестора» пакетно спрашивает, лежит ли каждый из объектов недвижимости в избранном у агента и/или у инвестора. Возвращает по каждой паре (userId, propertyObjectId) только флаг IsFavoritedличные заметки (Note) не отдаются ни при каких условиях и ни по одному владельцу: заметка приватна для владельца и не покидает контекст favorites (INV-FAV-005, ../../domain/favorite.md, раздел безопасности).

Назначение

Единственный смысл — обогащение выдачи агента признаком избранного без нарушения принципа «каталог без персонализации» (ADR-0056, Часть D): публичный каталог и его карточки остаются платформенной проекцией и не меняются, а «состояние в контексте инвестора» собирается композицией в аутентифицированном CRM/agent-слое пакетным вызовом сюда по списку PropertyObjectId.

Флаг избранного трёхзначен на стороне вызывающего (crm) и выводится из владельца строки без изменения сущности Favorite:

  • нет строки ни у агента, ни у инвестора → «не в избранном»;
  • строка с UserId = agentUserId → «у меня (агента)»;
  • строка с UserId = investorUserId → «у инвестора».

Оба флага независимы (объект недвижимости может быть в избранном у обоих). Данный endpoint отдаёт плоский набор пар «(владелец, объект) → есть/нет»; сведение в 3-state (моё / инвестора / нет) выполняет crm по двум запрошенным userId.

Авторизация делегированная (паттерн UC-ACT-004): право агента видеть избранное конкретного инвестора проверяет crm по наличию активного лида (фиксации) на этого инвестора; убедившись, crm вызывает этот internal-эндпоинт с доверенными userId (агента и инвестора). Сервис favorites активность лида не перепроверяет — доверяет вызывающему CRM-контексту (внутренний m2m-клиент из allowlist). Гостевое избранное (AnonymousId) через этот endpoint недоступно — обогащение работает только по авторизованным UserId.

Metadata

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

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

ПроверкаЗначение
Actor typeSystem (m2m: сервис crm)
Auth policy / Permissionsm2m client credentials; permission favorites.status.read-internal; allowlist клиентов internal-области на gateway (pin-selections, pin-crm); вызов из публичной сети невозможен
Scope (RBAC)не применяется — доверенный внутренний вызов; ответственность за право агента видеть избранное инвестора несёт вызывающий контекст (crm проверяет активный лид/фиксацию перед вызовом, UC-ACT-004)
Record-levelвыборка строго по UserId ∈ userIds (гостевые записи AnonymousId предикатом не затрагиваются); favorites не сверяет связь агент↔инвестор — это обязанность crm
Tenant isolationне применяется — сущности вне tenant (ADR-0052 п.2)
Auditnone (чтение без sensitive-полей — заметки исключены из ответа; отдаётся только булев флаг)

HTTP-контракт

ПолеЗначение
MethodPOST (батч-чтение: списки в теле, не в query — размер и приватность id)
Route/internal/favorites/status
Success status200
Idempotency headerне применяется (безопасное чтение без побочных эффектов)
Correlationсквозной trace через заголовок traceparent из операции crm

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdsbodyIReadOnlyList<Guid>Да1..2 элемента; каждый != Guid.Empty; без дублейдоверенный вход из m2m-клиента; типично [agentUserId, investorUserId]; сопоставление с активным лидом — обязанность crm
PropertyObjectIdsbodyIReadOnlyList<Guid>Да1..200 элементов; каждый != Guid.Empty; дубли схлопываютсяограничение размера страницы каталога на стороне читателя

Пары запрашиваются как декартово произведение UserIds × PropertyObjectIds; в ответ включаются только пары, для которых запись избранного существует (см. алгоритм) — отсутствующая пара трактуется читателем как «не в избранном».

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

ПолеТипОбязательностьИсточникMaskingОписание
ItemsIReadOnlyList<FavoriteStatusItem>Да (может быть пуст)projection FavoriteНайденные факты избранного по запрошенным владельцам и объектам недвижимости.

FavoriteStatusItem

ПолеТипОбязательностьИсточникMaskingОписание
UserIdGuidДаFavorite.UserIdВладелец записи (один из запрошенных UserIds).
PropertyObjectIdGuidДаFavorite.PropertyObjectIdОбъект недвижимости в избранном у данного владельца.
IsFavoritedboolДавычисляетсяВсегда true для присутствующих элементов (запись найдена). Отсутствие пары = «не в избранном».

Note исключена намеренно (masking = exclude) и не отдаётся ни по одному владельцу — даже когда userId совпадает с самим агентом-инициатором: приватная заметка не должна утекать через кросс-контекстный обогащающий вызов (INV-FAV-005). Дата добавления также не отдаётся — задача endpoint-а только «есть/нет».

Форма Items намеренно «разреженная» (только IsFavorited = true), чтобы не раздувать ответ на 200 × 2 парах; булев флаг присутствует ради самодокументируемости и совместимости, если позже потребуется отдавать явные false.

Алгоритм

Тривиальное пакетное чтение. Актор — m2m-клиент pin-crm (client credentials, permission favorites.status.read-internal, проверено на gateway internal-области; клиент в allowlist). Проверяется вход: UserIds (1..2, без пустых/дублей), PropertyObjectIds (1..200, без пустых). Существование пользователей и объектов недвижимости в соседних контекстах не проверяется (кросс-контекстные ссылки без sync-вызова — несуществующий userId/propertyObjectId просто не даст совпадений).

Читаются записи избранного, у которых UserId ∈ UserIds и PropertyObjectId ∈ PropertyObjectIds (гостевые AnonymousId-записи предикатом не затрагиваются — обогащение только по авторизованным владельцам). Из каждой найденной записи в проекцию берутся только UserId и PropertyObjectId; поле Note в выборку не включается вовсе (masking = exclude). Возвращается FavoriteStatusResult со списком FavoriteStatusItem (IsFavorited = true) по найденным парам; отсутствующая пара означает «не в избранном» и в ответе не появляется. Пустой список — валидный 200.

Активность лида агента на инвестора здесь не проверяется — это ответственность вызывающего crm (делегированная авторизация, UC-ACT-004). Freshness: source of truth, read-your-writes относительно операций пользователей.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
Unauthorized401Невалидный/чужой m2m-токенстандартный ответno retry (конфигурация клиента)
Forbidden403Клиент не в allowlist internal-области либо без permission favorites.status.read-internalстандартный ответno retry
ValidationError400UserIds пуст / > 2 / содержит Guid.Empty / дубли; PropertyObjectIds пуст / > 200 / содержит Guid.Emptyдетали валидацииno retry
InternalError500Отказ хранилищаgeneric messageretry на стороне crm (обогащение деградирует до «статус неизвестен»)

Acceptance-критерии

  • Given m2m-клиент pin-crm с permission favorites.status.read-internal, UserIds = [agentUserId, investorUserId] и PropertyObjectIds из M объектов, где объект X в избранном у агента, а объект Y — у инвестора, When POST /internal/favorites/status, Then ответ 200, Items содержит (agentUserId, X, true) и (investorUserId, Y, true) и не содержит пар для объектов, которых нет в избранном; поле Note отсутствует в каждом элементе.
  • Given объект Z в избранном и у агента, и у инвестора, When POST, Then Items содержит обе пары (agentUserId, Z, true) и (investorUserId, Z, true) (флаги независимы).
  • Given ни агент, ни инвестор не добавляли ни один из запрошенных объектов (или userId несуществующий), When POST, Then ответ 200, Items = [] (кросс-контекстное существование не проверяется).
  • Given у инвестора есть заметка (Note) на объект X, When POST с этим объектом, Then в ответе присутствует пара (investorUserId, X, true) без какого-либо поля заметки (приватность INV-FAV-005) — даже для строки самого агента-инициатора.
  • Given у владельца есть только гостевые записи (AnonymousId), When POST по его UserId, Then они в выборку не попадают — читаются строго UserId-записи.
  • Given клиент не в allowlist internal-области или без permission favorites.status.read-internal, When POST, Then отказ Forbidden (403); Given невалидный/чужой m2m-токен — Unauthorized (401); Given UserIds > 2 или PropertyObjectIds > 200 либо пустые/дубли/Guid.EmptyValidationError (400).

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

  • Контракт internal, версионируется совместно с клиентом crm; расширение — только additive (Note и даты не добавлять — правило безопасности выше); смена маршрута/полей — синхронный rollout обоих сервисов.
  • Logs: debug с clientId и счётчиками входа (|UserIds|, |PropertyObjectIds|) и найденных пар; сами списки PropertyObjectId/UserId не логируются.
  • Metrics: favorites_internal_status_total{client}, гистограмма latency, гистограмма размера батча.
  • Traces: сквозной correlation от операции crm (обогащение карточек).
  • Dashboard/alerts/runbook — ../../README.md.

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

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

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

  • Потребитель: docs/06-services/crm/README.md (обогащение «в контексте инвестора»; делегированная авторизация по активному лиду — паттерн docs/02-architecture/scenarios/UC-ACT-004-*).
  • Сущность: ../../domain/favorite.md (приватность Note, INV-FAV-005; 3-state выводится из владельца строки без изменения сущности).
  • Соседний internal-метод: list-favorites.md (чтение полного списка для selections).