GET /api/messenger/contacts — Список подтверждённых контактов
Назначение
Табличный список контактов пользователя (страница «Контакты» портала): все связи актора со статусом
Accepted, где актор — инициатор или адресат. Для Accepted профиль контрагента раскрыт полностью
(ФИО + телефон, INV-CT-5), доступен звонок. Опционально — поиск по ФИО и нормализованному телефону
контрагента через поисковую проекцию MessengerContactSearchDocument
(../../search/contacts.md, ADR-0059), сужённую до контактов актора. Вызывает любой
пользователь; ответ постраничный, сортировка — по ФИО.
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | public |
| Feature group | contacts |
| Статус | draft |
| Документ | docs/06-services/messenger/api/contacts/list-contacts.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Инвестор/Агент/Руководитель/Менеджер застройщика/Менеджер ПИН) |
| Auth policy / Permissions | messenger.contacts.manage (каталог — ../../../identity/domain/permission.md) |
| Scope (RBAC) | платформа (граф контактов — person-level, вне tenant, ADR-0052) |
| Record-level | только связи, где actor ∈ {RequesterUserId, AddresseeUserId} |
| Tenant isolation | не применяется — контакты вне tenant (person-level) |
| Audit | read (список PII: ФИО/телефон контактов); access-log без значений телефона/поиска |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/messenger/contacts |
| Success status | 200 |
| Idempotency header | не применяется (GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да (вычисляется) | != Guid.Empty | определяет record-level |
Search | query (search) | string? | Нет | trim, 1..128; пустой после trim → игнор; передаётся в полнотекстовый поиск | PII: значение в логи не пишется |
Page | query | int | Нет (default 1) | >= 1 | — |
PageSize | query | int | Нет (default 20) | 1..100 | — |
Сортировка фиксирована — LastName asc, FirstName asc; тай-брейк ContactId asc (стабильная пагинация).
Постраничный контейнер: Items, Page, PageSize, TotalCount.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
ContactId | Guid | Да | ContactEntity.Id | — | Идентификатор связи (для remove-contact). |
CounterpartUserId | Guid | Да | сторона пары ≠ актор | — | UserId контрагента (открыть чат/звонок). |
FullName | FullName | Да | MessengerUserEntity (FirstName/MiddleName?/LastName) | полностью (Accepted, INV-CT-5) | Полное ФИО контрагента. |
PhoneNumber | string | Да | MessengerUserEntity.PhoneNumber | полностью (Accepted) | Телефон E.164 (PII). |
IsCounterpartActive | bool | Да | MessengerUserEntity.IsActive | — | false — контрагент заблокирован/архивирован в identity. |
ConnectedAt | DateTimeOffset | Да | ContactEntity.RespondedAt | — | Момент подтверждения контакта. |
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь; требуется право
messenger.contacts.manage
(иначе — FORBIDDEN, fail-closed). Актор берётся из идентификационных данных.
- Проверка входа.
Search — trim, длина 1..128; Page >= 1; PageSize в 1..100 — иначе
VALIDATION_ERROR. Видимость строк ограничена членством актора в паре (шаг 3).
- Что читаем. Выбрать связи со статусом
Accepted, где актор — одна из сторон (индексы
ix_contacts_requester/ix_contacts_addressee по (UserId, Status)). Для страницы CounterpartUserId —
противоположная сторона; профили контрагентов подгружаются пакетно (без N+1). При заданном Search —
запрос к поисковой проекции messenger-contacts, сужённой до множества CounterpartUserId актора
(record-level, ../../search/contacts.md), с fuzzy-матчем по
FullNameSearch/NameTranslit/PhoneDigits; результат — множество совпавших UserId, которым
фильтруется страница. Без Search полнотекстовый поиск не вызывается (чистый постраничный запрос к
PostgreSQL).
- Маппинг. Все строки
Accepted, поэтому маскирование не применяется (полный профиль, INV-CT-5).
Сортировка LastName asc, FirstName asc (тай-брейк ContactId asc).
- Результат. Постраничный список; пустая выдача →
Items = [], TotalCount = 0, 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Проекции / поиск | поисковая проекция MessengerContactSearchDocument (messenger-contacts) при заданном search, scope-filter к контактам актора (ADR-0059); source of truth — MessengerUserEntity/ContactEntity в PostgreSQL |
Свежесть и деградация. Базовый листинг без search идёт напрямую к PostgreSQL — авторитетен и всегда
актуален: только что подтверждённый контакт виден немедленно. Поиск с search использует Elasticsearch-проекцию
(ADR-0059), которая пополняется по событию pin.identity.users.v1 и при подтверждении контакта — окно
eventual-consistency секунды; недавний контакт/смена ФИО могут кратко не находиться поиском, оставаясь видимыми
в базовом листинге. Недоступность проекции — деградация: поиск не работает/отстаёт, листинг без search
продолжает работать; восстановление — воркер contact-search-rebuild. IsCounterpartActive отражает реплику
identity (монотонный version-guard) и может кратко отставать от блокировки/архивации пользователя в identity.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Нарушение таблицы входных (Search длина, Page/PageSize) | «Некорректные параметры запроса» | no retry |
FORBIDDEN | 403 | Нет permission messenger.contacts.manage | «Недостаточно прав» | no retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой | generic | retry (backoff) |
Acceptance Criteria
- Список (happy). Дано: у актора есть подтверждённые контакты. Когда запрашивает без
search. Тогда 200 со
страницей LastName asc, FirstName asc; профиль раскрыт полностью (ФИО + телефон, INV-CT-5); показаны только
Accepted-связи, где актор — сторона пары.
- Поиск по ФИО/телефону. Дано: задан
search (2+ значимых символа). Когда запрашивает. Тогда страница
сужена до контактов актора, совпавших по FullNameSearch/NameTranslit/PhoneDigits (fuzzy); значение
search в логи не пишется.
- Свежесть базового листинга. Дано: контакт только что подтверждён. Когда актор запрашивает без
search.
Тогда контакт присутствует немедленно (PostgreSQL — source of truth), даже если поисковая проекция ещё не
переиндексирована.
- Только свои контакты. Дано: существуют чужие связи. Когда актор запрашивает. Тогда видны только связи, где
actor ∈ {RequesterUserId, AddresseeUserId} (record-level).
- Пустая выдача. Дано: контактов нет (или ни один не совпал с
search). Когда запрашивает. Тогда 200 c
Items = [], TotalCount = 0.
- Некорректный вход. Дано:
Search длиннее 128, Page < 1 или PageSize вне 1..100. Когда запрашивает.
Тогда 400 VALIDATION_ERROR.
- Нет прав. Дано: у актора нет
messenger.contacts.manage. Тогда 403 FORBIDDEN (fail-closed).
Совместимость и наблюдаемость
- Новые optional поля ответа — additive; удаление/переименование/смена типа — breaking (migration + rollout).
- Logs: actor,
hasSearch, TotalCount (без значений телефона/поиска); metrics
messenger_contacts_list_requests_total, latency p95 (SLO списки < 500 мс); traces через traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы