Перейти к основному содержимому

UC-NWS-006. История версий поста (полный snapshot)

Назначение

Автор просматривает полную неизменяемую историю публикаций своего поста — список версий и полный snapshot конкретной версии. Версионирование — полный слепок каждой публикации (не diff), блочный контент. Связан с контекстом news (лента новостей). Дом слепка — post-version.md, дом агрегата — post.md.

Metadata

ПолеЗначение
IDUC-NWS-006
Контекстnews (см. docs/06-services/news/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольАвтор (User): автор поста либо менеджер компании-автора (застройщик/агентство)
Permissions / scopenews.posts.manage-own (свои посты) + news.posts.manage (посты компаний в scope менеджера); чтение истории — не public (post-version.md, «Permissions»); каталог — permission.md
TenantTenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052 п.4); история читается кросс-tenant предикатом «свои ∪ компанийные»
Record-levelPost.AuthorUserId == actor ИЛИ (Post.AuthorCompanyId ∈ managed-scopenews.posts.manage), INV-NWS-007 (post.md); иначе 403

Предусловие

  • Пост существует (news.posts) и доступен актору по record-level (INV-NWS-007).
  • Актор аутентифицирован; гость → 401.
  • Пост публиковался хотя бы раз (Post.CurrentVersionNumber >= 1) — иначе история пуста (успех, не ошибка): черновик без публикаций версий не создаёт (INV-PV-004, post-version.md).

Триггер

ПолеЗначение
ИсточникAPI call (GET)
Триггерящий элементВкладка «История версий» карточки поста автора (area authoring, README)

Основной Поток

  1. Автор открывает карточку своего поста → вкладку «История версий»; фронт вызывает GET /api/v1/posts/{id}/versions (keyset-пагинация).
  2. Доступ и загрузка поста. Определить актора и его managedCompanyIds (компании с news.posts.manage); загрузить пост на платформенном уровне, включая снятые с публикации и архивные (автор видит историю снятых/архивных постов). Пост не найден → NEWS_POST_NOT_FOUND (404); record-level предикат (INV-NWS-007) ложен → 403 (A2).
  3. Список версий. Отобрать PostVersion по PostId, порядок VersionNumber desc (индекс ix_post_versions_post, keyset Limit + 1). Маппинг поле-в-поле в PostVersionListItem (VersionNumber/Reason (enum-строкой)/CreatedByUserId + CreatedByDisplayName из AuthorReplica, identity-replicas.md/CapturedAt) → 200.
  4. Автор выбирает версию n → фронт GET /api/v1/posts/{id}/versions/{versionNumber}.
  5. Загрузка версии. Тот же record-level предикат; найти версию по (PostId, VersionNumber == n) (unique ux_post_versions_number); не найдена → NEWS_VERSION_NOT_FOUND (404, A1).
  6. Маппинг детали. PostVersionDetail (PostId/VersionNumber/Reason/CreatedByUserId/CapturedAt
    • Snapshot = PostSnapshotDocumentвсе поля + блоки Content, post-version.md). Иммутабельный слепок (INV-PV-001) отдаётся как есть → 200.
  7. Фронт рендерит историческую версию: Text/ImageGallery — из слепка; SelectionEmbed-блоки — из иммутабельного SelectionSnapshot по SnapshotId (selections GET snapshot; читаем даже при Status = Detached — INV-SNP-006, selection-snapshot.md) → исторический состав подборки; display-данные объектов недвижимости — актуальные батчем из catalog по PropertyObjectId (не заморожены, решение №3 README).

Диаграмма

Альтернативные Потоки

A1. Запрошенная версия отсутствует

  1. GET /api/v1/posts/{id}/versions/{n} с несуществующим n (или n > CurrentVersionNumber).
  2. Версия по (PostId, VersionNumber == n) не найдена → NEWS_VERSION_NOT_FOUND (404).

Завершается: фронт показывает «Версия не найдена», список версий (шаг 3) остаётся источником валидных n.

A2. Актор не автор и не менеджер scope

  1. Актор запрашивает историю чужого поста (AuthorUserId != actorAuthorCompanyId ∉ managed-scope).
  2. Record-level предикат ложен (INV-NWS-007) → 403.

Завершается: история не раскрывается (чтение истории — не public, post-version.md).

