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

GET /api/v1/posts/{id}/versions — История версий поста (snapshot)

Назначение

Отдать автору (или менеджеру компании-автора) полную snapshot-историю публикаций конкретного поста: каждая публикация/перепубликация фиксирует иммутабельную строку PostVersion, endpoint постранично перечисляет их метаданные (номер, причина, кто и когда опубликовал, размер контента) от свежих к старым. Полный слепок каждой версии наружу не отдаётся — его читает соседний GET /api/v1/posts/{id}/versions/{versionNumber}. Только чтение: без мутаций, событий и синхронных вызовов в чужие контексты.

Metadata

ПолеЗначение
Сервис/контекстnews
API areaauthoring (self + company scope)
Feature groupversions
Статусdraft
OwnerPIN Engagement
Документdocs/06-services/news/api/authoring/list-post-versions.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (автор поста или менеджер компании-автора)
Auth policy / Permissionsnews.posts.manage-own (дом — identity/domain/permission.md); news.posts.moderate — сквозной доступ модератора
Scope (RBAC)организация (пост компании) / self (пост инвестора)
Record-levelприменяется: Post.AuthorUserId == actor.UserId или Post.AuthorCompanyId ∈ company-scope актора; иначе FORBIDDEN (шаг 2)
Tenant isolationпост застройщика/агентства — TenantId = AuthorCompanyId; пост инвестора — вне tenant (nullable, ADR-0052 п.3); доступ решает record-level, не жёсткий tenant-фильтр
Auditnone (read; сама история — append-only аудит, post-version.md)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/posts/{id}/versions
Success status200
Idempotency headerне применяется (безопасный идемпотентный GET)
Correlationсквозной trace через traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute ({id})GuidДаRequired, != Guid.Empty; пост существует (шаг 2)
Cursorquerystring?Нетbase64url PostVersionCursor (ContractVersion = 1, LastVersionNumber : int); сбой парсинга / чужой ContractVersionVALIDATION_ERROR (шаг 1)непрозрачный, не PII; подмена = другая страница истории
LimitqueryintНет (default 20)1..50защита от тяжёлых страниц
ActorUserIdidentity claimsGuidДаJWT автора/менеджера; используется для record-level (шаг 2)

Модель ответа (страница PostVersionListItem)

Постраничный список в keyset-режиме: Items + курсорные поля; TotalCount не вычисляется (история монотонно растёт, keyset достаточен).

ПолеТипОбязательностьИсточникОписание
ItemsIReadOnlyList<PostVersionListItem>Да (может быть пустым)projectionСтраница версий в порядке VersionNumber desc.
NextCursorstring?Нетcomputed (keyset)Opaque-курсор следующей (более старой) страницы; null — история исчерпана.
HasMoreboolДаcomputedЕсть ли ещё версии.

Вложенная модель PostVersionListItem (проекция post-version.md)

ПолеТипОбязательностьИсточникMaskingОписание
VersionNumberintДаPostVersion.VersionNumberМонотонный номер версии в пределах поста (>= 1).
ReasonPostVersionReason (string)ДаPostVersion.ReasonPublished / Republished (post-version.md).
CreatedByUserIdGuidДаPostVersion.CreatedByUserIdКто опубликовал/переопубликовал (актор перехода).
CreatedByDisplayNamestring?НетAuthorReplica.DisplayName по CreatedByUserIdПодпись публикатора из реплики identity; null при лаге консюмера (fallback).
CapturedAtDateTimeOffsetДаPostVersion.Snapshot.CapturedAtМомент снятия слепка (внутри jsonb-документа).
CreatedAtDateTimeOffsetДаPostVersion.CreatedAtМомент записи строки истории.
BlocksCountintДаcomputed из PostVersion.Snapshot.ContentЧисло блоков контента в слепке (jsonb_array_length, без материализации всего документа).

Полный PostSnapshotDocument в списке не отдаётся (тяжёлый jsonb) — читается точечным GET /api/v1/posts/{id}/versions/{versionNumber}.

Алгоритм

  1. Контекст и доступ. Читает автор поста либо менеджер компании-автора (или носитель news.posts.moderate); требуется JWT. Если задан Cursor — декодировать в keyset-позицию (LastVersionNumber); сбой парсинга или несовместимая версия курсора → VALIDATION_ERROR (400).
  2. Проверка входа и доступа. PostId != Guid.Empty, Limit в [1..50] (default 20), иначе VALIDATION_ERROR (400). Найти пост по PostId — в любом статусе (Draft/Published/Unpublished/ Archived: автор видит историю в любом состоянии); если не найден → NEWS_POST_NOT_FOUND (404). Проверить record-level: актор — автор (AuthorUserId, self), либо менеджер компании AuthorCompanyId в scope, либо носитель news.posts.moderate; иначе → FORBIDDEN (403). Проверяется после существования — 404 и 403 различимы намеренно.
  3. Выборка истории. Вернуть страницу версий поста в порядке VersionNumber desc, keyset-предикатом «версии старше LastVersionNumber» (номер монотонен и уникален в паре (PostId, VersionNumber), INV-PV-002 — одноключевой keyset без tiebreaker; индекс ix_post_versions_post). Читается на одну строку больше Limit — определяет наличие следующей (более старой) страницы. BlocksCount берётся проекцией числа блоков слепка — тяжёлый PostSnapshotDocument не материализуется. Подписи публикаторов — батчем из локальных реплик identity по CreatedByUserId (без N+1 и без синхронного вызова identity, identity-replicas.md).
  4. Маппинг и результат. Переходов состояний нет (append-only история иммутабельна, INV-PV-001). Собрать PostVersionListItem поле-в-поле: VersionNumber, Reason (enum строкой), CreatedByUserId, CapturedAt, CreatedAt, BlocksCount; CreatedByDisplayName — из реплики (fallback null при лаге консюмера). Сформировать страницу: Items = первые Limit; HasMore = была ли лишняя строка; NextCursor — позиция последнего отданного при HasMore, иначе null. Ответ → 200. Пустая история (пост ещё не публиковался — только Draft) — успех 200 с пустым Items.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (read-only)
External callsnone — подписи из локальной реплики AuthorReplica, без синхронного вызова identity/selections
Cache / projections / searchЧтение ix_post_versions_post; собственного кэша ответа нет (ADR-0056)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400PostId == Guid.Empty; Limit вне [1..50]; повреждённый/чужой Cursor«Проверьте параметры запроса»retry без cursor (первая страница)
NEWS_POST_NOT_FOUND404Пост не существует«Пост не найден»no retry
FORBIDDEN403Актор не автор и не менеджер компании-автора (нет news.posts.manage-own в scope) и без news.posts.moderate«Недостаточно прав»no retry
INTERNAL_ERROR500Инфраструктурный сбой (БД)genericretry (backoff)

Пустая история (пост ещё ни разу не публиковался — только Draft) — успех 200 с пустым Items, не ошибка.

Предусловия и постусловия

Предусловия (истинны до сценария):

  • актор аутентифицирован; он — автор поста, менеджер компании-автора в scope AuthorCompanyId либо носитель news.posts.moderate (INV-NWS-007);
  • пост существует (в любом статусе — история читается и для Unpublished/Archived); Cursor (если задан) — совместимый keyset-курсор.

Постусловия (гарантированы после чтения):

  • состояние не изменяется (read-only): нет мутаций, событий и синхронных вызовов в чужие контексты;
  • выдача — страница метаданных версий в порядке VersionNumber desc (keyset по монотонному номеру, INV-PV-002), полный PostSnapshotDocument не отдаётся; NextCursor/HasMore согласованы;
  • подписи публикаторов (CreatedByDisplayName) — из локальной реплики AuthorReplica; при лаге консюмера identitynull (мягкая деградация, идентичность CreatedByUserId всегда точна).

Acceptance Criteria

  • История опубликованного поста. Дано: автор, пост с несколькими публикациями. Когда: GET /api/v1/posts/{id}/versions. Тогда: 200, версии в порядке VersionNumber desc с Reason/CreatedByUserId/CapturedAt/BlocksCount; полный слепок не отдан.
  • Пустая история. Дано: пост только Draft (ни разу не публиковался). Когда: GET. Тогда: 200 с пустым Items (не ошибка).
  • История снятого/архивного. Дано: пост Unpublished/Archived. Когда: GET. Тогда: 200, версии видны (история живёт, пока жив пост).
  • Keyset-пагинация. Дано: версий больше Limit. Когда: GET с NextCursor. Тогда: следующая (более старая) страница без пропусков/дублей.
  • Доступ модератора. Дано: носитель news.posts.moderate. Когда: GET. Тогда: 200 (сквозной доступ помимо авторства).
  • Лаг реплики. Дано: реплика публикатора ещё не догнала. Когда: GET. Тогда: CreatedByDisplayName = null, CreatedByUserId присутствует.
  • Несуществующий пост. Дано: поста нет. Когда: GET. Тогда: 404 NEWS_POST_NOT_FOUND.
  • Не владелец. Дано: актор не автор/не менеджер scope и без news.posts.moderate. Когда: GET. Тогда: 403 FORBIDDEN (различимо от 404).
  • Невалидные параметры. Дано: PostId == Guid.Empty, Limit вне [1..50] или чужой/повреждённый Cursor. Когда: GET. Тогда: 400 VALIDATION_ERROR (клиент повторяет без cursor).

Совместимость и наблюдаемость

  • Новые optional-поля PostVersionListItem и параметры запроса — additive (без breaking). Смена default Limit, семантики keyset или формата курсора — breaking (bump ContractVersion курсора + rollout).
  • Enum PostVersionReason — строкой; клиентский fallback на unknown обязателен (additive-значения).
  • Logs: postId, размер страницы, HasMore (без PII/слепков); metrics: news_post_versions_list_requests_total{result}, latency histogram (SLO чтение p99 < 200 мс, README); traces — сквозной trace через traceparent; dashboard/runbook — news-overview (README.md).

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

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

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

  • Сущности/проекции: post-version.md (поля, PostVersionReason, Snapshot.CapturedAt, INV-PV-001/002, ix_post_versions_post), identity-replicas.md (AuthorReplica.DisplayName, GetByIds), post.md (статусы, AuthorUserId/AuthorCompanyId).
  • Соседняя операция: GET /api/v1/posts/{id}/versions/{versionNumber} (полный слепок версии) — карта области в README.md; permission-дом — identity/domain/permission.md.