GET /api/messenger/chats — Список чатов пользователя
Назначение
Отдать авторизованному пользователю постранично его чаты (ChatEntity) —
личные Direct и Deal-чаты сделок, в которых он активный участник (participant.md) —
с денормализованным превью/временем последнего сообщения и счётчиком непрочитанных
(unread = Chat.LastMessageSeq − Participant.LastReadSeq, решение №7 README). Точка входа
экрана чатов портала (ADR-0055).
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | chats |
| Feature group | chats |
| Статус | draft |
| Документ | docs/06-services/messenger/api/chats/list-chats.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (инвестор / агент / руководитель агентства / менеджер застройщика / менеджер ПИН) |
| Auth policy / Permissions | permission messenger.chats.view — каталог ../../../identity/domain/permission.md |
| Scope (RBAC) | не применяется — доступ по членству, не по scope/tenant (ADR-0052 п.3, README реш. №6) |
| Record-level | видны только чаты, где есть активный ParticipantEntity (UserId = actor, RemovedAt is null, INV-CHAT-4) — предикат выборки, не пост-фильтр |
| Tenant isolation | нет (кросс-стороннее общение; messenger не tenanted строково, chat.md) |
| Audit | read; ФИО собеседника/превью в логи не пишутся (PII) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/messenger/chats |
| Success status | 200 |
| Idempotency header | не применяется (безопасный GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да | Required; permission messenger.chats.view | membership-предикат |
Kind | query | ChatKind? | Нет | enum строкой Direct/Deal; unknown → ошибка валидации — дом chat.md | — |
OnlyUnread | query | bool? | Нет | true → только чаты с unread > 0; null/false — все | — |
Paging | query | PagingQuery | Нет | Page >= 1 (default 1), PageSize 1..100 (default 20) | — |
Фильтры комбинируются по AND (логические предикаты: отсутствие совпадений → пустая страница, не 404).
Сортировка фиксирована — LastMessageAt desc (свежие сверху), tiebreak Chat.Id desc.
Модель ответа — постраничный список ChatListItemModel
Постраничный ответ: элементы страницы + метаданные пагинации (Page, PageSize, TotalCount).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Items | IReadOnlyList<ChatListItemModel> | Да (может быть пустым) | страница выборки | Элементы страницы. |
Page | int | Да | вход | Текущая страница. |
PageSize | int | Да | вход | Размер страницы. |
TotalCount | long | Да | Count по предикату | Всего чатов под фильтром. |
Вложенная модель ChatListItemModel (проекция chat.md + participant.md)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
ChatId | Guid | Да | Chat.Id | — | Переход в карточку чата (get-chat). |
Kind | ChatKind (enum→string) | Да | Chat.Kind | — | Direct / Deal. |
Status | ChatStatus (enum→string) | Да | Chat.Status | — | Active/Archived/Closed. |
Title | string | Да | Chat.Title (Deal) / резолв (Direct) | — | Для Direct — ФИО собеседника (per-reader, шаг 4); для Deal — денорм-заголовок сделки. |
CounterpartyUserId | Guid? | Нет | активный Participant.UserId != actor | — | Собеседник в Direct; null для Deal. |
DealId | Guid? | Нет | Chat.DealId | — | Сделка Deal-чата (crm/deal.md); null для Direct. |
ProjectId | Guid? | Нет | Chat.ProjectId | — | Проект сделки (навигация/группировка); null для Direct. |
LastMessageId | Guid? | Нет | Chat.LastMessageId | — | Последнее сообщение (денорм). |
LastMessagePreview | string? | Нет | Chat.LastMessagePreview | PII (не в логи) | Короткое превью (≤256; без тела вложения). |
LastMessageAt | DateTimeOffset? | Нет | Chat.LastMessageAt | — | Время последнего сообщения (ключ сортировки). |
LastMessageSeq | long | Да | Chat.LastMessageSeq | — | Seq последнего сообщения. |
UnreadCount | long | Да | computed (шаг 4) | — | max(0, Chat.LastMessageSeq − Participant.LastReadSeq). |
MyLastReadSeq | long | Да | Participant.LastReadSeq | — | Курсор прочитанного актора. |
IsMuted | bool | Да | Participant.IsMuted | — | Пер-участниковая тишина уведомлений. |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь (
User); ActorUserId — из
claims; требуется право messenger.chats.view (иначе MESSENGER_FORBIDDEN). Доступ — по членству, не по
scope/tenant.
- Проверка входа.
Paging.Page >= 1, PageSize ∈ 1..100; Kind (если задан) — известное значение
enum Direct/Deal (unknown → VALIDATION_FAILED). Record-level реализуется предикатом выборки.
- Выборка. Чаты, где есть активный участник-актор (
UserId = actor, RemovedAt is null, INV-CHAT-4,
индексы ix_participants_active/ix_participants_user_id); опц. фильтр Kind; исключаются soft-deleted.
OnlyUnread → добавить условие Chat.LastMessageSeq > Participant.LastReadSeq. Сортировка фиксирована
LastMessageAt desc, tiebreak Chat.Id desc. Для Direct-строк — реплики собеседников за ФИО (одной
выборкой на страницу).
- Маппинг.
UnreadCount = max(0, Chat.LastMessageSeq − Participant.LastReadSeq). Title: Deal —
заголовок сделки; Direct — ФИО собеседника из реплики (per-reader; лаг консюмера → мягкая деградация
подписи-placeholder, не ошибка). Enum передаются строкой.
- Результат. Постраничный список
ChatListItemModel (Items/Page/PageSize/TotalCount) → HTTP
200. Пустая выдача → Items = [], TotalCount = 0 (не 404). Допустима микро-рассинхронизация unread
(денорм-снимок), свежесть — на момент запроса.
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | Page/PageSize вне диапазона; unknown Kind | «Проверьте параметры запроса» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | re-login |
MESSENGER_FORBIDDEN | 403 | нет permission messenger.chats.view | «Недостаточно прав» | no retry |
INTERNAL_ERROR | 500 | инфраструктурный сбой | generic | retry (backoff) |
Совместимость и наблюдаемость
- Новые optional-поля/фильтры — additive; удаление/переименование/смена типа поля или значения enum
ChatKind/ChatStatus — breaking (migration + rollout). Unknown enum во входе — ошибка валидации.
- Logs:
chats listed (ActorUserId, TotalCount, hasUnreadFilter; ФИО/превью — не в логи); metrics:
messenger_chats_list_total{result}, latency p95 (< 500 мс, SLO списков README); traces —
сквозной trace (traceparent); dashboard pin-messenger.
Acceptance Criteria
- Happy path. Given у актора есть чаты, где он активный участник; When
GET ?kind&onlyUnread&paging;
Then 200, только чаты с активным участием (INV-CHAT-4), LastMessageAt desc (tiebreak Chat.Id desc),
UnreadCount = max(0, LastMessageSeq − LastReadSeq).
- Только непрочитанные. Given
OnlyUnread = true; When GET; Then только чаты с unread > 0.
- Фильтр по типу. Given
Kind = Direct|Deal; When GET; Then срез по типу; unknown Kind →
400 VALIDATION_FAILED.
- Direct Title. Given
Direct-строка; When GET; Then Title = ФИО собеседника (per-reader),
CounterpartyUserId заполнен; для Deal — денорм-заголовок сделки, CounterpartyUserId = null.
- Лаг реплики. Given реплика собеседника отстаёт; When
GET; Then placeholder-подпись, не ошибка.
- Пустая выдача. Given у актора нет чатов под фильтром; When
GET; Then Items = [], TotalCount = 0
(не 404).
- Свежесть unread. Given денорм-снимок; Then допустима микро-рассинхронизация
UnreadCount, свежесть —
на момент запроса.
- Доступ. Given нет
messenger.chats.view; When GET; Then 403 MESSENGER_FORBIDDEN; PII (ФИО/превью)
в логи не пишутся.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы