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

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.

Metadata

ПолеЗначение
IDUC-MSG-001
Контекстmessenger
Типосновной (+ A1 чат уже существует, A2 адресат в чёрном списке, A3 адресат неактивен, A4 поиск без совпадений, A5 гонка двух открытий)
Статусdraft

Актор

ПолеЗначение
РольИнвестор / Агент / Руководитель агентства / Менеджер застройщика / Менеджер ПИН (все User)
Permissions / scopemessenger.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-directPOST /api/messenger/chats/{chatId}/messages

Основной Поток

  1. Поиск по телефону. Актор вводит телефон; система нормализует ввод к E.164 и делает точечный lookup по уникальному телефону (ux_users_phone) — 0 или 1 совпадение (не FTS, INV-MU-2).
  2. Маскированная выдача. Для каждого найденного возвращается профиль { UserId, FirstName, MiddleName?, LastNameMasked ("И."), PhoneMasked } — фамилия маскируется первой буквой, пока актор не в Accepted-контакте (INV-MU-3); IsActive = false — не выдаётся для новых чатов.
  3. Открыть чат. Актор выбирает пользователя (PeerUserId); инициатор (RequesterUserId) берётся из токена (стамп, не из тела); опционально передаётся ключ идемпотентности.
  4. Гейт и вычисление ключа. Проверить: 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.
  5. Создание/возврат чата (идемпотентно). По DirectKey найти Direct-чат: есть (A1) → вернуть существующий; нет → открыть новый (Chat.OpenDirect: Active, ровно 2 Participant роли Member, INV-CHAT-7, событие ChatCreated) под уникальным ux_chats_direct_key (гонка двух открытий даёт один чат, INV-CHAT-1).
  6. Отправка первого сообщения. По send-text-message.md: проверить членство (INV-CHAT-4) и CanMessage; зарегистрировать сообщение (Chat.RegisterPosted: Seq = ++счётчик, денорм last-message, INV-CHAT-5); захватить IpAddress/UserAgent (Sensitive, INV-MSG-6).
  7. Согласованность и события. Чат/сообщение, доменные факты (ChatCreated/MessagePosted), realtime-доставка (message_received, persist-then-push) и интеграционное MessagePostedEvent (pin.messenger.message-posted.v1, без тела/IP/UA) фиксируются в одном согласованном изменении.
  8. Ответ и realtime. Возврат { ChatId, PeerDisplay }200/201; realtime-доставка пушит message_received в chat:{ChatId}; notifications по message-posted шлёт offline-push собеседнику. Фронт открывает тред. PeerDisplay резолвится на чтении из реплики MessengerUserEntity; если реплика собеседника отстаёт — отдаётся безопасный placeholder, доступ по членству от этого не зависит (eventual, имя догоняет по мере обработки pin.identity.users.v1).

Диаграмма

Альтернативные Потоки

A1. Чат с этим пользователем уже существует

  1. По DirectKey найден чат → открытие не создаёт новый, отдаёт существующий ChatId (идемпотентно, INV-CHAT-1); событий/мутаций нет. 201 заменяется на 200.
  2. Фронт переходит в существующий тред; поток продолжается с шага 6 (отправка) при необходимости.

A2. Адресат в чёрном списке (любой стороной)

  1. CommunicationPolicy.CanMessage(actor, peer) = deny (INV-BL-3) → MSG_COMMUNICATION_BLOCKED (409, общая ошибка — не раскрывает, кто/когда заблокировал, приватность blacklist.md).
  2. Чат/сообщение не создаются. Клиент скрывает кнопки «написать»; завершается.

A3. Адресат не найден / неактивен

  1. Адресата нет или IsActive = false (identity Blocked/Archived) → MSG_USER_NOT_FOUND (404). Поток завершается.

A4. Поиск по телефону без совпадений

  1. Нормализованный телефон не найден (ux_users_phone — 0 совпадений, INV-MU-2) → не ошибка: пустой список. Это не различимо для актора от «пользователь скрыт»/IsActive = false (приватность — не раскрываем существование). Кнопка «написать» не показывается; поток завершается на шаге 2.

A5. Гонка двух открытий чата на одну пару

  1. Два параллельных 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_FAILED400телефон не парсится в E.164 / Body вне 1..8000Показать ошибку формы
MSG_USER_NOT_FOUND404адресат не найден / IsActive = false (A3)«Пользователь недоступен»
MSG_COMMUNICATION_BLOCKED409блокировка любой стороной (A2, INV-BL-3)Скрыть «написать», общее сообщение
MSG_CHAT_ACCESS_DENIED403отправка в чат без активного членства (INV-CHAT-4)«Нет доступа к чату»
MSG_SELF_CHAT400PeerUserId == actor«Нельзя открыть чат с собой»
ConcurrencyConflict409гонка двух открытий/постовРетрай (перечитка) — вернётся тот же чат

Доменные События

СобытиеКогда поднимаетсяSubscribers
ChatCreatedOpenDirect создал Direct-чатлента ChatEventEntity ChatCreated; realtime ChatUpdated
MessagePostedChat.RegisterPosted (первое сообщение)обновление Seq/unread + realtime MessageReceived

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
MessagePostedEventpin.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
AC5message-posted публикуется без тела; notifications уведомляет; realtime пушит в chat:{ChatId}Тест: payload без Body/IP; realtime MessageReceived
AC6Дано телефон без совпадений; когда актор ищет; тогда пустой список без ошибки, «написать» скрыто (A4, приватность)Тест: search200 пустой, существование адресата не раскрыто
AC7Дано два параллельных open-direct на пару; когда оба исполняются; тогда создан один чат, проигравший при перечитке получает тот же ChatId (A5)Тест гонки: один ux_chats_direct_key, второй → 409 → тот же ChatId
AC8Дано реплика имени собеседника отстаёт; когда актор открывает чат; тогда заголовок/подпись — placeholder, доступ по членству работает, имя догоняет (eventual)Тест: отсутствие MessengerUserEntity → placeholder, чат доступен

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

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

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