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 area | internal |
| Feature group | favorites-internal |
| Статус | draft |
| Документ | docs/06-services/favorites/api/internal/get-favorite-status.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | System (m2m: сервис crm) |
| Auth policy / Permissions | m2m 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) |
| Audit | none (чтение без sensitive-полей — заметки исключены из ответа; отдаётся только булев флаг) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST (батч-чтение: списки в теле, не в query — размер и приватность id) |
| Route | /internal/favorites/status |
| Success status | 200 |
| Idempotency header | не применяется (безопасное чтение без побочных эффектов) |
| Correlation | сквозной trace через заголовок traceparent из операции crm |
Входные данные / тело запроса FavoriteStatusQuery
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserIds | body | IReadOnlyList<Guid> | Да | 1..2 элемента; каждый != Guid.Empty; без дублей | доверенный вход из m2m-клиента; типично [agentUserId, investorUserId]; сопоставление с активным лидом — обязанность crm |
PropertyObjectIds | body | IReadOnlyList<Guid> | Да | 1..200 элементов; каждый != Guid.Empty; дубли схлопываются | ограничение размера страницы каталога на стороне читателя |
Пары запрашиваются как декартово произведение UserIds × PropertyObjectIds; в ответ включаются только
пары, для которых запись избранного существует (см. алгоритм) — отсутствующая пара трактуется читателем как
«не в избранном».
Модель ответа FavoriteStatusResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Items | IReadOnlyList<FavoriteStatusItem> | Да (может быть пуст) | projection Favorite | — | Найденные факты избранного по запрошенным владельцам и объектам недвижимости. |
FavoriteStatusItem
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
UserId | Guid | Да | Favorite.UserId | — | Владелец записи (один из запрошенных UserIds). |
PropertyObjectId | Guid | Да | Favorite.PropertyObjectId | — | Объект недвижимости в избранном у данного владельца. |
IsFavorited | bool | Да | вычисляется | — | Всегда 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 |
|---|---|---|---|---|
Unauthorized | 401 | Невалидный/чужой m2m-токен | стандартный ответ | no retry (конфигурация клиента) |
Forbidden | 403 | Клиент не в allowlist internal-области либо без permission favorites.status.read-internal | стандартный ответ | no retry |
ValidationError | 400 | UserIds пуст / > 2 / содержит Guid.Empty / дубли; PropertyObjectIds пуст / > 200 / содержит Guid.Empty | детали валидации | no retry |
InternalError | 500 | Отказ хранилища | generic message | retry на стороне crm (обогащение деградирует до «статус неизвестен») |
Acceptance-критерии
- Given m2m-клиент
pin-crmс permissionfavorites.status.read-internal,UserIds = [agentUserId, investorUserId]иPropertyObjectIdsизMобъектов, где объектXв избранном у агента, а объектY— у инвестора, WhenPOST /internal/favorites/status, Then ответ200,Itemsсодержит(agentUserId, X, true)и(investorUserId, Y, true)и не содержит пар для объектов, которых нет в избранном; полеNoteотсутствует в каждом элементе. - Given объект
Zв избранном и у агента, и у инвестора, WhenPOST, ThenItemsсодержит обе пары(agentUserId, Z, true)и(investorUserId, Z, true)(флаги независимы). - Given ни агент, ни инвестор не добавляли ни один из запрошенных объектов (или
userIdнесуществующий), WhenPOST, Then ответ200,Items = [](кросс-контекстное существование не проверяется). - Given у инвестора есть заметка (
Note) на объектX, WhenPOSTс этим объектом, Then в ответе присутствует пара(investorUserId, X, true)без какого-либо поля заметки (приватность INV-FAV-005) — даже для строки самого агента-инициатора. - Given у владельца есть только гостевые записи (
AnonymousId), WhenPOSTпо егоUserId, Then они в выборку не попадают — читаются строгоUserId-записи. - Given клиент не в allowlist internal-области или без permission
favorites.status.read-internal, WhenPOST, Then отказForbidden(403); Given невалидный/чужой m2m-токен —Unauthorized(401); GivenUserIds> 2 илиPropertyObjectIds> 200 либо пустые/дубли/Guid.Empty—ValidationError(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.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): Favorites API — индекс операций
Связанные документы
- Потребитель:
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).