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

GET /api/agent/crm/leads — Список лидов агента

Список лидов агентской воронки для страницы «Лиды» ЛК агента (US-AGT). Read-only проекция агрегата LeadEntity с сортировкой по последней активности клиента (LastEventAt desc, индекс ix_leads_last_event_at), фильтрами и record-level разграничением «свои / всё агентство» (переключатель руководителя). Мутаций и событий не производит.

Назначение

Отдать агенту постранично его активные/истёкшие/архивные лиды (ФИО клиента, краткая «шапка хотелок», статус, срок жизни, счётчики порождённых фиксаций и сделок) для навигации по воронке.

Metadata

ПолеЗначение
Сервис/контекстcrm
API areaagent
Feature groupleads
Статусdraft
Документdocs/06-services/crm/api/agent/list-leads.md

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

ПроверкаЗначение
Actor typeUser (Агент или Руководитель агентства)
Auth policy / Permissionscrm.leads.view
Scope (RBAC)организация (агентство) — TenantId = AgencyCompanyId
Record-levelАгент — только свои (ExecutorAgentUserId = actor); Руководитель — весь TenantId, опциональный фильтр по ExecutorAgentUserId
Tenant isolationTenantId в каждом запросе, авто-предикат по scope
Auditread (не sensitive): PII клиентов в логи не пишется
Доступ

Агент видит только свои лиды (ExecutorAgentUserId = actor); руководитель агентства — весь TenantId, опционально сузить фильтром по ExecutorAgentUserId. Попытка агента заглянуть за пределы своего исполнения (ExecutorAgentUserId != actor) отклоняется, а не молча сужается до пустого списка.

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/agent/crm/leads
Success status200
Idempotency headerне применяется (read-only, безопасный метод)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired (AgencyCompanyId)Tenant isolation
PagingqueryPagedQueryНетTake 1..200 (default 20), Offset >= 0 (default 0)
StatusqueryLeadStatus?Нетenum строкой Active/Expired/Archived; неизвестное → ValidationError
ClientIdqueryGuid?Нет!= Guid.Empty; несуществующий/чужой → пустой список (scope+filter), без 404
SourcequeryLeadSource?Нетenum строкой ConsultationRequest/BookingRequest/Referral/Manual
ExecutorAgentUserIdqueryGuid?Неттолько для Руководителя; для Агента задан и != actorCRM_LEADS_SCOPE_FORBIDDEN

Фиксированная сортировка LastEventAt desc (индекс ix_leads_last_event_at), tiebreak CreatedAt descId desc; лиды без событий (LastEventAt = null) идут в хвост (NULLS LAST).

Модель ответа — постраничный список LeadListItemModel

Постраничный контейнер: Items: IReadOnlyList<LeadListItemModel> + Meta (Total, Take, Offset, HasMore). Поля элемента — проекция LeadEntity и ClientEntity:

LeadListItemModel

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаLeadEntity.IdИдентификатор лида.
ClientIdGuidДаLeadEntity.ClientIdКарточка клиента (навигация).
ExecutorAgentUserIdGuidДаLeadEntity.ExecutorAgentUserIdТекущий исполнитель лида (для колонки «Агент» в режиме руководителя).
ClientNamestringДаClientEntity.Name (FullName)PII: не в логиФИО клиента одной строкой.
StatusLeadStatusДаLeadEntity.Status (enum строкой)Active/Expired/Archived.
SourceLeadSourceДаLeadEntity.Source (enum строкой)Как появился лид.
WishesSummarystring?Нетcomputed из LeadEntity.Wishes (LeadWishes)PII: не в логиКраткая «шапка»: бюджет-диапазон + Urgency + PurchaseGoal; null, если хотелки пусты.
ExpiresAtDateTimeOffsetДаLeadEntity.ExpiresAtСрок жизни лида.
LastEventAtDateTimeOffset?НетLeadEntity.LastEventAtПоследнее событие ленты (ключ сортировки).
FixationsCountintДачисло заявок на фиксацию по LeadIdПорождено заявок на фиксацию.
DealsCountintДачисло сделок по LeadIdПорождено сделок.

WishesSummary — производная строка (типизированная), не сырой jsonb; полный VO — в доме lead.md.

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент/руководитель агентства (permission crm.leads.view; иначе CRM_LEADS_FORBIDDEN) в tenant своего агентства (TenantId = AgencyCompanyId).
  2. Проверка входа и предусловий.
    • формат: Paging.Take ∈ 1..200, Offset >= 0; Status/Source — известные значения enum (unknown → ошибка валидации); ClientId != Guid.Empty при заданном;
    • несуществующий/чужой ClientId даёт пустую страницу (фильтр, не 404);
    • scope: если actor не Руководитель и ExecutorAgentUserId != actorCRM_LEADS_SCOPE_FORBIDDEN.
  3. Что читаем. Страницу лидов с проекцией строк: связь Lead → Client (ClientName), счётчики порождённых фиксаций/сделок по LeadId; фильтры Status/ClientId/Source, record-level (см. шаг 4), TenantId из scope, deleted_at IS NULL; сортировка LastEventAt desc → CreatedAt desc (индекс ix_leads_last_event_at).
  4. Правила scope и маппинг. Record-level: Агент → ExecutorAgentUserId == actor; Руководитель без фильтра → весь TenantId; Руководитель с ExecutorAgentUserIdExecutorAgentUserId == query.ExecutorAgentUserId. Маппинг поле-в-поле — таблица «Модель ответа»; WishesSummary собирается из LeadWishes (бюджет + Urgency + PurchaseGoal).
  5. Результат. Постраничный список (Items, Meta = {Total, Take, Offset}) → HTTP 200. Пустой результат — 200 с Items = [] (не 404).

Диаграмма

Побочные эффекты

ТипДетали
Интеграционные событияnone (read-only)
Внешние вызовыnone (данные — своя схема crm; ФИО — реплика из identity, консюмер user-upserted)
Проекции / поискnone (прямой запрос PostgreSQL по ix_leads_last_event_at)
Уведомленияnone

Ошибки и failure modes

Ошибки

Нарушение record-level по ExecutorAgentUserId — не пустая страница, а явный отказ 403 CRM_LEADS_SCOPE_FORBIDDEN. Пустой список (нет 404) возможен только как исход фильтрации: несуществующий/ чужой ClientId, отсутствие подходящих лидов.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Take вне 1..200, неизвестный Status/Source, ClientId = Guid.Empty«Проверьте параметры запроса»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
CRM_LEADS_FORBIDDEN403нет permission crm.leads.view«Недостаточно прав»no retry
CRM_LEADS_SCOPE_FORBIDDEN403Агент запросил ExecutorAgentUserId != actor (чужие лиды / всё агентство)«Недостаточно прав для просмотра лидов другого агента»no retry

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

  • Новые optional поля ответа/фильтры — additive (без breaking); удаление/переименование/смена типа поля или значения enum — breaking (migration + rollout). Unknown enum во входе — ValidationError (не игнор).
  • Logs: leads listed (TenantId, ExecutorAgentUserId, счётчик Total, набор фильтров; ФИО/хотелки — не в логи); metrics: crm_leads_list_total{result}, latency p95 (< 1 c, SLO списков); traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path. Given агент без фильтров; When GET; Then 200 — свои лиды, сортировка LastEventAt desc (лиды без событий — в хвост, NULLS LAST); WishesSummary заполнен при непустых хотелках.
  • Фильтр по агенту. Given руководитель агентства с фильтром ExecutorAgentUserId; When GET; Then 200 — лиды указанного агента; без фильтра — весь TenantId.
  • Scope запрещён. Given агент задал ExecutorAgentUserId != actor; When GET; Then 403 CRM_LEADS_SCOPE_FORBIDDEN.
  • Фильтры. Given Status/Source/ClientId; When GET; Then выдача сужена; несуществующий/чужой ClientId даёт пустую страницу (не 404).
  • Нет прав. Given отсутствует permission crm.leads.view; When GET; Then 403 CRM_LEADS_FORBIDDEN.
  • Валидация. Given неизвестный Status/Source или Take вне 1..200; When GET; Then 400 VALIDATION_FAILED.
  • Пусто. Given под фильтр не попал ни один лид; When GET; Then 200 с Items = [] (не 404).

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

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

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