POST /api/agent/crm/leads/{leadId}/property-object-context — Контекст инвестора по объектам недвижимости
Пакетная композиция «в контексте инвестора лида»: по списку PropertyObjectId агент получает для основного
инвестора лида его поведенческий и личный контекст — сколько раз и как долго инвестор смотрел объект, лежит
ли объект в избранном (у агента и/или у инвестора) и в какие подборки инвестора он входит. Метод ничего не
считает сам: он резолвит инвестора из лида, проверяет право агента на активный лид и делегированно
дозагружает три internal-источника (просмотры, избранное, подборки), сшивая их по PropertyObjectId.
Каталог остаётся без персонализации (платформенная проекция, ADR-0056 Часть D) — обогащение живёт здесь, в
аутентифицированном CRM-слое, и накладывается фронтом оверлеем поверх карточек каталога/избранного/подборок.
Назначение
Дать агенту на карточке лида и в его рабочих списках объектов (каталог/шахматка/избранное/подборки) увидеть, как этот объект соотносится с интересами его клиента-инвестора: смотрел ли инвестор объект и насколько активно, отмечен ли объект в избранном самим агентом и/или инвестором (независимые флаги, 3-state), в какие подборки инвестора он уже добавлен. Фронт агента вызывает метод параллельно с публичными каталогом/избранным/подборками и накладывает вернувшийся контекст оверлеем на уже отрисованные карточки — без изменения самих платформенных проекций.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | agent (JWT агента/куратора/руководителя агентства/Менеджера ПИН) |
| Feature group | leads |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/get-property-object-investor-context.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (агент-исполнитель / куратор лида) / User (руководитель агентства) / Operator(Admin) (Менеджер ПИН, platform-scope) |
| Auth policy / Permissions | crm.leads.view |
| Scope (RBAC) | организация (агентство): TenantId = AgencyCompanyId; руководитель — весь свой TenantId; Менеджер ПИН — платформа |
| Record-level | доступ = актор ∈ активные участники агентской роли лида (ExecutorAgentUserId = actor / членство) или руководитель компании-владельца или Менеджер ПИН; иначе CRM_LEAD_FORBIDDEN |
| Tenant isolation | TenantId в каждом запросе; lead.TenantId обязан совпасть |
| Audit | read-sensitive: доступ агента к активности целевого инвестора (actor, PrimaryInvestorUserId, без PII) |
Авторизация — делегированная (паттерн UC-ACT-004):
право «активный лид агента на инвестора» проверяет сам crm перед вызовом internal-источников; источники
(user-activity/favorites/selections) доверяют переданному userId и повторно его не перепроверяют.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/agent/crm/leads/{leadId}/property-object-context |
| Success status | 200 (контекст собран; частичная деградация источников не роняет ответ) |
| Idempotency header | не применяется (безопасное чтение, оформленное POST из-за батч-тела) |
| Correlation | сквозной trace через заголовок traceparent |
Метод — чтение (состояние не меняется); POST выбран из-за списочного тела (PropertyObjectIds не помещается
в query безопасно). Кешируется только на клиенте на время сессии карточки.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required; = AgencyCompanyId | Tenant isolation |
ActorUserId | identity claims | Guid | Да | Required | Record-level (членство) |
LeadId | route | Guid | Да | != Guid.Empty; существующий лид в tenant | — |
PropertyObjectIds | body | List<Guid> | Да | Required; 1..200 элементов; каждый != Guid.Empty; дубликаты схлопываются | — |
Модель ответа PropertyObjectInvestorContextListModel
Обёртка + один элемент на каждый запрошенный (после дедупликации) PropertyObjectId — включая объекты
без активности/избранного/подборок (пустой контекст), чтобы фронт мог наложить оверлей строго 1:1 по id.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
LeadId | Guid | Да | lead.Id | — | Лид, в контексте которого собран ответ. |
PrimaryInvestorUserId | Guid | Да | lead.PrimaryInvestorUserId | — | Инвестор, чей контекст отражён (основной инвестор лида). |
Items | List<PropertyObjectInvestorContextModel> | Да | композиция | — | По одному элементу на запрошенный объект. |
PropertyObjectInvestorContextModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
PropertyObjectId | Guid | Да | вход | — | Объект недвижимости из запроса. |
InvestorViews | InvestorViewsModel | Да | D1 (user-activity) | — | Агрегат просмотров инвестора по объекту; при отсутствии просмотров — нули/null. |
FavoritedByAgent | bool | Да | D2 (favorites) | — | Объект в избранном самого агента-актора (UserId = ActorUserId). |
FavoritedByInvestor | bool | Да | D2 (favorites) | — | Объект в избранном инвестора (UserId = PrimaryInvestorUserId). Независим от FavoritedByAgent. |
InInvestorSelections | List<SelectionRef> | Да | D3 (selections) | — | Подборки инвестора, в которые входит объект; пусто — если ни в одну. |
InvestorViewsModel
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
Count | int | Да | D1 | Число закрытых просмотров объекта инвестором (0 — не смотрел). |
TotalDurationSeconds | long | Да | D1 | Суммарная длительность закрытых просмотров, сек (0 — не смотрел). |
LastViewedAt | DateTimeOffset? | Нет | D1 | Когда инвестор смотрел объект в последний раз; null — не смотрел. |
SelectionRef
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
SelectionId | Guid | Да | D3 | Идентификатор подборки инвестора. |
Title | string | Да | D3 | Название подборки (для подписи оверлея). |
FavoritedByAgent и FavoritedByInvestor — вывод из владельца строки, не отдельная сущность: вычисляются D2
как наличие строки Favorite соответствующего владельца (UserId = ActorUserId /
UserId = PrimaryInvestorUserId); нет строки → флаг false. Флаги независимы: объект может быть у обоих, у
одного или ни у кого. Личная заметка (Note) владельца никогда не отдаётся кросс-владельца — метод её не
запрашивает и не возвращает.
Алгоритм
- Контекст и доступ. Операцию выполняет участник агентской роли лида / руководитель агентства / Менеджер
ПИН (permission
crm.leads.view) в рамкахTenantId. - Проверка входа и предусловий.
- формат:
LeadId != Guid.Empty;PropertyObjectIds—1..200непустыхGuid; иначеVALIDATION_FAILED; - лид существует в
TenantId— иначеCRM_LEAD_NOT_FOUND; - record-level: актор ∈ активные участники агентской роли лида (
ExecutorAgentUserId = actor/ членство) или руководитель компании-владельца или Менеджер ПИН — иначеCRM_LEAD_FORBIDDEN.
- формат:
- Резолв инвестора. Из лида берётся производная
PrimaryInvestorUserId(основной инвестор пары «исполнитель ↔ основной инвестор», INV-CRM-001; дом — lead.md). Инвариант INV-LEAD-7 гарантирует ровно одного основного инвестора у активного лида. - Нормализация запроса.
PropertyObjectIdsдедуплицируются; каждому сохранённому id соответствует ровно один выходной элемент (даже если контекст пуст). - Делегированная пакетная дозагрузка (параллельно). По доверенным
userId(record-level уже проверен на шаге 2)crmвызывает три internal-источника, каждый — батчем поPropertyObjectIds:- D1 — просмотры инвестора:
user-activityпо(PrimaryInvestorUserId, PropertyObjectIds)→{ PropertyObjectId, Count, TotalDurationSeconds, LastViewedAt }(только закрытые просмотры; открытые не входят); - D2 — статус избранного:
favoritesпо(UserIds = [ActorUserId, PrimaryInvestorUserId], PropertyObjectIds)→ для каждого объекта наличие строки на агента и на инвестора (Noteне запрашивается); - D3 — подборки инвестора:
selectionsпо(PrimaryInvestorUserId, PropertyObjectIds)→ для каждого объекта список подборок инвестора (SelectionId,Title), в которые он входит.
- D1 — просмотры инвестора:
- Сшивка по
PropertyObjectId. Результаты трёх источников мёржатся по ключу объекта:InvestorViews— из D1 (нет записи →Count = 0,TotalDurationSeconds = 0,LastViewedAt = null);FavoritedByAgent/FavoritedByInvestor— из D2 (нет строки →false);InInvestorSelections— из D3 (нет вхождений → пустой список). - Деградация источника. Если internal-источник недоступен, его секция отдаётся «пустой» (просмотры — нули,
флаги —
false, подборки — пусто), а операция всё равно возвращает200— фронт накладывает оверлей по тем данным, что пришли (graceful degradation, как в UC-ACT-004 A5). Полная недоступность лида/CRM — не деградация, а отказ (CRM_LEAD_*). - Результат.
PropertyObjectInvestorContextListModelс элементом на каждый запрошенный объект → HTTP200. Свежесть eventual: закрытие просмотров происходит polling-циклом worker-аuser-activity(10–15 мин, ADR-0056), поэтому только что просмотренный объект может ещё не отразиться вInvestorViews; избранное/подборки — актуальны на момент чтения соответствующих источников.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | none (read-only) |
| Внешние вызовы | m2m internal: user-activity (D1), favorites (D2), selections (D3) — параллельно, с таймаутом; деградация секции при недоступности |
| Проекции / поиск | не затрагиваются; каталог/избранное/подборки остаются платформенными проекциями без персонализации |
| Уведомления | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | LeadId = Guid.Empty; PropertyObjectIds пуст / > 200 / содержит Guid.Empty | «Проверьте корректность полей» | no retry |
CRM_LEAD_NOT_FOUND | 404 | лид не найден по LeadId в tenant | «Лид не найден» | no retry |
CRM_LEAD_FORBIDDEN | 403 | актор не участник агентской роли / не руководитель / не Менеджер ПИН | «Недостаточно прав на просмотр лида» | no retry |
Недоступность отдельного internal-источника (D1/D2/D3) — не ошибка: секция деградирует до пустой, ответ
200 (см. Алгоритм п.7).
Предусловия и постусловия
Предусловия (истинны до сценария):
- актор авторизован, имеет permission
crm.leads.view, является участником агентской роли лида / руководителем агентства / Менеджером ПИН; - лид существует в
TenantIdактора и имеет основного инвестора (INV-LEAD-7); PropertyObjectIds— непустой список (1..200) корректныхGuid.
Постусловия (гарантированы после успеха):
- состояние не изменилось (read-only: ни мутаций, ни доменных, ни интеграционных событий);
- возвращён контекст только основного инвестора данного лида; чужой контекст недоступен ни при каких параметрах;
- на каждый запрошенный (после дедупликации) объект есть ровно один элемент ответа; объекты без активности —
с пустым контекстом (нули/
false/пустой список); - личные заметки избранного (
Note) не раскрыты кросс-владельца; - зафиксирован read-sensitive access-audit (
actor,PrimaryInvestorUserId, без PII).
Acceptance Criteria
- Happy path. Дано: активный лид, актор — исполнитель,
PropertyObjectIds= [O1, O2]; инвестор смотрел O1 дважды, O1 у инвестора в избранном и в подборке S1, O2 — у агента в избранном. Когда:POST. Тогда:200, для O1 —InvestorViews.Count = 2,FavoritedByInvestor = true,InInvestorSelections = [S1]; для O2 —FavoritedByAgent = true, остальное пусто. - 3-state независимость. Дано: O в избранном и у агента, и у инвестора. Когда:
POST. Тогда:FavoritedByAgent = trueиFavoritedByInvestor = trueнезависимо; заметки не возвращены. - Объект без контекста. Дано: O3 не смотрелся, не в избранном, не в подборках. Когда:
POSTc O3. Тогда: элемент O3 присутствует сCount = 0,LastViewedAt = null, оба флагаfalse,InInvestorSelections = []. - Чужой лид. Дано: актор не участник агентской роли / не руководитель / не Менеджер ПИН. Когда:
POST. Тогда:403 CRM_LEAD_FORBIDDEN, источники не вызываются. - Несуществующий лид. Дано:
LeadIdне в tenant. Когда:POST. Тогда:404 CRM_LEAD_NOT_FOUND. - Деградация источника. Дано:
selectionsнедоступен. Когда:POST. Тогда:200,InInvestorSelectionsпуст у всех объектов, просмотры и избранное — заполнены. - Свежесть. Дано: инвестор только что открыл O (просмотр ещё не закрыт worker-ом). Когда:
POST. Тогда: O может вернуться сCount, не учитывающим открытый просмотр (eventual, ADR-0056) — не ошибка. - Дедупликация. Дано:
PropertyObjectIds= [O1, O1, O2]. Когда:POST. Тогда:Itemsсодержит по одному элементу на O1 и O2.
Совместимость и наблюдаемость
- Каталог/избранное/подборки не персонализируются — обогащение остаётся композицией в CRM-слое (ADR-0056 Часть D); публичные методы и карточки не меняются. Новые optional поля контекста — без breaking change.
- Logs:
lead-property-object-context read(LeadId, tenant, actor, число запрошенных id;PrimaryInvestorUserId— только id); деградации источников логируются отдельно (source=user-activity|favorites|selections). Metrics:crm_lead_property_object_context_total{result}, доля деградаций по источнику, latency p95; traces — сквозной trace черезtraceparentво все три internal-вызова; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (3): UC-ACT-004. Агент/менеджер видит активность инвестора (для карточки лида), UC-CRM-019. Агент просматривает каталог в контексте инвестора активного лида, UC-UAC-001. Агрегированная активность нескольких инвесторов-участников лида
- index.md (1): Catalog Service (Pin.Catalog)
Связанные документы
- Сущность-дом лида: lead.md (
PrimaryInvestorUserId, INV-LEAD-7, INV-CRM-001). - Делегированные источники контекста (internal m2m):
D1 — get-property-object-views-by-user.md
(просмотры инвестора по объектам),
D2 — get-property-object-favorite-status.md
(3-state избранного для агента и инвестора;
Noteне раскрывается — favorite.md), D3 — list-property-object-selections-by-user.md (подборки инвестора по объектам). - Паттерн делегированной авторизации по активному лиду: UC-ACT-004.
- Смежная карточка лида: get-lead.md; permission —
identity/domain/permission.md (
crm.leads.view). - ADR-0056 (Часть D — каталог без персонализации, обогащение композицией; polling-свежесть просмотров), ADR-0025 (scope-иерархия), ADR-0071 (владение процессами, участники лида).