UC-MSG-001. Открыть личный чат по поиску пользователя по телефону
Назначение
Пользователь (инвестор/агент) находит другого пользователя глобальным поиском по телефону (в выдаче до
подтверждения контакта — Имя Отчество + первая буква Фамилии), открывает с ним личный 1:1-чат
(chat.md, Kind = Direct) и отправляет первое сообщение. Открытие идемпотентно по
паре (DirectKey, INV-CHAT-1); писать можно не-контакту — запрет только по чёрному списку
(blacklist.md, CommunicationPolicy.CanMessage). Детали алгоритма/маппинга — в
API-методах: search-users-by-phone.md,
open-direct-chat.md,
send-text-message.md.
| Поле | Значение |
|---|
| ID | UC-MSG-001 |
| Контекст | messenger |
| Тип | основной (+ A1 чат уже существует, A2 адресат в чёрном списке, A3 адресат неактивен, A4 поиск без совпадений, A5 гонка двух открытий) |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Инвестор / Агент / Руководитель агентства / Менеджер застройщика / Менеджер ПИН (все User) |
| Permissions / scope | messenger.contacts.search (поиск), messenger.chats.write (открытие/отправка); guest → 401 |
| Tenant | не применяется — messenger кросс-тенантный, доступ по членству (ADR-0052); MessengerUserEntity — person-level |
| Record-level | доступ к чату = активный ParticipantEntity (INV-CHAT-4); поиск — маска фамилии до Accepted-контакта (INV-MU-3) |
Предусловие
- Актор аутентифицирован; реплика
MessengerUserEntity адресата существует
и IsActive = true (наполнена консюмером pin.identity.users.v1).
- Ни одна из сторон не заблокировала другую в чёрном списке (иначе
CanMessage = deny, A2).
Триггер
| Поле | Значение |
|---|
| Источник | UI (страница /profile → «Контакты» / поиск) → API call |
| Триггерящий элемент | GET /api/messenger/contacts/search?phone=… → POST /api/messenger/chats/open-direct → POST /api/messenger/chats/{chatId}/messages |
Основной Поток
- Поиск по телефону. Актор вводит телефон; система нормализует ввод к E.164 и делает точечный lookup
по уникальному телефону (
ux_users_phone) — 0 или 1 совпадение (не FTS, INV-MU-2).
- Маскированная выдача. Для каждого найденного возвращается профиль
{ UserId, FirstName, MiddleName?, LastNameMasked ("И."), PhoneMasked } — фамилия маскируется первой буквой, пока актор не в
Accepted-контакте (INV-MU-3); IsActive = false — не выдаётся для новых чатов.
- Открыть чат. Актор выбирает пользователя (
PeerUserId); инициатор (RequesterUserId) берётся из
токена (стамп, не из тела); опционально передаётся ключ идемпотентности.
- Гейт и вычисление ключа. Проверить:
PeerUserId != actor (self-guard, иначе MSG_SELF_CHAT);
адресат существует и IsActive = true, иначе MSG_USER_NOT_FOUND (A3); политика общения
CanMessage(actor, peer) — нет блокировки ни одной стороной (blacklist.md,
INV-BL-3), иначе MSG_COMMUNICATION_BLOCKED (A2). Вычислить DirectKey = min(a,b):max(a,b) по Guid.
- Создание/возврат чата (идемпотентно). По
DirectKey найти Direct-чат: есть (A1) → вернуть
существующий; нет → открыть новый (Chat.OpenDirect: Active, ровно 2 Participant роли Member,
INV-CHAT-7, событие ChatCreated) под уникальным ux_chats_direct_key (гонка двух открытий даёт один
чат, INV-CHAT-1).
- Отправка первого сообщения. По send-text-message.md:
проверить членство (INV-CHAT-4) и
CanMessage; зарегистрировать сообщение (Chat.RegisterPosted:
Seq = ++счётчик, денорм last-message, INV-CHAT-5); захватить IpAddress/UserAgent (Sensitive,
INV-MSG-6).
- Согласованность и события. Чат/сообщение, доменные факты (
ChatCreated/MessagePosted),
realtime-доставка (message_received, persist-then-push) и интеграционное MessagePostedEvent
(pin.messenger.message-posted.v1, без тела/IP/UA) фиксируются в одном согласованном изменении.
- Ответ и realtime. Возврат
{ ChatId, PeerDisplay } → 200/201; realtime-доставка пушит
message_received в chat:{ChatId}; notifications по message-posted шлёт offline-push собеседнику.
Фронт открывает тред. PeerDisplay резолвится на чтении из реплики MessengerUserEntity; если реплика
собеседника отстаёт — отдаётся безопасный placeholder, доступ по членству от этого не зависит (eventual,
имя догоняет по мере обработки pin.identity.users.v1).
Диаграмма
Альтернативные Потоки
A1. Чат с этим пользователем уже существует
- По
DirectKey найден чат → открытие не создаёт новый, отдаёт существующий ChatId (идемпотентно,
INV-CHAT-1); событий/мутаций нет. 201 заменяется на 200.
- Фронт переходит в существующий тред; поток продолжается с шага 6 (отправка) при необходимости.
A2. Адресат в чёрном списке (любой стороной)
CommunicationPolicy.CanMessage(actor, peer) = deny (INV-BL-3) → MSG_COMMUNICATION_BLOCKED (409,
общая ошибка — не раскрывает, кто/когда заблокировал, приватность blacklist.md).
- Чат/сообщение не создаются. Клиент скрывает кнопки «написать»; завершается.
A3. Адресат не найден / неактивен
- Адресата нет или
IsActive = false (identity Blocked/Archived) → MSG_USER_NOT_FOUND (404). Поток
завершается.
A4. Поиск по телефону без совпадений
- Нормализованный телефон не найден (
ux_users_phone — 0 совпадений, INV-MU-2) → не ошибка: пустой
список. Это не различимо для актора от «пользователь скрыт»/IsActive = false (приватность — не
раскрываем существование). Кнопка «написать» не показывается; поток завершается на шаге 2.
A5. Гонка двух открытий чата на одну пару
- Два параллельных
open-direct с одинаковым DirectKey → уникальный ux_chats_direct_key пропускает
ровно один Chat.OpenDirect; проигравший получает ConcurrencyConflict (409), при перечитке возвращает
тот же ChatId (INV-CHAT-1). Второго чата не возникает; клиент повторяет и открывает существующий тред.
Постусловие
- Существует ровно один
Direct-чат на пару (DirectKey unique); актор — активный Participant.
- При отправке: создан
MessageEntity (Text, Seq), денорм last-message чата обновлён (INV-CHAT-5);
захвачены IpAddress/UserAgent (Sensitive, наружу не отдаются).
- Опубликован
pin.messenger.message-posted.v1 (без тела/PII); realtime message_received доставлен в hub.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
VALIDATION_FAILED | 400 | телефон не парсится в E.164 / Body вне 1..8000 | Показать ошибку формы |
MSG_USER_NOT_FOUND | 404 | адресат не найден / IsActive = false (A3) | «Пользователь недоступен» |
MSG_COMMUNICATION_BLOCKED | 409 | блокировка любой стороной (A2, INV-BL-3) | Скрыть «написать», общее сообщение |
MSG_CHAT_ACCESS_DENIED | 403 | отправка в чат без активного членства (INV-CHAT-4) | «Нет доступа к чату» |
MSG_SELF_CHAT | 400 | PeerUserId == actor | «Нельзя открыть чат с собой» |
ConcurrencyConflict | 409 | гонка двух открытий/постов | Ретрай (перечитка) — вернётся тот же чат |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
ChatCreated | OpenDirect создал Direct-чат | лента ChatEventEntity ChatCreated; realtime ChatUpdated |
MessagePosted | Chat.RegisterPosted (первое сообщение) | обновление Seq/unread + realtime MessageReceived |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
MessagePostedEvent | pin.messenger.message-posted.v1 | при [*] → Sent первого/любого сообщения | notifications (offline push/email собеседнику) |
Payload { ChatId, MessageId, Seq, Kind, SenderUserId?, SenderRole?, SentAt, Preview }, ContractVersion = 1,
ключ = ChatId; без тела/IP/UA (message.md). Открытие чата Kafka-событий не порождает (in-process
доменное + realtime). Дом контракта — message-posted.md.
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Поиск отдаёт маску фамилии до Accepted-контакта; IsActive=false не выдаётся | Тест search-users-by-phone: LastNameMasked="И.", заблокированный/архивный скрыт |
| AC2 | Открытие идемпотентно: повтор возвращает тот же ChatId, второго чата нет (ux_chats_direct_key) | Тест: два open-direct на пару → один чат, 201 затем 200 |
| AC3 | Писать можно не-контакту; запрет только по чёрному списку | Тест: CanMessage без контакта → 201; при blacklist → 409 (A2) |
| AC4 | Первое сообщение выдаёт Seq, обновляет last-message, захватывает IP/UA (не в ответе/событии) | Тест send-text: Seq=1, LastMessagePreview set; IP/UA в БД, не в message-posted |
| AC5 | message-posted публикуется без тела; notifications уведомляет; realtime пушит в chat:{ChatId} | Тест: payload без Body/IP; realtime MessageReceived |
| AC6 | Дано телефон без совпадений; когда актор ищет; тогда пустой список без ошибки, «написать» скрыто (A4, приватность) | Тест: search → 200 пустой, существование адресата не раскрыто |
| AC7 | Дано два параллельных open-direct на пару; когда оба исполняются; тогда создан один чат, проигравший при перечитке получает тот же ChatId (A5) | Тест гонки: один ux_chats_direct_key, второй → 409 → тот же ChatId |
| AC8 | Дано реплика имени собеседника отстаёт; когда актор открывает чат; тогда заголовок/подпись — placeholder, доступ по членству работает, имя догоняет (eventual) | Тест: отсутствие MessengerUserEntity → placeholder, чат доступен |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы