GET /api/v1/posts/{id}/versions — История версий поста (snapshot)
Назначение
Отдать автору (или менеджеру компании-автора) полную snapshot-историю публикаций конкретного поста:
каждая публикация/перепубликация фиксирует иммутабельную строку PostVersion, endpoint постранично
перечисляет их метаданные (номер, причина, кто и когда опубликовал, размер контента) от свежих к старым.
Полный слепок каждой версии наружу не отдаётся — его читает соседний
GET /api/v1/posts/{id}/versions/{versionNumber}. Только чтение: без мутаций, событий и синхронных
вызовов в чужие контексты.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | authoring (self + company scope) |
| Feature group | versions |
| Статус | draft |
| Owner | PIN Engagement |
| Документ | docs/06-services/news/api/authoring/list-post-versions.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (автор поста или менеджер компании-автора) |
| Auth policy / Permissions | news.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-фильтр |
| Audit | none (read; сама история — append-only аудит, post-version.md) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/posts/{id}/versions |
| Success status | 200 |
| Idempotency header | не применяется (безопасный идемпотентный GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PostId | route ({id}) | Guid | Да | Required, != Guid.Empty; пост существует (шаг 2) | — |
Cursor | query | string? | Нет | base64url PostVersionCursor (ContractVersion = 1, LastVersionNumber : int); сбой парсинга / чужой ContractVersion → VALIDATION_ERROR (шаг 1) | непрозрачный, не PII; подмена = другая страница истории |
Limit | query | int | Нет (default 20) | 1..50 | защита от тяжёлых страниц |
ActorUserId | identity claims | Guid | Да | JWT автора/менеджера; используется для record-level (шаг 2) | — |
Модель ответа (страница PostVersionListItem)
Постраничный список в keyset-режиме: Items + курсорные поля; TotalCount не вычисляется (история
монотонно растёт, keyset достаточен).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
Items | IReadOnlyList<PostVersionListItem> | Да (может быть пустым) | projection | Страница версий в порядке VersionNumber desc. |
NextCursor | string? | Нет | computed (keyset) | Opaque-курсор следующей (более старой) страницы; null — история исчерпана. |
HasMore | bool | Да | computed | Есть ли ещё версии. |
Вложенная модель PostVersionListItem (проекция post-version.md)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
VersionNumber | int | Да | PostVersion.VersionNumber | — | Монотонный номер версии в пределах поста (>= 1). |
Reason | PostVersionReason (string) | Да | PostVersion.Reason | — | Published / Republished (post-version.md). |
CreatedByUserId | Guid | Да | PostVersion.CreatedByUserId | — | Кто опубликовал/переопубликовал (актор перехода). |
CreatedByDisplayName | string? | Нет | AuthorReplica.DisplayName по CreatedByUserId | — | Подпись публикатора из реплики identity; null при лаге консюмера (fallback). |
CapturedAt | DateTimeOffset | Да | PostVersion.Snapshot.CapturedAt | — | Момент снятия слепка (внутри jsonb-документа). |
CreatedAt | DateTimeOffset | Да | PostVersion.CreatedAt | — | Момент записи строки истории. |
BlocksCount | int | Да | computed из PostVersion.Snapshot.Content | — | Число блоков контента в слепке (jsonb_array_length, без материализации всего документа). |
Полный PostSnapshotDocument в списке не отдаётся (тяжёлый jsonb) — читается точечным
GET /api/v1/posts/{id}/versions/{versionNumber}.
Алгоритм
- Контекст и доступ. Читает автор поста либо менеджер компании-автора (или носитель
news.posts.moderate); требуется JWT. Если задан Cursor — декодировать в keyset-позицию
(LastVersionNumber); сбой парсинга или несовместимая версия курсора → VALIDATION_ERROR (400).
- Проверка входа и доступа.
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 различимы намеренно.
- Выборка истории. Вернуть страницу версий поста в порядке
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).
- Маппинг и результат. Переходов состояний нет (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 events | none (read-only) |
| External calls | none — подписи из локальной реплики AuthorReplica, без синхронного вызова identity/selections |
| Cache / projections / search | Чтение ix_post_versions_post; собственного кэша ответа нет (ADR-0056) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | PostId == Guid.Empty; Limit вне [1..50]; повреждённый/чужой Cursor | «Проверьте параметры запроса» | retry без cursor (первая страница) |
NEWS_POST_NOT_FOUND | 404 | Пост не существует | «Пост не найден» | no retry |
FORBIDDEN | 403 | Актор не автор и не менеджер компании-автора (нет news.posts.manage-own в scope) и без news.posts.moderate | «Недостаточно прав» | no retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой (БД) | generic | retry (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; при лаге консюмера identity → null (мягкая деградация, идентичность 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).
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы