GET /api/agent/crm/clients — Клиентская база агента (список + поиск)
Табличный список клиентской базы агентства (страница /crm/clients): карточки клиентов с пагинацией,
сортировкой по последней активности и полнотекстовым поиском через поисковую проекцию ClientSearchDocument
(../../search/clients.md, ADR-0059) — тот же класс проекций, что у дубль-детекта
фиксаций (../../search/fixations.md). О клиентской базе не знает застройщик —
это внутренние данные агентства (../../domain/client.md).
Назначение
Показать агенту клиентов, которых он ведёт: создатель записи (OriginAgentUserId = actor) или активный
участник их процессов. Руководителю агентства и Менеджеру ПИН — отдать базу всего агентства с опциональным
фильтром по агенту-создателю (OriginAgentUserId), чтобы находить клиентов конкретного агента. Поиск —
fuzzy-match по ФИО, нормализованному телефону и email.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | agent |
| Feature group | clients |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/list-clients.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User — Агент или Руководитель агентства; Operator — Менеджер ПИН (platform-scope, ADR-0052) |
| Auth policy / Permissions | crm.clients.view (каталог — ../../../identity/domain/permission.md) |
| Scope (RBAC) | организация (агентство): TenantId = AgencyCompanyId |
| Record-level | агент — клиенты, где он создатель (OriginAgentUserId = actor) или активный участник процессов клиента; руководитель — весь TenantId c опциональным фильтром по агенту-создателю (OriginAgentUserId); Менеджер ПИН — кросс-tenant (platform-scope) |
| Tenant isolation | TenantId = AgencyCompanyId в каждом запросе (авто-фильтр по scope) |
| Audit | read (список PII); access-log без значений PhoneNumber/Email/поиска |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/agent/crm/clients |
| Success status | 200 |
| Idempotency header | не применяется (GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да (вычисляется) | AgencyCompanyId; Required scope | Tenant isolation |
ActorUserId | identity claims | Guid | Да (вычисляется) | из identity | определяет record-level и masking |
Search | query (search) | string? | Нет | trim, 1..128; пустой после trim → игнор; поисковый запрос multi-match (fuzzy), не raw-DSL | PII: значение поиска в логи не пишется |
Status | query (status) | ClientStatus? (string) | Нет | Active / Archived (../../domain/client.md); null — все статусы | — |
OriginAgentUserId | query (originAgentUserId) | Guid? | Нет | != Guid.Empty; фильтр по агенту-создателю «свои/все» — только руководитель/Менеджер ПИН; null — вся база агентства | record-level |
Page | query | int | Нет (default 1) | >= 1 | — |
PageSize | query | int | Нет (default 20) | 1..100 (лента ~20) | — |
Сортировка фиксирована — LastActivityAt desc (последняя активность сверху); поле сортировки клиентом не
задаётся. Тай-брейк — CreatedAt desc, Id asc (стабильная пагинация).
Модель ответа — постраничный список ClientListItemModel
Постраничный контейнер: Items, Page, PageSize, TotalCount.
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
Items | IReadOnlyList<ClientListItemModel> | Да (может быть пустым) | страница выборки | Элементы страницы. |
Page | int | Да | вход | Текущая страница. |
PageSize | int | Да | вход | Размер страницы. |
TotalCount | long | Да | число клиентов под фильтром | Всего клиентов под фильтром. |
Вложенная модель ClientListItemModel (проекция ../../domain/client.md)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | Client.Id | — | Переход в карточку /crm/clients/[clientId]. |
Name | string | Да | Client.Name (FullName, собранное ФИО) | — | ФИО клиента. |
PhoneNumber | string | Да | Client.PhoneNumber (E.164) | +7***NNNN в чужих списках | Телефон клиента (PII). |
Status | ClientStatus (string) | Да | Client.Status | — | Active / Archived. |
ActiveLeadId | Guid? | Нет | Client.ActiveLeadId | — | Текущий активный лид (../../domain/lead.md) для быстрого перехода; null — активного лида нет. |
LastActivityAt | DateTimeOffset? | Нет | Client.LastActivityAt | — | Момент последней активности; ключ сортировки. |
LeadsCount | int | Да | число лидов клиента (leads.client_id) | — | Число лидов клиента (все статусы). |
Алгоритм
- Контекст и доступ. Операцию выполняет агент/руководитель агентства/Менеджер ПИН (permission
crm.clients.view) в tenant своего агентства (TenantId = AgencyCompanyId, при отсутствии scope — fail-closed). Право видеть всю базу агентства — permissioncrm.clients.view-all. - Проверка входа и предусловий.
- формат:
Search— trim и1..128;Status— допустимое значение enum;OriginAgentUserId != Guid.Emptyпри заданном;Page >= 1;PageSizeв1..100— иначе ошибка валидации (400); - record-level: если у актора нет
crm.clients.view-allи заданOriginAgentUserId != actor—CRM_CLIENTS_FORBIDDEN_AGENT_FILTER(403): рядовой агент не листает базу другого агента.
- формат:
- Что читаем. Эффективный доступ по членству агентства (../../domain/client.md):
без
view-allрядовой агент видит клиентов, где он создатель (OriginAgentUserId = actor) или активный участник их процессов (лид/фиксация/сделка); с правом — вся база агентства с опциональным фильтром по создателю (заданныйOriginAgentUserId, илиnull= вся база). Основная выборка — поисковая проекцияClientSearchDocumentсо scope-фильтромTenantId(AgencyCompanyId) + фильтром доступа / по создателю (OriginAgentUserId), опциональнымStatus, сортировкойLastActivityAt desc(тай-брейкCreatedAt desc, Id asc), пагинациейPage/PageSizeиTotalCount; архивные (soft-delete) документы в индекс не попадают; при заданномSearch— multi-match (fuzzy) по ФИО+телефону+email. Актуальные PII отдаёт карточка (source-of-truth PostgreSQL). СчётчикLeadsCount— батч-агрегатом по клиентам страницы (без N+1). - Маппинг и masking. Поле-в-поле
ClientListItemModel;LeadsCount— из батч-агрегата (0, если лидов нет).PhoneNumberмаскируется в+7***NNNN, если карточка чужого агента (руководитель/Менеджер ПИН смотрит чужой список — телефон чужого клиента скрыт; см. ../../domain/client.md, PII). - Результат. Постраничный список →
200; пустая выдача →Items = [],TotalCount = 0(валидное «ничего не найдено»). Ошибки — 400/403 ProblemDetails.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | none (клиентская база — внутренние данные агентства) |
| Внешние вызовы | none |
| Проекции / поиск | чтение поисковой проекции ClientSearchDocument (scope-фильтр, ADR-0059); проекций не пишет |
| Уведомления | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_ERROR | 400 | Нарушение правил таблицы входных (Search длина, неизвестный Status, Page/PageSize) | «Некорректные параметры запроса» | no retry |
CRM_CLIENTS_FORBIDDEN_AGENT_FILTER | 403 | Рядовой агент задал OriginAgentUserId ≠ свой (нет crm.clients.view-all) | «Просмотр базы другого агента недоступен» | no retry |
FORBIDDEN | 403 | Нет permission crm.clients.view | «Недостаточно прав» | no retry |
CRM_CLIENTS_SEARCH_UNAVAILABLE | 503 | Search задан, но поисковая проекция (Elasticsearch) недоступна | «Поиск временно недоступен, повторите позже» | retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой | generic | retry (backoff) |
При недоступной проекции (../../README.md → зависимости) запрос без Search обслуживается из
source-of-truth PostgreSQL (сортировка LastActivityAt desc, без fuzzy-ранжирования) — список остаётся
доступным, это не ошибка (200). Запрос с Search в этом режиме выполнить нельзя (fuzzy-матчинг живёт
только в проекции) → CRM_CLIENTS_SEARCH_UNAVAILABLE (503, retry).
Совместимость и наблюдаемость
- Новые фильтры/поля ответа — additive; изменение default-сортировки или ужесточение валидации — breaking
(фиксируется в changelog контекста). Enum-строки (
ClientStatus) — клиентский fallback на unknown обязателен. - Logs:
TenantId, actor type,effectiveAgentId,Status,hasSearch(без значения поиска и телефонов),TotalCount; metrics:crm_clients_list_requests_total{scope=own|all}, latency histogram (SLO списки p95 < 1 с, ../../README.md); traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Acceptance Criteria
- Happy path. Given агент без фильтров; When
GET; Then200— клиенты, которых он ведёт (создательOriginAgentUserId = actorили участник процессов), сортировкаLastActivityAt desc; телефоны своих клиентов — полностью. - База другого агента. Given руководитель с
crm.clients.view-allиOriginAgentUserIdдругого агента; WhenGET; Then200— база того агента (по создателю); телефоны чужих клиентов маскируются+7***NNNN. - Фильтр без прав. Given рядовой агент без
view-allзадалOriginAgentUserId != actor; WhenGET; Then403 CRM_CLIENTS_FORBIDDEN_AGENT_FILTER. - Поиск. Given заданный
Search; WhenGET; Then fuzzy-выдача по ФИО + нормализованному телефону + email. - Пусто. Given фильтр без совпадений; When
GET; Then200,Items = [],TotalCount = 0. - Валидация. Given
Searchвне1..128, неизвестныйStatusилиPage/PageSizeвне границ; WhenGET; Then400 VALIDATION_ERROR. - Деградация (без поиска). Given проекция Elasticsearch недоступна и запрос без
Search; WhenGET; Then200из PostgreSQL source-of-truth (сортировкаLastActivityAt, без fuzzy). - Деградация (поиск). Given проекция недоступна и задан
Search; WhenGET; Then503 CRM_CLIENTS_SEARCH_UNAVAILABLE(retry).
Обратные ссылки
Автоссылки: где используется этот документ.
- Поиск (1): ClientSearchDocument
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: ../../domain/client.md (дом полей + проекция, masking PII),
../../domain/lead.md (
ActiveLeadId,LeadsCount). - Поисковая проекция: ../../search/clients.md (
ClientSearchDocument, ADR-0059). - Соседние операции агента (get/create/update/archive клиента) — карта областей в ../../README.md.
- ADR: 0052 tenancy, 0056 simplicity-first, 0059 Elasticsearch-проекции.