GET /api/v1/posts/{id}/versions/{versionNumber} — Конкретный snapshot версии поста
Назначение
Authoring-чтение одной иммутабельной версии поста: автор (или менеджер компании-автора) открывает
конкретный snapshot из истории публикаций (PostVersion, полное версионирование — не diff) на
странице «История версий → версия N». Метод отдаёт полный слепок PostSnapshotDocument (метаданные +
подпись автора + ProjectRefs + типизированные блоки, включая SelectionEmbed.SnapshotId) ровно в том
виде, в каком он был заморожен при публикации. Состав подборок читается по SnapshotId из selections
даже для Detached-подборок (snapshot-ы иммутабельны и не удаляются, INV-SNP-006), а display-данные
объектов недвижимости embed-блока не заморожены — рендер добирает актуальные из catalog по
PropertyObjectId (решение №3, README). Мутаций нет. Список версий — соседний endpoint
list-post-versions.md. Дом полей слепка —
post-version.md.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | authoring |
| Feature group | versions |
| Статус | draft |
| Документ | docs/06-services/news/api/authoring/get-post-version.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (автор поста либо менеджер компании-автора); JWT обязателен — гость/аноним → 401 |
| Auth policy / Permissions | news.posts.manage-own (семейство news.posts.manage); каталог — permission.md |
| Scope (RBAC) | проект/организация: self (AuthorUserId) + company scope менеджера (AuthorCompanyId) |
| Record-level | Post.AuthorUserId == actor.UserId или actor — менеджер Post.AuthorCompanyId со scope; иначе FORBIDDEN (INV-NWS-007, post-version.md §Permissions) |
| Tenant isolation | TenantId = Post.AuthorCompanyId для постов застройщика/агентства (ADR-0052); посты инвестора — вне tenant (record-level по AuthorUserId) |
| Audit | none (read; сам PostVersion — append-only аудит публикаций) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/posts/{id}/versions/{versionNumber} |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PostId | route (id) | Guid | Да | != Guid.Empty | trusted route-параметр |
VersionNumber | route (versionNumber) | int | Да | >= 1 (монотонный доменный номер, INV-PV-002) | trusted route-параметр |
ActorUserId | identity claims | Guid | Да | валидный JWT (иначе 401); резолв record-level (шаг 2) | — |
Модель ответа PostVersionDetail
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
PostId | Guid | Да | PostVersion.PostId | — | Пост, чью публикацию фиксирует слепок. |
VersionNumber | int | Да | PostVersion.VersionNumber | — | Номер версии в пределах поста. |
Reason | PostVersionReason (enum-string) | Да | PostVersion.Reason | — | Published/Republished — причина захвата. |
CreatedByUserId | Guid | Да | PostVersion.CreatedByUserId | — | Кто опубликовал/переопубликовал (актор перехода; может отличаться от автора поста). |
CapturedAt | DateTimeOffset | Да | Snapshot.CapturedAt (= PostVersion.CreatedAt) | — | Момент снятия слепка. |
Snapshot | PostSnapshotModel | Да | PostVersion.Snapshot (jsonb) | — | Полный замороженный слепок поста (ниже). |
PostSnapshotModel (проекция PostSnapshotDocument, дом — post-version.md)
| Поле | Тип | Обязательность | Источник (frozen, ContractVersion=1) | Описание |
|---|
Name | string | Да | Snapshot.Name | «Название» поста на момент публикации. |
Suffix | string | Да | Snapshot.Suffix | URL-slug на момент публикации. |
Title | string | Да | Snapshot.Title | Заголовок. |
Subtitle | string? | Нет | Snapshot.Subtitle | Подзаголовок. |
CategoryId | Guid | Да | Snapshot.CategoryId | Категория (frozen). |
CategorySlug | string | Да | Snapshot.CategorySlug | Kebab-slug категории на момент публикации (мог быть переименован позже — заморожен). |
Author | PostVersionAuthorModel | Да | frozen refs + живая подпись | Автор слепка (ниже). |
ProjectIds | IReadOnlyList<Guid> | Да (допустим пустой) | Snapshot.ProjectRefs | Привязка застройщика к проектам (карточки добирает фронт в catalog). |
CoverFileId | Guid? | Нет | Snapshot.CoverFileId | Обложка (storage/CDN, без FK). |
CommentsEnabled | bool | Да | Snapshot.CommentsEnabled | Флаг комментариев на момент публикации. |
Blocks | IReadOnlyList<PostBlockModel> | Да | Snapshot.Content.Blocks (по SortOrder) | Типизированные блоки слепка (ниже). |
PostVersionAuthorModel
Идентичность автора — frozen из слепка; подпись (DisplayName/аватар/лого) — живая из реплик
identity (может отличаться от подписи на момент публикации; для истории автор смотрит свой пост).
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AuthorUserId | Guid | Да | Snapshot.AuthorUserId (frozen) | Автор поста. |
Type | PostAuthorType (enum-string) | Да | Snapshot.AuthorType (frozen) | Investor/DeveloperManager/Agent/AgencyManager/PinEmployee — дом post.md. |
AvatarFileId | Guid? | Нет | AuthorReplica.AvatarFileId (live) | Аватар (storage/CDN). |
DisplayName | string | Да | AuthorReplica.DisplayName (live) | Подпись автора; отсутствие реплики (лаг) → мягкая деградация. |
AuthorCompany | PostVersionAuthorCompanyModel? | Нет | frozen id + живая подпись | Компания-автор (nested ref-model); null — авторство не от компании. |
PostVersionAuthorCompanyModel (nested ref-model компании-автора)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
AuthorCompanyId | Guid | Да | Snapshot.AuthorCompanyId (frozen) | Идентификатор компании-автора (заморожен в слепке). |
AuthorCompanyName | string | Да | CompanyReplica.Name (live) | Название компании-автора (живое из реплики). |
PostBlockModel (полиморфная, дискриминатор Kind)
База: BlockId (Guid), Kind (PostBlockKind enum-string: Text/ImageGallery/SelectionEmbed),
SortOrder (int). Sealed-подтипы — проекция typed-графа PostContentDocument, дом —
post-block.md; ниже — только дельта выдачи:
TextBlockModel (Kind=Text): Format (TextFormat enum-string Markdown/PlainText), Text
(string; санитизация — при рендере). Производный PlainText — только источник поиска, в ответ не входит.
ImageGalleryBlockModel (Kind=ImageGallery): Layout (GalleryLayout enum-string
Single/Grid/Carousel), Images (IReadOnlyList<GalleryImageModel> — FileId Guid,
SortOrder int, Caption string?, Alt string?, Width int?, Height int?).
SelectionEmbedBlockModel (Kind=SelectionEmbed): SnapshotId (Guid, frozen), SourceSelectionId
(Guid), SourceSelectionPublicCode (string, 12 base62), Title (string), ItemsCount (int).
Backend отдаёт только ссылки; состав фронт читает по SnapshotId в selections (доступен даже
если подборка Detached — INV-SNP-006), display-данные объектов недвижимости — актуальные из catalog
по PropertyObjectId (не заморожены, решение №3).
Алгоритм
- Контекст и доступ. Читает автор поста либо менеджер компании-автора; требуется валидный JWT
(гость/невалидный токен →
401) и право news.posts.manage-own.
- Проверка входа и доступа.
PostId != Guid.Empty, VersionNumber >= 1, иначе ValidationError
(400). Найти пост по PostId; если не найден → NEWS_POST_NOT_FOUND (404). Статус поста не фильтруется:
историю читают и для Unpublished/Archived (версии живут, пока жив пост). Проверить record-level
(INV-NWS-007): актор — автор (AuthorUserId) либо менеджер компании AuthorCompanyId в scope; иначе →
FORBIDDEN (403). 404 и 403 различимы намеренно.
- Что читаем. Найти версию по паре (
PostId, VersionNumber) — уникальна (INV-PV-002); если её нет →
NEWS_VERSION_NOT_FOUND (404). Полный слепок PostSnapshotDocument уже хранится в строке версии. Для
живой подписи автора — реплика по AuthorUserId слепка; при наличии компании-автора — реплика компании.
Синхронных вызовов selections/catalog нет: состав встроенных подборок и объекты недвижимости резолвит
фронт по SnapshotId/PropertyObjectId.
- Сборка ответа. Переходов состояний нет — слот версии иммутабелен (INV-PV-001). Собрать
PostVersionDetail по таблицам моделей: метаданные, CategorySlug, ProjectIds; идентичность автора —
из замороженного слепка (frozen), подпись (DisplayName/аватар/лого) — из живых реплик identity; блоки
по SortOrder; SelectionEmbed-блоки отдают только snapshot-ссылки. Неизвестный Kind блока при чтении
→ ошибка (INV-NWS-B06, строгая типизация).
- Результат.
PostVersionDetail → 200. Поля слепка строго frozen (иммутабельны); подпись автора и
display-данные объектов недвижимости встроенных блоков — актуальные/eventual, не заморожены.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | none (read-only) |
| External calls | none — selections (GET snapshot по SnapshotId, доступен и для Detached) и catalog (объекты недвижимости по PropertyObjectId) вызывает фронт при рендере, не бэкенд |
| Cache / projections / search | чтение по PK поста + unique ux_post_versions_number; кэша нет (ADR-0056) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | JWT отсутствует/невалиден | без тела схемы (фронт очищает сессию) | re-login |
ValidationError | 400 | PostId == Guid.Empty или VersionNumber < 1 | Список ошибок полей | no retry |
NEWS_POST_NOT_FOUND | 404 | Поста нет | «Пост не найден» | no retry |
NEWS_VERSION_NOT_FOUND | 404 | Пост есть, но нет версии с таким VersionNumber | «Версия поста не найдена» | no retry |
FORBIDDEN | 403 | Пост есть, но actor — не автор и не менеджер компании-автора в scope | «Недостаточно прав» | no retry |
Предусловия и постусловия
Предусловия (истинны до сценария):
- актор аутентифицирован; он — автор поста либо менеджер компании-автора в scope
AuthorCompanyId (INV-NWS-007);
- пост существует (в любом статусе); запрошенная версия
(PostId, VersionNumber) существует и иммутабельна (INV-PV-001).
Постусловия (гарантированы после чтения):
- состояние не изменяется (read-only): нет мутаций, событий и синхронных вызовов бэкенда в
selections/catalog;
- отдан полный замороженный слепок
PostSnapshotDocument ровно в виде на момент публикации: метаданные, CategorySlug, ProjectIds, идентичность автора и типизированные блоки, включая SelectionEmbed.SnapshotId — frozen (иммутабельны);
- не заморожены и берутся живыми: подпись автора/компании (реплики
identity; при лаге → мягкая деградация) и display-данные объектов недвижимости embed-блоков (добирает фронт из catalog по PropertyObjectId, решение №3).
Интеграционная последовательность
Бэкенд синхронно отдаёт слепок из своей БД (никаких синхронных вызовов в selections/catalog). Что предоставляет наружу: ссылки embed-блока (SnapshotId, SourceSelectionId, PublicCode, ItemsCount), по которым фронт дорезолвит: состав подборки — из selections по SnapshotId (доступен даже для Detached-подборок, snapshot иммутабелен INV-SNP-006); display-данные объектов недвижимости — из catalog по PropertyObjectId (актуальные, не заморожены). Что ожидает: локальная реплика автора/компании для живой подписи; её отсутствие (лаг консюмера identity) не блокирует ответ — деградация подписи.
Acceptance Criteria
- Чтение конкретной версии. Дано: автор, версия
(PostId, N) существует. Когда: GET /api/v1/posts/{id}/versions/{N}. Тогда: 200, полный frozen-слепок с блоками по SortOrder, SelectionEmbed-блоки отдают только ссылки.
- Живая подпись поверх frozen-идентичности. Дано:
DisplayName/лого автора изменились после публикации. Когда: GET. Тогда: идентичность (AuthorUserId/AuthorType/AuthorCompanyId) — из слепка, подпись (DisplayName/аватар/название компании) — текущая из реплик.
- История доступна для снятого/архивного поста. Дано: пост
Unpublished/Archived. Когда: GET. Тогда: 200 (версии живут, пока жив пост).
- Embed на
Detached-подборке. Дано: подборка блока позже отвязана. Когда: фронт читает selections по SnapshotId. Тогда: состав доступен (INV-SNP-006).
- Лаг реплики. Дано: реплика автора не догнала. Когда:
GET. Тогда: DisplayName мягко деградирует, ответ отдаётся.
- Несуществующий пост. Дано: поста нет. Когда:
GET. Тогда: 404 NEWS_POST_NOT_FOUND.
- Несуществующая версия. Дано: пост есть, версии с
VersionNumber нет. Когда: GET. Тогда: 404 NEWS_VERSION_NOT_FOUND.
- Не владелец. Дано: актор не автор/не менеджер компании-автора в scope. Когда:
GET. Тогда: 403 FORBIDDEN (различимо от 404).
- Невалидный вход. Дано:
PostId == Guid.Empty или VersionNumber < 1. Когда: GET. Тогда: 400 ValidationError.
- Гость. Дано: нет/невалидный JWT. Когда:
GET. Тогда: 401.
Совместимость и наблюдаемость
- Новые optional-поля
PostVersionDetail/PostSnapshotModel/блоков — additive (без breaking);
удаление/переименование/смена типа — breaking (migration + rollout). Slot читается по всем
ContractVersion из БД — snapshot-ы не мигрируются (иммутабельны, post-version.md);
неизвестный Kind блока → fail (INV-NWS-B06, post-block.md).
- Logs:
PostId, VersionNumber, actor.UserId, способ доступа (self/manager), результат; metrics —
news_post_version_read_requests_total{result}, latency histogram (SLO чтения p99 < 200 мс —
README); traces — сквозной trace через traceparent; dashboard news-overview.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Дома полей: post-version.md (slot
PostSnapshotDocument, Reason,
INV-PV-001/002), post-block.md (типизированные блоки, SelectionEmbed),
post.md (агрегат, PostAuthorType, INV-NWS-007),
post-category.md (Slug),
identity-replicas.md (живая подпись автора/компании).
- Соседние endpoints: list-post-versions.md (список версий),
get-post.md (публичное чтение текущего поста).
- Embed-подборки: selection-snapshot.md (
SnapshotId,
иммутабельность INV-SNP-006, Detached); рендер объектов недвижимости — catalog/ по PropertyObjectId.
- Permission-каталог: permission.md.
- ADR: ADR-0052 (tenancy),
ADR-0056 (simplicity-first).