GET /api/messenger/chats/{chatId}/messages — История чата (keyset-пагинация)
Назначение
Отдаёт участнику страницу ленты сообщений чата — text / attachment / event-card — упорядоченную по монотонному
per-chat Seq, keyset-пагинацией «прокрутка вверх» (курсор по Seq, а не offset). Только чтение проекции
сообщения (message.md); скрытая история правок (MessageEditEntity) и Sensitive
IpAddress/UserAgent не отдаются (INV-MSG-2, кроме admin-арбитража); per-participant видимость
event-карточек применяется на чтении (INV-MSG-5). Вызывает участник чата для первичной загрузки и подгрузки
«вверх» (realtime-новые приходят через хаб; при переподключении клиент перезапрашивает REST).
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | messages |
| Feature group | messages |
| Статус | draft |
| Документ | docs/06-services/messenger/api/messages/list-messages.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Инвестор / Агент / Руководитель / Менеджер застройщика / Менеджер ПИН) |
| Auth policy / Permissions | authenticated (JWT) + permission messenger.chats.view |
| Scope (RBAC) | не применяется: доступ по членству, не по scope (ADR-0055/0052) |
| Record-level | активный ParticipantEntity (ChatId,UserId = actor, RemovedAt is null) — INV-CHAT-4 |
| Tenant isolation | не tenanted: чаты кросс-сторонние; изоляция — членство (chat.md) |
| Audit | read (не sensitive наружу): IpAddress/UserAgent и история правок из ответа исключены |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/messenger/chats/{chatId}/messages |
| Success status | 200 |
| Idempotency header | не применяется (read-only, безопасный метод) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ChatId | route | Guid | Да | != Guid.Empty; существующий чат; actor — активный участник (см. Алгоритм) | — |
BeforeSeq | query | long? | Нет | keyset-курсор: вернуть сообщения с Seq < BeforeSeq (страница «старее»); null — от последних; >= 1 | — |
PageSize | query | int | Нет | default 30, 1..100 | защита от unbounded |
UserId | identity claims | Guid | Да | Required; предикат членства + фильтр видимости карточек (INV-MSG-5) | scope isolation |
Keyset (не offset) обоснован высокой объёмностью ленты и стабильностью курсора при дозаписи (ADR-0056);
Seq монотонен по SentAt, поэтому упорядочение по времени и по Seq совпадают.
Модель ответа MessagePageModel
Контейнер keyset-страницы (offset здесь неуместен). Дом полей — message.md; ниже —
проекция строки ленты (union через Kind + типизированные nullable-подмодели).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Items | IReadOnlyList<MessageModel> | Да | проекция MessageEntity | — | Страница, порядок Seq desc (новые→старые); клиент разворачивает для показа. |
NextBeforeSeq | long? | Нет | min(Items.Seq) | — | Курсор для следующей страницы «вверх»; null — достигнуто начало. |
HasMore | bool | Да | computed | — | Есть ли ещё более старые сообщения. |
MessageModel — общие поля + одна непустая под-модель по Kind:
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | MessageEntity.Id | — | Идентификатор сообщения. |
Seq | long | Да | Seq | — | Порядок в ленте / курсор. |
Kind | string (enum MessageKind) | Да | Kind | — | Text / Attachment / EventCard. |
SenderUserId | Guid? | Нет | SenderUserId | — | Автор; null — системная карточка. |
SenderRole | string? (enum ParticipantRole) | Нет | SenderRole | — | Роль автора на момент отправки. |
IsSystem | bool | Да | IsSystem | — | true — event-card/системное. |
SentAt | DateTimeOffset | Да | SentAt | — | Момент отправки. |
EditedAt | DateTimeOffset? | Нет | EditedAt | — | Момент последней правки (флаг «изменено»). |
IsDeleted | bool | Да | DeletedAt != null | тело скрыто | Удалено → тело/подпись не отдаются («сообщение удалено»). |
EditableUntil | DateTimeOffset? | Нет | EditableUntil | — | Граница окна правки для автора (UI). |
Text | MessageTextModel? | Нет | TextMessageEntity | — | { Body: string }; null, если не Text или удалено. |
Attachment | MessageAttachmentModel? | Нет | AttachmentMessageEntity | — | { FileId, FileName, ContentType, SizeBytes, Caption?, AttachmentKind, DownloadUrl } (presign, см. Алгоритм). |
Card | MessageCardModel? | Нет | EventCardMessageEntity + recipient | — | { CardType, Category, ActionStatus, Text, DealId? }; Text = TextOverride ?? DefaultText. |
IpAddress/UserAgent и MessageEditEntity в модель не попадают (Sensitive/internal, INV-MSG-6).
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь; требуется право
messenger.chats.view (иначе —
FORBIDDEN; без JWT — UNAUTHORIZED). ChatId берётся из маршрута, BeforeSeq/PageSize — из query.
- Проверка входа и предусловий.
ChatId непуст, BeforeSeq >= 1 (если задан), PageSize в 1..100 —
иначе VALIDATION_FAILED. Актор должен быть активным участником чата — иначе MSG_CHAT_ACCESS_DENIED
(существование чата отдельно не раскрывается — тот же 403). Статус чата чтение не ограничивает (историю
Closed-чата читать можно).
- Что читаем. Keyset-выборка сообщений чата:
Seq < BeforeSeq (если задан), сортировка Seq desc,
берём PageSize + 1 строк (лишняя определяет HasMore). Индекс ux_messages_chat_seq (ChatId,Seq)
покрывает keyset и порядок; для фильтра видимости карточек подгружаются recipient-строки.
- Маппинг и видимость.
NextBeforeSeq = min(Seq) страницы. Фильтр видимости карточек (INV-MSG-5): нет
recipient-строк ⇒ видно всем (DefaultText); иначе строка UserId с IsVisible = true, Text = TextOverride ?? DefaultText; невидимые карточки исключаются. Удалённые (DeletedAt != null):
IsDeleted = true, тело/подпись не отдаются (строка остаётся ради Seq). Для Attachment — presign
download-URL у storage-адаптера по FileId (короткоживущий, не логируется). IP/UA и история правок
исключены.
- Результат.
MessagePageModel { Items (Seq desc), NextBeforeSeq, HasMore }, 200 (пустой Items для
пустого чата — тоже 200).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Внешние вызовы | storage/CDN presign download-URL для Attachment (короткий TTL; FileId/URL не логируются) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | пустой ChatId, BeforeSeq < 1, PageSize вне 1..100 | «Проверьте параметры запроса» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
FORBIDDEN | 403 | нет permission messenger.chats.view | «Недостаточно прав» | no retry |
MSG_CHAT_ACCESS_DENIED | 403 | actor — не активный участник (или чат не существует, единый ответ) | «Нет доступа к чату» | no retry |
Acceptance Criteria
- Первая страница. Дано активный участник и
BeforeSeq не задан. Когда GET. Тогда 200 с
PageSize последних сообщений в порядке Seq desc, NextBeforeSeq = min(Seq), HasMore отражает наличие
более старых.
- Подгрузка вверх. Дано задан
BeforeSeq. Когда GET. Тогда возвращаются сообщения Seq < BeforeSeq;
при достижении начала NextBeforeSeq = null, HasMore = false.
- Видимость event-карточек (INV-MSG-5). Дано
EventCard c recipient-строками. Когда читает
получатель. Тогда Text = TextOverride ?? DefaultText; невидимые для актора карточки исключены; при
отсутствии recipient-строк карточка видна всем.
- Удалённое сообщение. Дано soft-deleted сообщение. Когда
GET. Тогда IsDeleted = true,
Text/Attachment = null, Seq сохранён (строка остаётся ради порядка).
- Вложение. Дано
Attachment. Когда GET. Тогда DownloadUrl — короткоживущий presign у
storage-адаптера (не логируется).
- Закрытый чат. Дано чат
Closed. Когда GET. Тогда история читается (статус чтение не ограничивает).
- Не участник / несуществующий чат. Дано актор не активный участник (или чата нет). Когда
GET. Тогда
MSG_CHAT_ACCESS_DENIED (403) единым ответом — существование чата не раскрывается.
- Валидация. Дано
PageSize вне 1..100 или BeforeSeq < 1. Когда GET. Тогда VALIDATION_FAILED
(400).
- Приватность.
IpAddress/UserAgent и история правок (MessageEditEntity) в ответ не входят
(INV-MSG-6).
Совместимость и наблюдаемость
- Новые optional поля
MessageModel/под-моделей — additive без breaking change; удаление/переименование/
смена типа поля или значения enum (MessageKind/ParticipantRole) — breaking (migration + rollout).
- Logs:
chat-messages-listed (ChatId, UserId, PageSize, count; тела/FileId/IP/UA не пишутся);
metrics: messenger_messages_list_total{result}, latency p95 (SLO списков < 500 мс); traces через
traceparent; dashboard pin-messenger.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы