UC-NWS-003. Вложенные переключаемые комментарии
Назначение
Аутентифицированный читатель комментирует опубликованный пост ленты новостей и отвечает на существующие
комментарии, строя вложенное дерево (материализованный путь Path, Depth ≤ 6); автор поста
переключает приём новых комментариев флагом CommentsEnabled, автор/модератор удаляет узел, не разрушая
ветку. Дом полей и инвариантов — comment.md, гейт публикации — post.md
(INV-NWS-006); операции — add-comment.md,
list-comments.md,
toggle-comments.md,
delete-comment.md.
| Поле | Значение |
|---|
| ID | UC-NWS-003 |
| Контекст | news (см. docs/06-services/news/) |
| Тип | основной (+ альтернативы A1 переключение, A2 удаление/скрытие, A3 лимит глубины) |
| Статус | draft |
Актор
| Роль | Permissions / scope | Tenant | Record-level |
|---|
| User (комментатор) | аутентификация identity; комментирование self-scope (без отдельного permission); guest — 401 | не применяется (Comment не tenanted, ADR-0052) | автор своего узла (AuthorUserId, INV-CMT-004) |
| Автор поста (переключение/удаление) | record-level Post.AuthorUserId или менеджер компании в scope AuthorCompanyId или news.posts.manage (INV-NWS-007) | TenantId = Post.AuthorCompanyId (nullable) | владелец поста |
| Оператор (модерация A2) | permission news.comments.moderate (m2m Admin, ADR-0053) | не применяется | permission-гейт |
System (потребитель notifications, фоновая сверка comment-count-reconcile) | system-scope | — | не применяется |
Предусловие
- Пост существует,
Post.Status = Published и виден в ленте/по прямой ссылке
(get-post.md); дерево обсуждения читается публично
(list-comments.md).
- Для добавления:
Post.CommentsEnabled = true (гейт INV-NWS-006 / INV-CMT-001) и актор аутентифицирован.
- Для ответа: родительский
Comment существует, принадлежит тому же посту (INV-CMT-002) и его
Depth + 1 ≤ MaxDepth (6) (INV-CMT-003).
Триггер
| Поле | Значение |
|---|
| Источник | UI button (форма комментария/ответа под постом) → API call |
| Триггерящий элемент | POST /api/v1/posts/{id}/comments — add-comment.md; переключение — POST /api/v1/posts/{id}/comments-settings; удаление — DELETE /api/v1/comments/{id} |
Основной Поток
Нить — добавление корневого комментария или ответа под открытым к комментированию постом.
- Открытие поста и дерева. Читатель открывает пост и запрашивает обсуждение
GET /api/v1/posts/{id}/comments (list-comments.md):
keyset-страница корневых комментариев, ветки собраны по Path, плейсхолдеры удалённых/скрытых узлов
сохраняют связность. Просмотр трекает фронт → user-activity (ADR-0054).
- Отправка комментария/ответа. Форма отправляет тело
{ parentCommentId?, body }; AuthorUserId
берётся из контекста актора (не из тела); опционально клиент передаёт Idempotency-Key для защиты от
двойной отправки.
- Валидация и гейт. Проверить
body: 1..4000 символов после Trim — иначе VALIDATION_FAILED.
Пост существует — иначе NEWS_POST_NOT_FOUND. Гейт INV-NWS-006 / INV-CMT-001:
Post.Status == Published ∧ Post.CommentsEnabled == true, иначе NEWS_COMMENTS_DISABLED (A1). При
ответе (parentCommentId != null): родитель существует и принадлежит тому же посту (INV-CMT-002, иначе
NEWS_COMMENT_PARENT_MISMATCH); parent.Depth + 1 ≤ MaxDepth(6) (INV-CMT-003, иначе
NEWS_COMMENT_TOO_DEEP — A3).
- Построение узла. Комментарий создаётся со
Status = Visible (единственное стартовое состояние);
идентификатор хронологически упорядочен в треде:
- корневой (
parent == null): Depth = 0, RootCommentId = Id, Path = "/{Id}";
- ответ:
Depth = parent.Depth + 1, RootCommentId = parent.RootCommentId ?? parent.Id,
Path = parent.Path + "/{Id}".
- Счётчик и событие. Денормализованный
Post.CommentsCount инкрементируется в том же согласованном
изменении (INV-CMT-005). Публикуется интеграционное событие pin.news.comment-added.v1
(ContractVersion = 1, ключ партиционирования = PostId) — атомарно с созданием комментария.
- Результат и уведомления. Ответ
{ CommentId, PostId, ParentCommentId?, Depth, CreatedAt } → HTTP
201 (idempotency-replay — 200). Потребитель notifications по comment-added уведомляет автора поста
и автора родительского комментария. Фронт перечитывает дерево — новый узел в своей ветке.
Диаграмма
Альтернативные Потоки
A1. Автор выключил комментарии (переключение)
- Автор поста вызывает
POST /api/v1/posts/{id}/comments-settings { enabled: false }
(toggle-comments.md): Post.CommentsEnabled = false, статус поста
и дерево комментариев не трогаются, событий нет.
- Существующие узлы остаются
Visible и читаемы; добавление новых блокируется гейтом основного потока
(шаг 3) → NEWS_COMMENTS_DISABLED (409).
- Повторное открытие —
{ enabled: true } (идемпотентно). Возврат к основному потоку.
A2. Автор/модератор удаляет комментарий (ветка сохраняется)
DELETE /api/v1/comments/{id} (delete-comment.md); ветвление
record-level (INV-CMT-004): ActorUserId == Comment.AuthorUserId → авторская ветка; иначе — permission
news.comments.moderate; ни то ни другое ⇒ FORBIDDEN.
- Переход
Visible → … (guard INV-CMT-006 no-resurrect): автор — Status = DeletedByAuthor,
DeletedAt = now; модератор — Status = HiddenByModerator (Reason: CommentModerationReason),
DeletedAt пуст. Узел остаётся плейсхолдером: Body/подпись при рендере скрыты, дети по
Path/RootCommentId не «отрываются». Post.CommentsCount-- (INV-CMT-005).
- Интеграционных событий нет (осознанно — удаление/скрытие в Kafka не публикуется).
204 No Content;
терминальный повтор идемпотентен. Ответы на плейсхолдер по-прежнему добавляются (гейт по посту, не по узлу).
A3. Превышение глубины вложенности
- Ответ на комментарий с
Depth = 6: parent.Depth + 1 = 7 > MaxDepth(6) (INV-CMT-003).
- Политика UC-NWS-003 — отклонение (не тихое прикрепление к предку):
NEWS_COMMENT_TOO_DEEP (400);
узел не создаётся, счётчик/событие не меняются.
- Клиент показывает «Достигнут предел вложенности ответов» и предлагает ответить в корень треда.
A4. Ответ на параллельно удалённый/скрытый родитель
- Пользователь отвечает на комментарий, который в этот момент переводится автором/модератором в
DeletedByAuthor/HiddenByModerator. Узел-плейсхолдер не исчезает (удаление не рушит ветку, A2): его
Path/Depth/RootCommentId валидны, поэтому ответ штатно прикрепляется к плейсхолдеру — гейт по посту
(Published ∧ CommentsEnabled), а не по состоянию родительского узла.
- Обратного перехода плейсхолдера в
Visible не бывает (INV-CMT-006 no-resurrect) — «гонки воскрешения»
нет; новый ответ создаётся как Visible, CommentsCount++, событие comment-added публикуется штатно.
Дерево остаётся связным (плейсхолдер-предок + новый видимый потомок). Завершается успешно.
Постусловие
- Создан
Comment со Status = Visible под PostId — узел вложенного дерева (Path, Depth ≤ 6,
RootCommentId); читается GET /api/v1/posts/{id}/comments в своей ветке.
Post.CommentsCount инкрементирован (денорм. для отображения/сортировки); ночная сверка
comment-count-reconcile контролирует и страхует дрейф (INV-CMT-005).
- Опубликован
pin.news.comment-added.v1 — только при создании Visible-узла (не при удалении/скрытии/
переключении); notifications уведомил автора поста и автора родителя.
- A1:
Post.CommentsEnabled = false — существующие комментарии видны, новые запрещены гейтом. A2: узел —
плейсхолдер (DeletedByAuthor/HiddenByModerator), дети целы, CommentsCount--. A3: изменений нет.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
VALIDATION_FAILED | 400 | body вне 1..4000 / пустой PostId / недопустимый Reason | Показать ошибку формы |
NEWS_POST_NOT_FOUND | 404 | пост не найден / не Published / soft-deleted (либо родитель не найден) | «Пост не найден» |
NEWS_COMMENTS_DISABLED | 409 | Post.Status != Published ∨ CommentsEnabled == false (A1, INV-CMT-001) | «Комментарии к посту отключены» |
NEWS_COMMENT_PARENT_MISMATCH | 400 | parent.PostId != PostId (INV-CMT-002) | «Родительский комментарий из другого поста» |
NEWS_COMMENT_TOO_DEEP | 400 | parent.Depth + 1 > 6 (A3, INV-CMT-003) | «Достигнут предел вложенности ответов» |
NEWS_COMMENT_NOT_FOUND | 404 | удаляемый комментарий не найден (A2) | «Комментарий не найден» |
FORBIDDEN | 403 | не автор узла и без news.comments.moderate (A2, INV-CMT-004); переключение без прав на пост (A1, INV-NWS-007) | «Недостаточно прав» |
ConcurrencyConflict | 409 | конкурентный переход статуса одного узла | Ретрай (перечитать состояние узла) |
NEWS_COMMENT_CURSOR_INVALID | 400 | курсор дерева не парсится / чужая версия курсора (чтение) | Ретрай без cursor (первая страница) |
Доменные События
| Событие | Когда поднимается | Эффект |
|---|
CommentCreatedDomainEvent | Создание узла ([*] → Visible) | инкремент CommentsCount (INV-CMT-005) + публикация pin.news.comment-added.v1 в том же согласованном изменении |
CommentRemovedDomainEvent | Visible → DeletedByAuthor/HiddenByModerator (A2) | декремент CommentsCount; интеграционного события нет |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
CommentAddedKafkaEvent | pin.news.comment-added.v1 | Только при создании Visible-узла ([*] → Visible); A1/A2/A3 — no-op | notifications (автор поста + автор родителя) |
Payload { CommentId, PostId, ParentCommentId?, RootCommentId, AuthorUserId, Depth, CreatedAt },
ContractVersion = 1, ключ = PostId (per-post порядок); публикуется атомарно с созданием комментария.
Дом контракта — comment-added.md. Переключение CommentsEnabled и
удаление/скрытие комментария интеграционных событий не порождают.
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Гейт: комментарий создаётся только при Post.Status = Published ∧ CommentsEnabled = true; иначе NEWS_COMMENTS_DISABLED | Integration-тест add-comment: пост Draft/Unpublished/с CommentsEnabled=false → 409 |
| AC2 | Вложенность: Depth = parent.Depth + 1, ≤ 6; глубже → NEWS_COMMENT_TOO_DEEP, узел не создаётся (A3) | Тест: цепочка ответов до Depth=6; 7-й ответ → 400, новая строка не появляется |
| AC3 | Удаление не рушит ветку: DeletedByAuthor/HiddenByModerator — плейсхолдер (Body/подпись null), дети по Path сохраняются и читаются | Тест A2: удалить родителя с детьми → list-comments отдаёт плейсхолдер + детей |
| AC4 | Счётчик консистентен: CommentsCount++ при Visible-создании, -- при удалении/скрытии; сверка comment-count-reconcile контролирует дрейф | Тест: создать 3 + удалить 1 → CommentsCount = 2; прогон comment-count-reconcile не меняет значение |
| AC5 | Событие comment-added — только на создание; уведомляет автора поста и автора родителя; не на delete/toggle | Тест: одно событие pin.news.comment-added.v1 на создание; при A2/A1 событие не публикуется |
| AC6 | Переключение: enabled=false не трогает существующие узлы (видны), блокирует только новые; события нет | Тест A1: toggle → старые Visible читаются, POST comment → 409, событие не публикуется |
| AC7 | Дерево: list-comments собирает ветки по Path (keyset корневых), плейсхолдеры без Body/подписи | Тест list-comments: порядок DFS по Path, маскирование не-Visible узлов |
| AC8 | Ответ на параллельно удалённый родитель (A4) прикрепляется к плейсхолдеру (ветка цела, no-resurrect INV-CMT-006); ответ создаётся Visible, счётчик/событие штатны | Конкурентный тест: удалить родителя и одновременно добавить ответ → ответ виден под плейсхолдером |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Операции: add-comment.md,
list-comments.md,
toggle-comments.md,
delete-comment.md; чтение поста —
get-post.md.
- Domain: comment.md (
CommentStatus, Path/Depth/RootCommentId,
INV-CMT-001…006), post.md (гейт CommentsEnabled INV-NWS-006, денорм.
CommentsCount, record-level INV-NWS-007), identity-replicas.md
(подписи авторов дерева).
- Kafka: comment-added.md (
pin.news.comment-added.v1); потребитель —
docs/06-services/notifications/ (будущий путь по конвенции); фоновая сверка — comment-count-reconcile
(README, раздел Jobs).
- Permission-каталог: permission.md (
news.comments.moderate,
news.posts.manage).
- ADR: ADR-0052 (tenancy, публичный UGC),
ADR-0053 (модерация m2m),
ADR-0054 (просмотры → user-activity),
ADR-0056 (материализованный
Path
без ltree).