GET /api/messenger/contacts/search — Глобальный поиск пользователя по телефону
Назначение
Находит пользователя платформы по номеру телефона, чтобы отправить ему заявку в контакты или открыть чат.
Поиск сквозной (person-level, вне tenant): ищущий вводит телефон, система нормализует его к формату E.164 и
ищет точное совпадение среди пользователей мессенджера. До подтверждённого контакта профиль маскирован —
«Имя Отчество + первая буква Фамилии» (INV-MU-3); полный профиль раскрывается только при существующем
Accepted-контакте (INV-CT-5). Вызывает любой аутентифицированный пользователь; возвращается 0 или 1
результат (телефон уникален).
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | public |
| Feature group | contacts |
| Статус | draft |
| Документ | docs/06-services/messenger/api/contacts/search-users-by-phone.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (любой аутентифицированный пользователь платформы) |
| Auth policy / Permissions | messenger.contacts.search (каталог — ../../../identity/domain/permission.md) |
| Scope (RBAC) | платформа (поиск сквозной, person-level, ADR-0052) |
| Record-level | не применяется (глобальный справочник); результат маскируется до Accepted |
| Tenant isolation | не применяется — пользователи вне tenant |
| Audit | read-sensitive (поиск по PII-телефону); в лог — только факт поиска, телефон маской +7***NNNN |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/messenger/contacts/search |
| Success status | 200 (найдено 0 или 1 — телефон уникален ux_users_phone) |
| Idempotency header | не применяется (GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да (вычисляется) | != Guid.Empty | исключается из результата (не найти себя) |
Phone | query (phone) | string | Да | нормализация к E.164 (^\+[1-9]\d{9,14}$), max 32 после нормализации | PII: маска +7***NNNN в логах |
Модель ответа UserSearchResultModel
Эндпоинт возвращает список из 0 или 1 элемента (телефон уникален). Проекция
../../domain/messenger-user.md; маскирование — по RelationshipStatus.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
UserId | Guid | Да | MessengerUserEntity.UserId | — | Адресат для send-contact-request / открытия чата. |
FirstName | string | Да | MessengerUserEntity.FirstName | полностью | Имя (отдаётся всегда, INV-MU-3). |
MiddleName | string? | Нет | MessengerUserEntity.MiddleName | полностью | Отчество, если есть. |
LastNameMasked | string | Да | MessengerUserEntity.LastName | LastName[0]+'.' до Accepted; полностью при Accepted | Фамилия под маской (INV-MU-3/INV-CT-5). |
PhoneMasked | string | Да | MessengerUserEntity.PhoneNumber | +7***NNNN до Accepted; полностью при Accepted | Телефон (PII); ищущий уже владеет вводом. |
IsActive | bool | Да | MessengerUserEntity.IsActive | — | false — недоступен для новых заявок (INV-MU). |
RelationshipStatus | ContactRelationshipStatus (string) | Да | вычислено из ContactEntity | — | None/RequestSent/RequestReceived/Contact. |
CanSendRequest | bool | Да | computed | — | false, если уже есть активная связь (Pending/Accepted) или сам пользователь. |
Алгоритм
- Контекст и доступ. Операцию выполняет аутентифицированный пользователь; требуется право
messenger.contacts.search (иначе — FORBIDDEN, fail-closed).
- Проверка входа.
Phone непуст и нормализуется к E.164; несоответствие формату — VALIDATION_ERROR.
- Что читаем. Точечный поиск пользователя по нормализованному телефону (уникальный
ux_users_phone);
если нет совпадения — пустой список (шаг 5). Самого актора исключаем (нельзя найти себя). Для найденного —
статус связи актора с ним по паре (индекс активной пары ux_contacts_active_pair).
- Маппинг и маскирование.
RelationshipStatus: нет связи → None; Pending и актор инициатор →
RequestSent; Pending и актор адресат → RequestReceived; Accepted → Contact. При
RelationshipStatus == Contact — полные LastName/PhoneNumber; иначе LastNameMasked = LastName[0]+'.',
PhoneMasked = +7***NNNN (INV-MU-3). CanSendRequest = RelationshipStatus == None && IsActive (наличие
блокировки не раскрывается — приватность ../../domain/blacklist.md;
фактический запрет проявится на send-contact-request общей ошибкой).
- Результат. Список из 0 или 1 элемента; не найдено →
[] (валидное «ничего не найдено», без 404 — не
раскрываем существование номера).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Проекции / поиск | чтение реплики пользователей (ux_users_phone); внешнего поиска нет (ADR-0056) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | Phone пустой / не приводится к E.164 | «Укажите корректный номер телефона» | no retry |
FORBIDDEN | 403 | Нет permission messenger.contacts.search | «Недостаточно прав» | no retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой | generic | retry (backoff) |
Отсутствие пользователя — не ошибка: 200 + [] (не раскрываем, зарегистрирован ли номер).
Acceptance Criteria
- Найден незнакомец. Дано номер зарегистрированного активного пользователя без связи с актором. Когда
поиск. Тогда
200 c одним результатом, RelationshipStatus = None, LastNameMasked = LastName[0]+'.',
PhoneMasked = +7***NNNN (INV-MU-3), CanSendRequest = true.
- Найден контакт. Дано с найденным есть
Accepted-связь. Когда поиск. Тогда RelationshipStatus = Contact, полные LastName/PhoneNumber (INV-CT-5), CanSendRequest = false.
- Есть заявка. Дано
Pending-связь. Когда поиск: актор-инициатор → RelationshipStatus = RequestSent;
актор-адресат → RequestReceived; в обоих случаях CanSendRequest = false.
- Номер не зарегистрирован. Дано телефон, которого нет среди пользователей. Когда поиск. Тогда
200 +
[] (не 404 — существование номера не раскрывается).
- Поиск себя. Дано актор вводит собственный телефон. Когда поиск. Тогда
[] (сам исключён из выдачи).
- Некорректный номер. Дано телефон не приводится к E.164. Когда поиск. Тогда
VALIDATION_ERROR (400).
- Блокировка не раскрывается. Дано одна сторона в чёрном списке другой. Когда поиск. Тогда результат
обычный (
CanSendRequest = None && IsActive), факт блокировки скрыт; запрет проявится общей ошибкой лишь
на send-contact-request.
- Приватность в логах. В лог идёт
phoneHash/found/relationshipStatus; полный телефон и ФИО не
логируются (маска +7***NNNN).
Совместимость и наблюдаемость
- Новые optional поля результата — additive; смена правил маскирования — breaking для UI (fallback обязателен).
ContactRelationshipStatus — enum строкой; клиентский fallback на unknown обязателен.
- Logs: actor,
phoneHash, found (bool), relationshipStatus (без полного телефона/ФИО); metrics
messenger_contacts_search_total{found}, latency p95; traces через traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы