POST /api/messenger/chats/{chatId}/read — Отметить чат прочитанным
Назначение
Отметить чат (ChatEntity) прочитанным до сообщения UpToMessageId для актора-
участника: продвинуть монотонный курсор Participant.LastReadSeq (GREATEST, INV-PART-2) и LastReadAt,
обнулив/уменьшив непрочитанные. Порождает read-receipt собеседнику в Direct (push read_receipt_updated,
ADR-0055).
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | chats |
| Feature group | chats |
| Статус | draft |
| Документ | docs/06-services/messenger/api/chats/mark-chat-read.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (активный участник чата) |
| Auth policy / Permissions | permission messenger.chats.view — каталог ../../../identity/domain/permission.md |
| Scope (RBAC) | не применяется — доступ по членству (ADR-0052 п.3) |
| Record-level | активный ParticipantEntity (UserId = actor, RemovedAt is null, INV-CHAT-4); не-участник → MESSENGER_CHAT_NOT_FOUND (privacy) |
| Tenant isolation | нет (кросс-стороннее общение; messenger не tenanted строково) |
| Audit | update read-state актора (LastReadSeq/LastReadAt); тело сообщения не читается/не логируется |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/messenger/chats/{chatId}/read |
| Success status | 200 |
| Idempotency header | не применяется — идемпотентность естественная: монотонный GREATEST (повтор с тем же/меньшим Seq — no-op, INV-PART-2) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ActorUserId | identity claims | Guid | Да | Required; permission messenger.chats.view | record-level (членство) |
ChatId | route | Guid | Да | != Guid.Empty; чат-участник актора (шаг 2) | — |
UpToMessageId | body | Guid | Да | != Guid.Empty; сообщение принадлежит ChatId (шаг 3); его Seq ≤ Chat.LastMessageSeq (INV-PART-2) | — |
Тело — типизированная модель MarkChatReadRequest { UpToMessageId: Guid }.
Модель ответа MarkChatReadResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
ChatId | Guid | Да | Chat.Id | — | Чат. |
LastReadSeq | long | Да | Participant.LastReadSeq после операции | — | Новый курсор прочитанного (монотонный). |
UnreadCount | long | Да | computed | — | max(0, Chat.LastMessageSeq − LastReadSeq) (обычно 0). |
LastReadAt | DateTimeOffset | Да | Participant.LastReadAt | — | Момент отметки (read-receipt в Direct). |
Advanced | bool | Да | computed (шаг 4) | — | true — курсор реально продвинулся; false — no-op (уже был прочитан). |
Предусловия и постусловия
Предусловия. Актор авторизован и имеет messenger.chats.view. Чат существует и не soft-deleted; актор —
его активный участник (RemovedAt is null, INV-CHAT-4). Сообщение UpToMessageId принадлежит ленте ChatId,
его Seq ≤ Chat.LastMessageSeq (INV-PART-2).
Постусловия (при Advanced = true). Participant.LastReadSeq продвинут до Seq(UpToMessageId) монотонно
(GREATEST, INV-PART-2), LastReadAt = now; UnreadCount = max(0, Chat.LastMessageSeq − LastReadSeq). В
realtime-канал chat:{ChatId} отправлен read_receipt_updated (в Direct — собеседнику; в Deal — обновление
агрегированного счётчика прочитавших). Kafka-события не публикуются (read-state локален), лента чата не
материализуется (INV-PART-5). Постусловие (no-op). При newLastRead == LastReadSeq состояние участника не
изменилось, push не отправлен; возвращается текущий курсор.
Алгоритм
- Контекст и доступ. Операцию выполняет участник чата (
User); ActorUserId — из claims; требуется
право messenger.chats.view. Идемпотентность естественная — курсор монотонный (дедуп на шаге 4).
- Проверка входа и членство.
ChatId и UpToMessageId непустые, иначе VALIDATION_FAILED. Чат
существует и актор — его активный участник (RemovedAt is null, INV-CHAT-4); иначе
MESSENGER_CHAT_NOT_FOUND (privacy).
- Разрешение сообщения. Сообщение
UpToMessageId существует, принадлежит ChatId и его Seq не
больше Chat.LastMessageSeq (INV-PART-2, курсор не за пределами ленты); иначе
MESSENGER_READ_SEQ_INVALID. Из него берётся целевой seq.
- Продвижение курсора.
newLastRead = GREATEST(Participant.LastReadSeq, seq) — монотонный guard
(INV-PART-2). Если курсор не изменился → no-op (Advanced = false), переход к результату без мутации.
Иначе Participant.LastReadSeq = newLastRead, LastReadAt = now; Advanced = true;
UnreadCount = max(0, Chat.LastMessageSeq − newLastRead).
- Реалтайм-уведомление. При
Advanced — в той же согласованной операции persist-then-push
read_receipt_updated в realtime-канал chat:{ChatId} (в Direct — собеседнику; в Deal — обновление
агрегированного счётчика прочитавших). Ленту чата операция не материализует (INV-PART-5). Интеграционных
Kafka-событий нет (read-state локален).
- Согласованность. При
Advanced — обновление строки участника и реалтайм-эффект фиксируются атомарно;
при no-op изменений не происходит. Монотонный GREATEST идемпотентен и не конфликтует с конкурентным
продвижением LastMessageSeq родителя (INV-PART-2, participant.md).
- Результат.
MarkChatReadResult (LastReadSeq, UnreadCount, LastReadAt, Advanced) → HTTP 200
(в т.ч. для идемпотентного no-op — возвращается текущее состояние курсора).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (read-state локален; Kafka не публикуется) |
| External calls | none |
| Cache / projections / search | none (unread — вычисляемый денорм по LastMessageSeq/LastReadSeq) |
| Notifications | realtime: persist-then-push read_receipt_updated в hub group chat:{ChatId} (при Advanced); offline-push — нет |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | ChatId/UpToMessageId = Guid.Empty | «Некорректный запрос» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | re-login |
MESSENGER_CHAT_NOT_FOUND | 404 | чат не существует, soft-deleted, либо актор не активный участник (INV-CHAT-4, privacy) | «Чат не найден» | no retry |
MESSENGER_READ_SEQ_INVALID | 409 | сообщение не из этого чата или Seq > Chat.LastMessageSeq (INV-PART-2) | «Некорректная отметка прочтения» | no retry |
Acceptance Criteria
- Продвижение (happy). Дано: актор — участник,
Participant.LastReadSeq = 5, Chat.LastMessageSeq = 9.
Когда отмечает прочитанным до сообщения с Seq = 9. Тогда LastReadSeq = 9, UnreadCount = 0,
Advanced = true, 200; собеседник получает realtime read_receipt_updated.
- Частичное продвижение. Дано:
LastReadSeq = 2, LastMessageSeq = 9. Когда отмечает до Seq = 6. Тогда
LastReadSeq = 6, UnreadCount = 3, Advanced = true.
- Идемпотентный повтор / откат. Дано:
LastReadSeq = 9. Когда отмечает до Seq = 7 (или снова до Seq = 9).
Тогда no-op: LastReadSeq не меняется, Advanced = false, push не отправляется, 200 с текущим состоянием.
- Курсор за пределами ленты. Дано:
UpToMessageId с Seq > Chat.LastMessageSeq либо сообщение из другого
чата. Когда отмечает прочитанным. Тогда 409 MESSENGER_READ_SEQ_INVALID, состояние не меняется.
- Не участник (privacy). Дано: актор не активный участник чата (или чат soft-deleted). Когда отмечает
прочитанным. Тогда
404 MESSENGER_CHAT_NOT_FOUND (наличие чата не раскрывается).
- Конкурентная отметка. Дано: два параллельных вызова с
Seq = 6 и Seq = 9. Тогда итоговый
LastReadSeq = 9 (монотонный GREATEST), оба ответа 200; повторное продвижение до меньшего значения не
откатывает курсор.
Совместимость и наблюдаемость
- Новые optional-поля ответа — additive; смена типа
UpToMessageId/LastReadSeq — breaking (migration +
rollout). Идемпотентный контракт (монотонный курсор, Advanced) стабилен.
- Logs:
chat marked read (ChatId, ActorUserId, LastReadSeq, Advanced; тело сообщения не читается);
metrics: messenger_mark_read_total{advanced,result}, latency p95 (< 300 мс, README);
traces — сквозной trace (traceparent); dashboard pin-messenger.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы