PATCH /api/messenger/chats/{chatId}/messages/{messageId} — Правка сообщения (≤ 1 мин)
Назначение
Автор правит своё сообщение в окне EditableUntil (≤ 60 с от отправки, INV-MSG-1): для Text меняется
Body, для Attachment — Caption (сам файл/FileId не меняется). Каждая правка пишет append-only снимок
прошлого контента MessageEditEntity (история правок; читателям не показывается — INV-MSG-2, кроме
admin-арбитража) и выставляет EditedAt. Карточки (EventCard) и системные сообщения править нельзя
(INV-MSG-4). Участникам чата уходит realtime-push MessageEdited. Дом полей —
message.md.
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | messages |
| Feature group | messages |
| Статус | draft |
| Документ | docs/06-services/messenger/api/messages/edit-message.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (автор сообщения) |
| Auth policy / Permissions | authenticated (JWT) + permission messenger.messages.edit |
| Scope (RBAC) | не применяется: доступ по членству + авторству (ADR-0055/0052) |
| Record-level | автор: message.SenderUserId = actor.UserId (INV-MSG-1) + активный Participant |
| Tenant isolation | не tenanted: изоляция — членство + авторство (chat.md) |
| Audit | update (MessageEntity) + append MessageEditEntity (снимок прошлого, EditorUserId = actor); IP/UA правки — Sensitive/internal |
HTTP-контракт
| Поле | Значение |
|---|
| Method | PATCH |
| Route | /api/messenger/chats/{chatId}/messages/{messageId} |
| Success status | 200 (правка применена) |
| Idempotency header | не применяется (правка повторяема; окно ≤ 60 с, конкуренция — оптимистическая блокировка) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
ChatId | route | Guid | Да | != Guid.Empty; чат существует; actor — активный участник | — |
MessageId | route | Guid | Да | != Guid.Empty; сообщение существует, ChatId совпадает; SenderUserId = actor; now <= EditableUntil; Kind != EventCard (см. Алгоритм) | — |
Body | body | string? | Условно | обязателен и только для Kind = Text; 1..8000 после trim | PII: не логируется |
Caption | body | string? | Условно | только для Kind = Attachment; <= 2000 после trim (null/пусто — снять подпись) | PII: не логируется |
UserId | identity claims | Guid | Да | Required; предикат членства + авторства | scope isolation |
IpAddress | server (remote/XFF) | IpAddress | Да | захват сервером; снимок правки | Sensitive/internal |
UserAgent | header User-Agent | string | Да | <= 512; захват сервером | Sensitive/internal |
Правится ровно одно поле по типу сообщения: Text → Body, Attachment → Caption; несовпадение поля и
Kind → VALIDATION_FAILED.
Модель ответа MessageModel
Union-проекция обновлённого сообщения (как в list-messages.md); EditedAt выставлен.
История правок и IP/UA в ответ не входят (INV-MSG-2).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Id | Guid | Да | MessageEntity.Id | — | Идентификатор сообщения. |
Seq | long | Да | Seq | — | Порядок в ленте (не меняется). |
Kind | string (MessageKind) | Да | Kind | — | Text / Attachment. |
EditedAt | DateTimeOffset | Да | EditedAt | — | Момент этой правки (флаг «изменено»). |
EditableUntil | DateTimeOffset | Да | EditableUntil | — | Не сдвигается правкой (окно от SentAt). |
Text | MessageTextModel? | Нет | TextMessageEntity | — | { Body } — для Text. |
Attachment | MessageAttachmentModel? | Нет | AttachmentMessageEntity | — | Обновлённая Caption (+ прежние FileId/метаданные). |
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь-автор; требуется право
messenger.messages.edit
(иначе — FORBIDDEN; без JWT — UNAUTHORIZED). ChatId/MessageId берутся из маршрута, Body?/
Caption? — из тела; IpAddress/UserAgent захватываются сервером.
- Проверка входа и предусловий.
ChatId/MessageId непусты; для Text — Body 1..8000, Caption
пуст; для Attachment — Caption <= 2000, Body пуст — иначе VALIDATION_FAILED. Актор должен быть
активным участником чата — иначе MSG_CHAT_ACCESS_DENIED. Сообщение должно существовать и принадлежать
этому чату — иначе MSG_MESSAGE_NOT_FOUND. Автор, тип и окно (INV-MSG-1/4): SenderUserId == actor иначе
MSG_EDIT_FORBIDDEN; сообщение не EventCard/системное иначе MSG_EDIT_FORBIDDEN; now <= EditableUntil
иначе MSG_EDIT_WINDOW_EXPIRED.
- Что читаем. Сообщение (для проверок и снимка); при правке последнего сообщения чата дополнительно сам
чат — для денормализации
LastMessagePreview.
- Бизнес-правила и переход. Рассчитать
Revision = (max prior)+1; записать снимок прошлого контента в
MessageEditEntity (PreviousBody для Text / PreviousCaption для Attachment, EditorUserId = actor, EditedAt = now, IpAddress/UserAgent); применить новое значение (Body/Caption);
EditedAt = now. Переход состояния Sent/Edited → Edited (INV-MSG-1). Если правится последнее сообщение
чата — обновить LastMessagePreview (ограничено ≤ 256).
- Что меняется. Обновляется сообщение + добавляется снимок
MessageEditEntity (+ обновляется чат при
денормализации). Всё фиксируется в одном согласованном изменении (INV-MSG-2); оптимистический конфликт →
MSG_EDIT_CONFLICT.
- Факты и события. Интеграционного Kafka-события нет (правка приватна, в
notifications не уходит —
INV-MSG-2). Записывается только намерение realtime-доставки для realtime-dispatch (persist-then-push →
хаб MessageEdited, группа chat:{ChatId}, нагрузка {ChatId, MessageId, EditedAt, Body?/Caption?} —
тело только участникам).
- Результат. Обновлённое сообщение
MessageModel (EditedAt выставлен), 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | нет (правка в Kafka/notifications не публикуется — INV-MSG-2/приватность) |
| Внешние вызовы | нет (для Attachment меняется только Caption; FileId/бинарь не трогаются) |
| Проекции / поиск | денорм LastMessagePreview на ChatEntity (если правится последнее сообщение) |
| Уведомления | in-app realtime MessageEdited (только участникам); offline — нет |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | поле не соответствует Kind, длина вне пределов, оба/ни одного поля | «Проверьте текст правки» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
FORBIDDEN | 403 | нет permission messenger.messages.edit | «Недостаточно прав» | no retry |
MSG_CHAT_ACCESS_DENIED | 403 | actor — не активный участник | «Нет доступа к чату» | no retry |
MSG_EDIT_FORBIDDEN | 403 | не автор, или EventCard/системное (INV-MSG-1/4) | «Править можно только своё сообщение» | no retry |
MSG_MESSAGE_NOT_FOUND | 404 | сообщение не найдено или ChatId не совпадает | «Сообщение не найдено» | no retry |
MSG_EDIT_WINDOW_EXPIRED | 409 | now > EditableUntil (окно ≤ 60 с истекло) | «Время для правки истекло» | no retry |
MSG_EDIT_CONFLICT | 409 | оптимистический конфликт | «Сообщение изменилось, повторите» | retry (перечитать) |
Acceptance Criteria
- Правка текста в окне. Дано своё
Text-сообщение, now <= EditableUntil, Body в 1..8000. Когда
PATCH. Тогда Body заменён, EditedAt = now, записан снимок MessageEditEntity (append-only, INV-MSG-2),
переход Sent/Edited → Edited, push MessageEdited, ответ 200; Seq и EditableUntil не меняются.
- Правка подписи вложения. Дано
Attachment-сообщение. Когда PATCH c Caption <= 2000. Тогда
обновляется только Caption; FileId/бинарь не трогаются.
- Снятие подписи. Дано
Attachment c подписью. Когда Caption = null/пусто. Тогда подпись снимается.
- Поле не соответствует
Kind. Дано Text c Caption (или Attachment c Body), либо оба/ни одного
поля. Когда PATCH. Тогда VALIDATION_FAILED (400).
- Окно истекло. Дано
now > EditableUntil. Когда PATCH. Тогда MSG_EDIT_WINDOW_EXPIRED (409).
- Не автор / карточка / системное. Дано
SenderUserId != actor или Kind == EventCard/системное
(INV-MSG-4). Когда PATCH. Тогда MSG_EDIT_FORBIDDEN (403).
- Не участник / не найдено. Дано актор не активный участник →
MSG_CHAT_ACCESS_DENIED (403); сообщение
не найдено/ChatId не совпал → MSG_MESSAGE_NOT_FOUND (404).
- Конкурентная правка. Дано две параллельные правки одного сообщения. Когда обе доходят. Тогда
проигравший получает
MSG_EDIT_CONFLICT (409) и перечитывает.
- Приватность. Kafka-события/уведомления нет (INV-MSG-2);
Body/Caption/PreviousBody/IP/UA не
логируются; тело правки уходит только участникам чата.
Совместимость и наблюдаемость
- Модель ответа — та же union
MessageModel; изменения — additive без breaking. Окно правки (60 с) —
доменная константа EditableUntil (изменение — доменное решение, не API-контракт).
- Logs:
message-edited (ChatId, MessageId, Revision, EditorUserId; Body/Caption/PreviousBody/
IP/UA не пишутся); metrics: messenger_messages_edited_total{result}, latency p95 < 300 мс; traces через
traceparent; dashboard pin-messenger.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы