GET /api/investor/crm/leads — Мои лиды (инвестор)
Список собственных лидов инвестора для раздела «Мои лиды» ЛК (US-INV). Read-only проекция агрегата
LeadEntity, отфильтрованная record-level по PrimaryInvestorUserId = actor и
отсортированная по последней активности (LastEventAt desc). Инвестор видит только свои лиды и без
данных о фиксациях (lead.md, «Безопасность»). Мутаций и событий не производит.
Назначение
Отдать инвестору постранично его лиды (ведущий агент, статус, срок жизни, момент последней активности, счётчик порождённых сделок) — навигация «инвестор ↔ агент». Данные о фиксациях застройщику/инвестору не раскрываются и в проекцию не входят.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | investor |
| Feature group | leads |
| Статус | draft |
| Документ | docs/06-services/crm/api/investor/list-leads.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Инвестор) |
| Auth policy / Permissions | crm.leads.view (JWT identity), area-гейт «investor» |
| Scope (RBAC) | платформа (инвестор вне tenant агентства; лиды к разным агентствам) |
| Record-level | только свои: обязательный предикат PrimaryInvestorUserId = actor |
| Tenant isolation | LeadEntity.TenantId = AgencyCompanyId, но инвестор — не член tenant; изоляция — record-level PrimaryInvestorUserId = actor (ADR-0052, «вторая сторона») |
| Audit | read (не sensitive): id и агрегаты; PII в логи не пишется |
Инвестор видит только свои лиды (PrimaryInvestorUserId = actor) — независимо от того, к какому агентству
относится лид (кросс-tenant «вторая сторона», ADR-0052). Данные о фиксациях в проекцию не входят: инвестор не
видит внутренней «кухни» агентства (lead.md, «Безопасность»).
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/investor/crm/leads |
| Success status | 200 |
| Idempotency header | не применяется (read-only, безопасный метод) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
PrimaryInvestorUserId | identity claims | Guid | Да | Required (= actor); задаётся сервером, из тела/query не принимается | Record-level (own-only) |
Paging | query | пагинация | Нет | Take 1..200 (default 20), Offset >= 0 (default 0) | — |
Status | query | LeadStatus? | Нет | enum строкой Active/Expired/Archived; неизвестное → VALIDATION_FAILED | — |
Фиксированная сортировка LastEventAt desc (индекс ix_leads_last_event_at), tiebreak CreatedAt desc →
Id desc; лиды без событий (LastEventAt = null) — в хвост (NULLS LAST). Отдельная настройка сортировки не
вводится — порядок задан назначением списка. Фильтра по агенту нет (у инвестора нет scope над агентами).
Модель ответа: постраничный список InvestorLeadListItemModel
Постраничный контейнер: элементы InvestorLeadListItemModel + метаданные страницы (Total, Take,
Offset, HasMore). Поля элемента — проекция LeadEntity (описываем только дельту):
InvestorLeadListItemModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | LeadEntity.Id | — | Идентификатор лида. |
Agent | AgentRefModel ({ ExecutorAgentUserId: Guid, AgentName: string? }) | Да | LeadEntity.ExecutorAgentUserId + display-реплика identity (событие user-upserted) | — | Ведущий агент; AgentName null, если display-реплика ещё не догнала. |
Status | LeadStatus | Да | LeadEntity.Status (enum строкой) | — | Active/Expired/Archived. |
ExpiresAt | DateTimeOffset | Да | LeadEntity.ExpiresAt | — | Срок жизни лида. |
LastEventAt | DateTimeOffset? | Нет | LeadEntity.LastEventAt | — | Последняя активность (ключ сортировки). |
DealsCount | int | Да | computed (count crm.deals по LeadId) | — | Порождено сделок из лида. |
Фиксации не отдаются. Поля-счётчика фиксаций в модели намеренно нет и join на
crm.fixation_requests не выполняется: инвестор не видит данных о
фиксациях (lead.md, «Безопасность»). «Шапка хотелок», клиентская PII и TenantId наружу не возвращаются
(внутренние данные агентства).
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор;
PrimaryInvestorUserId = actorберётся из токена (из тела/query не принимается); permissioncrm.leads.view(иначеCRM_LEADS_FORBIDDEN);Paging— из query. - Проверка входа и предусловий.
Paging.Take ∈ 1..200,Offset >= 0;Status— известное значение enum (unknown enum →VALIDATION_FAILED, а не «тихий» фильтр). Существование по id не проверяется — это список (пустая страница вместо 404). «Чужого инвестора» не существует —PrimaryInvestorUserIdподставляется сервером, scope-forbidden ветки нет. - Что читаем. Страницу собственных лидов инвестора: обязательный предикат
PrimaryInvestorUserId = actor+ опциональныйStatus, порядокLastEventAt desc → CreatedAt desc → Id desc, срез поPaging. К каждой строке — счётчик порождённых сделок (DealsCountпоcrm.deals) и имя агента из display-реплики identity (AgentName). Join наcrm.fixation_requestsотсутствует (безопасность); удалённые записи скрыты. - Бизнес-правила и маппинг. Record-level уже в предикате (
PrimaryInvestorUserId == actor) — гарантия own-only через все tenant-ы. Маппинг поле-в-поле — таблица «Модель ответа»;AgentName— из display-реплики,DealsCount— из счётчика. Переходов состояний нет. - Результат. Постраничный список
InvestorLeadListItemModel(Items+ метаданные страницы) → HTTP200. Пустой результат —200сItems = [](не 404).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (read-only) |
| External calls | none (данные — своя схема crm; имя агента — display-реплика из identity, событие user-upserted) |
| Cache / projections / search | none (прямой запрос PostgreSQL по ix_leads_last_event_at) |
| Notifications | none |
Ошибки и failure modes
Отдельного forbidden-сценария «чужой инвестор» не существует: PrimaryInvestorUserId всегда берётся из
токена и не принимается из query/тела запроса. Пустой список — не ошибка: возвращается 200 с Items = [],
включая случай, когда у инвестора нет лидов вовсе.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | Take вне 1..200, неизвестный Status | «Проверьте параметры запроса» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
CRM_LEADS_FORBIDDEN | 403 | нет permission crm.leads.view / actor не инвестор | «Недостаточно прав» | no retry |
Предусловия и постусловия
Предусловия (истинны до сценария):
- инвестор авторизован (JWT платформы) и имеет permission
crm.leads.view; охват — только собственные лиды (PrimaryInvestorUserId = actor), лиды могут относиться к разным агентствам; AgentNameберётся из display-репликиidentity(событиеuser-upserted) — может отставать (тогдаAgentName = null,ExecutorAgentUserIdприсутствует всегда);DealsCount— вычисляемый счётчик, согласован на момент запроса.
Постусловия (после запроса):
- состояние не изменяется (read-only): события не публикуются, счётчики не меняются;
- клиент получил свою страницу лидов без данных о фиксациях (join на
crm.fixation_requestsне выполняется); фильтра по агенту нет.
Кросс-сервисные ожидания. Ожидает от identity display-реплику имени агента (событие user-upserted); при отставании реплики — деградация без ошибки (AgentName = null, список всё равно 200). Внешних событий операция не публикует.
Acceptance Criteria
- Happy path. Дано: у инвестора 2 лида к разным агентствам. Когда:
GET /api/investor/crm/leadsбез фильтра. Тогда:200, порядокLastEventAt desc→CreatedAt desc→Id desc; лиды без событий (LastEventAt = null) — в хвосте (NULLS LAST). - Фиксации скрыты. Дано: у лида есть активные фиксации. Когда:
GET. Тогда: в строке нет ни счётчика, ни данных фиксаций (толькоDealsCount); PII клиента иTenantIdнаружу не отдаются. - Отставание реплики identity. Дано: реплика имени агента ещё не догнала. Когда:
GET. Тогда:200,Agent.AgentName = null,Agent.ExecutorAgentUserIdзаполнен. - Фильтр статуса. Когда:
status=Expired. Тогда: только лиды вExpired. - Пустой результат. Дано: у инвестора нет лидов. Когда:
GET. Тогда:200cItems = [](не 404). - Неизвестный enum. Когда:
status=Foo. Тогда:400 VALIDATION_FAILED. - Нет права. Когда: актор без
crm.leads.viewили не инвестор. Тогда:403 CRM_LEADS_FORBIDDEN.
Совместимость и наблюдаемость
- Новые optional поля ответа/фильтры — additive (без breaking); удаление/переименование/смена типа поля или
значения enum — breaking (migration + rollout). Unknown enum во входе —
VALIDATION_FAILED(не игнор). - Logs:
investor leads listed(PrimaryInvestorUserId, счётчикTotal, набор фильтров; имена/PII — не в логи); metrics:crm_leads_list_total{area="investor",result}, latency p95 (< 1 c, SLO списков); traces — OTel, correlation черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): GET /api/investor/crm/deals — Мои сделки (инвестор)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: lead.md (дом полей,
LeadStatus,ix_leads_last_event_at, record-level «инвестор — только свои, без фиксаций»), fixation-request.md (намеренно не отдаётся инвестору), deal.md (DealsCount). - События жизненного цикла: lead-created.md, lead-expired.md; агентский аналог: list-leads.md.
- Контекст: README; tenancy — ADR-0052.