UC-MSG-003. Правка сообщения в окне ≤ 60 секунд
Назначение
Описывает сценарий: автор правит (или удаляет) своё сообщение ленты чата
(message.md) только в окне ≤ 60 с (EditableUntil = SentAt + 60s, INV-MSG-1),
после чего сообщение иммутабельно. Каждая правка/удаление пишет скрытый append-only снимок прошлого
контента (MessageEditEntity, INV-MSG-2) — история хранится, но не отдаётся ни одной read-моделью,
кроме админ-арбитража Службы заботы. Захватываются IpAddress/UserAgent правки (Sensitive, INV-MSG-6).
Полный алгоритм и маппинг — в API-методах: edit-message.md
(PATCH), delete-message.md (DELETE, soft). Связан с контекстом
messenger.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-MSG-003 |
| Контекст | messenger (см. docs/06-services/messenger/) |
| Тип | основной (+ A1 окно истекло, A2 удаление, A3 не автор / системная карточка, A4 правка в закрытом чате, A5 конкурентная правка) |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Автор сообщения (User: Инвестор/Агент/Менеджер) |
| Permissions / scope | JWT identity; messenger.messages.edit (правка/удаление своего); guest → 401 |
| Tenant | не применяется — доступ по членству чата (INV-CHAT-4); tenant наследуется от чата |
| Record-level | только автор: Message.SenderUserId == actor (INV-MSG-1); арбитраж истории — messenger.admin.view (Служба заботы) |
Предусловие
- Сообщение существует, актор — его автор и активный
Participantчата (INV-CHAT-4);Kind != EventCard,IsSystem = false(системные карточки не правятся, INV-MSG-4). - Текущий момент в окне:
now <= EditableUntil(≤ 60 с отSentAt).
Триггер
| Поле | Значение |
|---|---|
| Источник | UI (контекстное меню сообщения «изменить»/«удалить», доступно только в окне) → API call |
| Триггерящий элемент | PATCH /api/messenger/chats/{chatId}/messages/{id} / DELETE /api/messenger/chats/{chatId}/messages/{id} |
Основной Поток
Нить — правка текстового сообщения в окне. Полный алгоритм и маппинг — в edit-message.md; ниже — поток на уровне use-case.
- Отправка правки. Автор меняет текст сообщения и отправляет правку
{ ChatId, MessageId, Body }; автор правки берётся из токена (не из тела). Клиент передаётIpAddress/UserAgentзаголовками запроса. - Проверка входа.
Body—1..8000послеTrim; сообщение существует и принадлежит указанномуChatId, иначеMSG_MESSAGE_NOT_FOUND. - Гейт прав и окна. Автор правки — отправитель сообщения (
Message.SenderUserId == actor, INV-MSG-1), иначеFORBIDDEN(A3); сообщение — не системная карточка (Kind != EventCard,IsSystem = false), иначеMSG_EDIT_FORBIDDEN(A3); текущий момент в окне (now <= EditableUntil), иначеMSG_EDIT_WINDOW_EXPIRED(A1); актор — активный участник чата (INV-CHAT-4). - Переход. Сообщение переходит
Sent/Edited → Edited(guard INV-MSG-1): пишется append-only снимок прошлого контентаMessageEditEntity { Revision = ++n, PreviousBody, EditedAt, EditorUserId, IpAddress, UserAgent }(INV-MSG-2);Bodyзаменяется новым,EditedAt = now, версия инкрементируется.Seqне меняется — порядок ленты стабилен. - Что меняется. Обновляются
Body/EditedAt/версия сообщения и добавляется снимок правки; конкурентная правка не должна затирать чужую (оптимистическая блокировка) — при конфликтеConcurrencyConflict. - События. Поднимается доменное
MessageEdited→ строка realtime-outboxMessageEdited(persist-then-push). Интеграционных Kafka-событий правка не порождает (message-posted— только на создание сообщения). - Согласованность. Сообщение, снимок правки и строка realtime-outbox фиксируются в одном согласованном изменении.
- Ответ и realtime. Возврат
{ MessageId, Seq, EditedAt }→200; realtime-dispatch пушитmessage_editedвchat:{ChatId}— клиенты обновляют тело in-place; история правок наружу не отдаётся (INV-MSG-2).
Диаграмма
Альтернативные Потоки
A1. Окно правки истекло
now > EditableUntil(прошло > 60 с отSentAt) → отказMSG_EDIT_WINDOW_EXPIRED(409); сообщение immutable, снимок не пишется, событий нет.- Клиент показывает «Время редактирования истекло», убирает пункт меню.
A2. Удаление в окне (soft)
DELETE …/messages/{id}(delete-message.md): те же гейты (автор + окно + не системное). Сообщение переходитSent/Edited → Deleted(soft):DeletedAt = now, тело скрывается (запись остаётся радиSeq), пишется снимокMessageEditEntity(PreviousBody/PreviousCaption).- Доменное
MessageDeleted→ строка realtime-outboxMessageDeleted;204 No Content. Клиенты показывают «сообщение удалено» плейсхолдером;Seq/порядок ленты сохраняются.
A3. Не автор или системная карточка
Message.SenderUserId != actor→FORBIDDEN(403); либоKind == EventCard/IsSystem == true(авто-карточка сделки, INV-MSG-4) →MSG_EDIT_FORBIDDEN(409). Мутаций нет.
A4. Правка/удаление в закрытом чате сделки
- Чат сделки перешёл в
Closed(сделкаCompleted/Cancelled, INV-CHAT-8). Закрытие запрещает новые сообщения пользователей (MSG_CHAT_CLOSEDна отправке), но правка/удаление уже отправленного сообщения гейтится только окном (now <= EditableUntil) и авторством (INV-MSG-1) — статус чата на них не влияет: если сообщение отправлено в последние ≤ 60 с перед закрытием, автор ещё может его поправить/удалить в остатке окна. Вне окна —MSG_EDIT_WINDOW_EXPIRED(как A1).
A5. Конкурентная правка того же сообщения
- Две параллельные правки (или правка + удаление) одного сообщения → оптимистическая блокировка: применяется
первая, вторая получает
ConcurrencyConflict(409). Проигравший перечитывает актуальное состояние (возможно, ужеDeletedили с обновлённымBody) и повторяет при необходимости в остатке окна. Двойного снимкаMessageEditEntityна одну победившую правку не возникает.
Постусловие
- В окне:
Message.Bodyобновлён (илиDeletedAtпроставлен),EditedAtset,Versionинкрементирован; добавлен скрытыйMessageEditEntity(Revision,PreviousBody, IP/UA) — виден только админ-арбитражу. Seqи порядок ленты не изменились; realtimemessage_edited/message_deletedдоставлен участникам.- Вне окна / не автор / системное: состояние не изменилось.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
VALIDATION_FAILED | 400 | Body вне 1..8000 / пустой после Trim | Показать ошибку формы |
MSG_MESSAGE_NOT_FOUND | 404 | сообщение не найдено / ChatId не совпал | «Сообщение не найдено» |
MSG_EDIT_WINDOW_EXPIRED | 409 | now > EditableUntil (A1, INV-MSG-1) | «Время редактирования истекло» |
FORBIDDEN | 403 | SenderUserId != actor (A3, record-level) | «Можно править только свои сообщения» |
MSG_EDIT_FORBIDDEN | 409 | Kind = EventCard / IsSystem (A3, INV-MSG-4) | Скрыть меню правки у карточек |
MSG_CHAT_ACCESS_DENIED | 403 | нет активного членства в чате (INV-CHAT-4) | «Нет доступа к чату» |
MSG_ORIGIN_REQUIRED | 400 | отсутствуют IP/UA у user-правки (INV-MSG-6) | Ретрай с корректными заголовками |
ConcurrencyConflict | 409 | конкурентная правка (оптимистическая блокировка) | Ретрай (перечитать сообщение) |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
MessageEdited | Sent/Edited → Edited в окне | realtime_outbox MessageEdited (push message_edited) |
MessageDeleted | Sent/Edited → Deleted (A2) | realtime_outbox MessageDeleted (push message_deleted) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
| — | — | правка/удаление не публикуют Kafka-событий (осознанно) | — |
pin.messenger.message-posted.v1 порождается только созданием сообщения (UC-MSG-001); правка/удаление —
локальный realtime без cross-service событий (message.md). История правок в Kafka/логи/ответы не попадает
(Sensitive, только админ-арбитраж).
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Правка/удаление возможны только в окне ≤ 60 с; позже → MSG_EDIT_WINDOW_EXPIRED | Тест: правка на 59-й с → 200; на 61-й → 409, тело неизменно |
| AC2 | Каждая правка/удаление пишет MessageEditEntity (append-only, Revision, PreviousBody, IP/UA) | Тест: 2 правки → 2 снимка Revision 1,2; история не в read-модели |
| AC3 | Править может только автор; системные карточки immutable | Тест A3: чужой → 403; EventCard → 409 |
| AC4 | Seq/порядок ленты не меняются; удаление — soft-плейсхолдер | Тест A2: Seq стабилен, list-messages отдаёт «удалено» |
| AC5 | Правка/удаление realtime-пушат message_edited/message_deleted; Kafka-событий нет | Тест: realtime_outbox строка есть, Kafka-outbox пуст |
| AC6 | История правок видна только админ-арбитражу (get-chat-transcript) | Тест: read-модель без истории; admin-эндпоинт — со снимками |
| AC7 | Дано чат сделки Closed, но сообщение в окне ≤ 60 с; когда автор правит; тогда правка проходит (закрытие блокирует новые сообщения, не правку в окне, A4) | Тест: Closed-чат, правка на 30-й с → 200; новая отправка → MSG_CHAT_CLOSED |
| AC8 | Дано две параллельные правки одного сообщения; когда исполняются; тогда применяется одна, вторая → ConcurrencyConflict, один снимок правки (A5) | Тест гонки: 409 проигравшему, один MessageEditEntity |
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API (дом алгоритма/маппинга): edit-message.md, delete-message.md; чтение — list-messages.md; арбитраж — get-chat-transcript.md.
- Domain: message.md (INV-MSG-1/2/4/6,
EditableUntil,MessageEditEntity, IP/UA), chat.md (INV-CHAT-4 членство). - Realtime: realtime-dispatch.md (
message_edited/message_deleted). - Permission-каталог: permission.md
(
messenger.messages.edit,messenger.admin.view). - ADR: ADR-0055.