A3. Недоступность зависимостей при рендере исторической версии

  1. Рендер версии (шаг 7) добирает исторический состав embed из selections (GET snapshot по SnapshotId) и актуальные display-данные объектов недвижимости из catalog (батч по PropertyObjectId) — обе зависимости синхронные и внешние для чтения.
  2. Отказ catalog → embed деградирует до исторического состава/порядка/комментариев из snapshot-а без живых цены/статуса (повтор при следующем рендере). Отказ/таймаут selections → исторический состав embed временно не резолвится, но сам PostVersionDetail (текст/галереи из иммутабельного PostSnapshotDocument, INV-PV-001) отдаётся штатно — от внешних сервисов зависит только рендер embed-блоков.

Завершается: деградация чтения, не бизнес-ошибка (мутаций и событий нет; 200 с частично догидрированным рендером).

  • Система не изменена: мутаций, доменных/интеграционных событий нет.
  • Актору доступна полная неизменяемая история публикаций поста: список версий (VersionNumber desc) и полный иммутабельный PostSnapshotDocument любой версии, включая исторический состав embed-подборок.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
UNAUTHENTICATED401Нет/невалидный токен (гость)Логин и повтор
VALIDATION_ERROR400versionNumber < 1; повреждён keyset-cursor; limit вне 1..30Исправить параметры
NEWS_POST_NOT_FOUND404Пост отсутствуетВернуться к списку постов
NEWS_VERSION_NOT_FOUND404Версия n не существует (A1)Показать «версия не найдена»; свериться со списком
403 (record-level)403Актор не автор и не менеджер scope (A2, INV-NWS-007)Скрыть вкладку истории

Отказ БД — инфраструктурная ошибка (5xx, ProblemDetails), а не бизнес-ошибка домена. Пустая история (пост без публикаций) — успех 200 с пустым Items.

События

Сценарий событий не порождает (read-only, два safe-GET). Захват версии (PostVersionCapturedEvent) поднимает агрегат Post в момент публикации (publish-post.md, INV-NWS-005), а не при чтении истории; внешние подписчики читают событие публикации pin.news.post-published.v1 (post.md), не историю версий.

Acceptance Criteria

#КритерийПроверка
AC1Каждая публикация = отдельная строка PostVersion (полный snapshot, не diff); список отдаёт по одной записи на публикацию, VersionNumber монотоненОпубликовать → править → republish; GET /versions вернёт 2 записи VersionNumber 1,2 в порядке desc
AC2Snapshot иммутабелен: повторное чтение той же версии даёт идентичный PostSnapshotDocument (INV-PV-001; мутатора нет)Прочитать версию n дважды с правкой поста между чтениями — тело неизменно
AC3Исторические embed читаемы даже после detach (INV-SNP-006): версия со SnapshotId в статусе Detached рендерит исторический составЗаменить блок-подборку → republish → открыть старую версию → snapshot резолвится, состав прежний
AC4Display-данные объектов недвижимости embed — актуальные из catalog, не замороженыИзменить цену объекта недвижимости в catalog → старая версия показывает новую цену при прежнем составе
AC5Чужой актор → 403; отсутствующая версия → NEWS_VERSION_NOT_FOUND (не раскрытие)Тесты record-level изоляции (INV-NWS-007) + запрос n = CurrentVersionNumber + 1
AC6Недоступность зависимостей рендера (A3): при отказе catalog/selections PostVersionDetail из иммутабельного snapshot отдаётся штатно, деградирует только рендер embedТест: недоступный catalog/selections200, текст/галереи версии на месте

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы

  • API методы (карта — README, area authoring; целевые пути по конвенции): list-post-versions.md (GET /posts/{id}/versions), get-post-version.md (GET /posts/{id}/versions/{n}).
  • Domain entity: post-version.md (слепок PostSnapshotDocument, INV-PV-001/002/004, PostVersionReason, permissions), post-block.md (PostContentDocument, SelectionEmbedBlock), post.md (агрегат, INV-NWS-005/007, CurrentVersionNumber), identity-replicas.md (CreatedByDisplayName).
  • Цепочка иммутабельности: selection-snapshot.md (INV-SNP-006, Detached); рендер объектов недвижимости — catalog по PropertyObjectId (не заморожено).
  • Захват версии: publish-post.md (INV-NWS-005); аналог read-эндпоинта — list-my-posts.md; публичное чтение — get-post.md.
  • Доступ: permission.md (news.posts.manage-own/manage); ADR-0052 (tenancy).