POST /api/messenger/contacts/requests — Отправить заявку в контакты
Назначение
Инициатор отправляет адресату заявку в контакты. Создаётся связь ContactEntity со статусом Pending,
детерминированным ключом пары PairKey (для дедупликации). Публикуется событие ContactRequestCreated в
топик pin.messenger.contact-request-created.v1 (потребитель — notifications), адресату уходит
realtime-push ContactRequestReceived. Писать сообщение не-контакту разрешено и без заявки; заявка нужна для
звонка и раскрытия профиля. Заблокированному пользователю (чёрный список любой стороны) заявку отправить
нельзя (INV-CT-4).
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | public |
| Feature group | contacts |
| Статус | draft |
| Документ | docs/06-services/messenger/api/contacts/send-contact-request.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User |
| Auth policy / Permissions | messenger.contacts.manage (каталог — ../../../identity/domain/permission.md) |
| Scope (RBAC) | платформа (person-level, вне tenant, ADR-0052) |
| Record-level | инициатор RequesterUserId = actor; видимость строки — участникам пары |
| Tenant isolation | не применяется — контакты вне tenant |
| Audit | create (CreatedByUserId = actor = RequesterUserId); RequestMessage в логи не пишется |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/messenger/contacts/requests |
| Success status | 201 (создано); идемпотентный повтор при активной паре → 200 (существующая связь) |
| Idempotency header | не применяется (бизнес-дедуп по ограничению активной пары ux_contacts_active_pair) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да (вычисляется) | = RequesterUserId | record-level |
AddresseeUserId | body | Guid | Да | != Guid.Empty; != ActorUserId (INV-CT-1) | — |
RequestMessage | body | string? | Нет | 0..512 | PII: в логи не пишется |
Проекция ../../domain/contact.md; состав возвращается сразу после подачи заявки.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
ContactId | Guid | Да | ContactEntity.Id | — | Идентификатор связи. |
Status | ContactStatus (string) | Да | ContactEntity.Status | — | Pending (или существующий статус при идемпотентном повторе). |
AddresseeUserId | Guid | Да | ContactEntity.AddresseeUserId | — | Адресат заявки. |
AlreadyExisted | bool | Да | computed | — | true — вернули существующую активную связь (идемпотентный 200). |
CreatedAt | DateTimeOffset | Да | ContactEntity.CreatedAt | — | Момент подачи. |
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь-инициатор (
actor = RequesterUserId); требуется
право messenger.contacts.manage (иначе — FORBIDDEN).
- Проверка входа.
AddresseeUserId непуст и != ActorUserId (INV-CT-1, иначе — MSG_CONTACT_SELF);
RequestMessage длиной 0..512 — иначе VALIDATION_FAILED.
- Проверка адресата, дедуп и чёрный список. Адресат должен существовать и быть активным, иначе —
MSG_CONTACT_USER_NOT_FOUND (INV-CT-6). По ключу пары PairKey(actor, Addressee) проверить дедуп
(INV-CT-2): если уже есть активная связь (Pending/Accepted) — вернуть её (AlreadyExisted = true,
идемпотентный 200). Проверить чёрный список (INV-CT-4): если любая сторона заблокировала другую —
MSG_CONTACT_BLOCKED (общая формулировка ради приватности ../../domain/blacklist.md).
- Создание связи. Завести
ContactEntity со статусом Pending, RequesterUserId = actor, заданным
AddresseeUserId, RequestMessage, ключом пары PairKey и CreatedByUserId = actor.
- Что меняется. Появляется единственный агрегат связи. Создание и факт события фиксируются в одном
согласованном изменении. Гонка
INV-CT-2 (параллельная заявка той же пары) закрывается ограничением
активной пары ux_contacts_active_pair — при конфликте возвращается существующая связь (200).
- Факты и события. Публикуется интеграционное событие
ContactRequestCreated в топик
pin.messenger.contact-request-created.v1 (ContractVersion = 1, ключ AddresseeUserId) — контракт
../../events/contact-request-created.md. Адресату уходит
realtime-push ContactRequestReceived.
- Результат.
ContactRequestResultModel; 201 (создано) или 200 (идемпотентный повтор,
AlreadyExisted = true).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | ContactRequestCreated / pin.messenger.contact-request-created.v1 / key AddresseeUserId |
| Уведомления | notifications (консюмер события) — уведомить адресата о заявке; realtime-push ContactRequestReceived |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | Формат тела (AddresseeUserId, RequestMessage) | «Проверьте корректность формы» | no retry |
MSG_CONTACT_SELF | 400 | INV-CT-1: заявка самому себе | «Нельзя добавить в контакты себя» | no retry |
FORBIDDEN | 403 | Нет permission messenger.contacts.manage | «Недостаточно прав» | no retry |
MSG_CONTACT_USER_NOT_FOUND | 404 | INV-CT-6: адресат не найден/неактивен | «Пользователь не найден» | no retry |
MSG_CONTACT_BLOCKED | 409 | INV-CT-4: пара в чёрном списке (любая сторона) | «Отправка заявки недоступна» | no retry |
INV-CT-2 (активная пара уже есть) — не ошибка: идемпотентный возврат существующей связи (200).
Acceptance Criteria
- Новая заявка. Дано активный адресат, между парой нет активной связи и нет блокировки. Когда
POST.
Тогда создаётся ContactEntity со статусом Pending (RequesterUserId = actor), ответ 201
(AlreadyExisted = false), публикуется ContactRequestCreated в pin.messenger.contact-request-created.v1
(ключ AddresseeUserId), адресату уходит realtime-push ContactRequestReceived.
- Идемпотентный повтор. Дано на пару уже есть активная связь (
Pending/Accepted). Когда повторная
заявка. Тогда 200, AlreadyExisted = true, возвращается существующая связь, дубля нет (INV-CT-2).
- Заявка самому себе. Дано
AddresseeUserId = ActorUserId. Когда POST. Тогда MSG_CONTACT_SELF (400)
(INV-CT-1).
- Адресат не найден/неактивен. Дано
AddresseeUserId не существует или неактивен. Когда POST. Тогда
MSG_CONTACT_USER_NOT_FOUND (404) (INV-CT-6).
- Чёрный список. Дано любая сторона заблокировала другую. Когда
POST. Тогда MSG_CONTACT_BLOCKED (409)
общей формулировкой ради приватности (INV-CT-4) — какая именно сторона заблокировала, не раскрывается.
- Гонка параллельных заявок пары. Дано две одновременные заявки той же пары. Когда обе доходят. Тогда
ограничение
ux_contacts_active_pair пропускает одну (создаёт Pending), вторая возвращает существующую
связь (200, AlreadyExisted = true).
- Валидация сообщения. Дано
RequestMessage длиннее 512. Когда POST. Тогда VALIDATION_FAILED (400);
текст сообщения в логи не пишется.
Совместимость и наблюдаемость
- Новые optional поля тела/ответа — additive; удаление/переименование/смена типа — breaking (migration).
- Контракт события версионируется (
ContractVersion); новые поля payload — additive.
- Logs: actor,
contactId, addresseeHash, alreadyExisted (без ФИО/сообщения); metrics
messenger_contact_requests_created_total{result}, latency p95; traces через traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы