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

PUT /api/v1/posts/{id}/blocks — Редактирование блоков поста (embed через selections)

Замена всего содержимого поста (PostContentDocument) одним запросом. Блок-подборка (SelectionEmbed) иммутабелен (INV-SEL-001): правка живой подборки блок не меняет, единственная правка автора — замена блока целиком (новый snapshot selections → переключение → detach старого). Дом структуры блоков — post-block.md; дом полей агрегата — post.md; дом иммутабельной копии — selection-snapshot.md.

Назначение

Автор (или менеджер компании в scope) редактирует блоки своего поста: текст, галерею изображений и встроенные подборки. Операция принимает полный новый состав блоков, синхронно снимает SelectionSnapshot в selections для новых embed-блоков, пересчитывает SearchText (источник поиска) и отвязывает снятые snapshot-ы после сохранения.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (автор поста; либо менеджер компании)
Auth policy / Permissionsnews.posts.manage-own; для чужого поста компании — news.posts.create-company в scope AuthorCompanyId
Scope (RBAC)self (AuthorUserId) + организация (AuthorCompanyId)
Record-levelAuthorUserId == actor, либо менеджер в scope AuthorCompanyId, либо permission news.posts.manage (INV-NWS-007) → иначе 403
Tenant isolationTenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052)
Auditupdate: UpdatedByUserId/UpdatedAt; PII нет

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/v1/posts/{id}/blocks
Success status200 (содержимое заменено)
Idempotency headerне применяется; внешние вызовы selections идемпотентны по паре (PostId, SourceSelectionId)
Correlationсквозной trace через traceparent

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

Полиморфные блоки — по дискриминатору Kind (enum-строка), дом полей — post-block.md; ниже — только input-дельта (без SnapshotId/PlainText, вычисляемых сервером).

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute {id}GuidДасуществующий пост (шаг 2); Status != Archivedrecord-level (INV-NWS-007)
BlocksbodyIReadOnlyList<PostBlockInput>Да0..200 (INV-NWS-B01); уникальные BlockId (B02); порядок = SortOrder
ActorUserIdidentity claimsGuidДаJWT автора/менеджера

PostBlockInput (полиморфный, discriminator Kind : PostBlockKind enum-string)

ПолеТипОбязательностьОграничения
BlockIdGuid?Нетnull — новый блок (сервер присвоит идентификатор); задан — стабильный id между версиями
KindPostBlockKindДаText / ImageGallery / SelectionEmbed
SortOrderintДа>= 0

Подтипы (typed, sealed): TextBlockInput { Format: TextFormat, Text: string(1..20000) } (PlainText деривится сервером); ImageGalleryBlockInput { Layout: GalleryLayout, Images: IReadOnlyList<GalleryImageInput> (1..50) }; SelectionEmbedBlockInput { SourceSelectionId: Guid?, SnapshotId: Guid? } — ровно одно из: SourceSelectionId (новый/заменяемый блок → снять snapshot) или SnapshotId (сохранить существующий Active-snapshot без пересъёмки).

Модель ответа SetPostBlocksResult

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPost.IdИдентификатор поста.
BlocksCountintДаcomputedЧисло блоков в новом составе.
EmbedSnapshotIdsIReadOnlyList<Guid>Даблоки SelectionEmbed нового составаАктивные SnapshotId встроенных подборок (ограниченная inline-коллекция).

Алгоритм

  1. Контекст и доступ. Правит автор поста либо менеджер компании в scope (User). Требуется право news.posts.manage-own (для чужого поста компании — news.posts.create-company в scope AuthorCompanyId). Безопасность повтора обеспечивают идемпотентные вызовы selections.
  2. Проверка входа и доступа. Границы состава: не более 200 блоков (NEWS_TOO_MANY_BLOCKS, INV-NWS-B01), уникальные BlockId (INV-NWS-B02), длина текста 1..20000, галерея 1..50, для каждого SelectionEmbed-блока задано ровно одно из SourceSelectionId/SnapshotId; нарушение → VALIDATION_FAILED. Найти пост по PostId; если не найден → NEWS_POST_NOT_FOUND (404). Допустимость состояния: Status ∈ {Draft, Published, Unpublished}; правка Archived запрещена → NEWS_POST_ARCHIVED (409). Record-level (INV-NWS-007): актор — автор, либо менеджер в scope AuthorCompanyId, либо носитель news.posts.manage; иначе → 403.
  3. Синхронизация встроенных подборок. Запомнить прежние SnapshotId встроенных блоков (для вычисления снятых). Построить новый состав контента:
    • Text/ImageGallery — прямой перенос input в блок (PlainText деривится из текста; BlockId — из входа либо новый);
    • SelectionEmbed: если задан SnapshotId живого блока — переносится как есть (не существует/не ActiveNEWS_SNAPSHOT_INVALID, INV-NWS-B05); если задан SourceSelectionId (новый/заменяемый блок) — синхронно снять новый snapshot в selections (идемпотентно по паре (PostId, SourceSelectionId) — вернёт существующий Active) и записать SnapshotId/PublicCode/Title/ItemsCount в блок. Пробросы из selections: подборка-источник удалена → NEWS_SELECTION_SOURCE_DELETED; пустая подборка → NEWS_SELECTION_SOURCE_EMPTY; selections недоступен/таймаут → NEWS_SELECTIONS_UNAVAILABLE (503).
    • detachSet = прежние SnapshotId минус новые — snapshot-ы, исчезнувшие из состава.
  4. Сохранение состава. Заменить контент поста новым составом; пересчитать денормализованный SearchText из текстовых блоков (INV-NWS-008; для Published-поста — обновить поисковую проекцию news-posts), actor-stamp. Пост и пересчёт SearchText сохраняются атомарно. Конкурентная правка → CONCURRENCY_CONFLICT (409, retry с перечиткой). Интеграционных Kafka-событий нет: публикация в ленту — отдельная операция POST /posts/{id}/publish; встроенные подборки синхронизируются вызовом selections, не через Kafka.
  5. Отвязка снятых snapshot-ов (после сохранения). Для каждого SnapshotId ∈ detachSet вызвать отвязку в selections (reason = BlockReplaced). Сбой отвязки не откатывает пост (новый состав уже валиден) — осиротевший Active-snapshot надёжно отвяжет job snapshot-detach-retry (README, Jobs).
  6. Результат. SetPostBlocksResult { PostId, BlocksCount, EmbedSnapshotIds }200.

Диаграмма

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

ТипДетали
Integration eventsnone (доменное PostContentChangedEvent — пересчёт SearchText в том же изменении; публикация в ленту — отдельная операция)
External callsselections (m2m): снять snapshot (на каждый новый embed) + отвязать (после сохранения); timeout 5 c, retry 2; идемпотентно по (PostId,SourceSelectionId)
Cache / projections / searchPost.SearchText (app) пересчитывается в транзакции; для Published-поста — upsert Elasticsearch-проекции news-posts (INV-NWS-008, search/posts.md)
Notificationsnone (уведомления шлёт publish-операция)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
NEWS_TOO_MANY_BLOCKS400Blocks.Count > 200 (INV-NWS-B01)«Не более 200 блоков в посте»no retry
NEWS_SELECTION_SOURCE_DELETED400selections: подборка-источник удалена (проброс)«Подборка недоступна»no retry
NEWS_SELECTION_SOURCE_EMPTY400selections: пустая подборка (проброс)«В подборке нет объектов»no retry
NEWS_SNAPSHOT_INVALID422SnapshotId живого блока не существует / не Active (INV-NWS-B05)«Некорректная встроенная подборка»no retry
NEWS_SELECTIONS_UNAVAILABLE503selections недоступен/timeout при create/detach«Сервис подборок временно недоступен»retry
VALIDATION_FAILED400границы текста/галереи, дубли BlockId, конфликт SourceSelectionId/SnapshotId«Проверьте содержимое блоков»no retry
NEWS_POST_NOT_FOUND404пост не найден«Пост не найден»no retry
NEWS_POST_ARCHIVED409Status = Archived«Пост в архиве»no retry
CONCURRENCY_CONFLICT409гонка RowVersion (xmin)«Повторите попытку»retry (перечитка)

Отвязка snapshot-а после сохранения при сбое не возвращает ошибку клиенту (пост уже сохранён) — надёжный повтор через job.

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

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

  • актор — автор поста, менеджер компании в scope AuthorCompanyId либо носитель news.posts.manage (INV-NWS-007);
  • пост существует и в статусе Draft/Published/Unpublished (правка Archived запрещена);
  • для каждого SelectionEmbed-блока задано ровно одно из SourceSelectionId/SnapshotId; при SnapshotId живого блока snapshot существует и Active;
  • selections доступен, если в составе есть новые embed-блоки по SourceSelectionId (иначе — деградация: добавление/замена блока-подборки блокируется).

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

  • контент поста заменён новым составом (0..200 блоков, уникальные BlockId, порядок по SortOrder, INV-NWS-B01/B02); денормализованный SearchText пересчитан из текстовых блоков (INV-NWS-008);
  • для каждого нового embed по SourceSelectionId снят иммутабельный SelectionSnapshot (Active), его SnapshotId/PublicCode/Title/ItemsCount записаны в блок; actor-stamp UpdatedByUserId/UpdatedAt;
  • snapshot-ы, исчезнувшие из состава (detachSet), отвязаны в selections (reason = BlockReplaced) после коммита либо поставлены в snapshot-detach-retry; сами snapshot-ы не удаляются (INV-SNP-006) — старые PostVersion рендерят исторический состав;
  • для Published-поста обновлена поисковая проекция news-posts; публикации в ленту (событие post-published) не происходит — это отдельная операция.

Интеграционная последовательность

Синхронно в запросе (порядок «создать → собрать → сохранить»): валидация состава → для каждого нового SelectionEmbed по SourceSelectionId синхронный вызов selections снять snapshot (идемпотентно по паре (PostId, SourceSelectionId) — повтор вернёт существующий Active) → сборка нового контента и пересчёт SearchText → атомарное сохранение поста → 200 клиенту. После коммита (порядок «сначала фиксируем состав, затем отвязываем»): для каждого SnapshotId ∈ detachSet — синхронная отвязка в selections (reason = BlockReplaced); сбой отвязки не откатывает пост — осиротевший Active-snapshot надёжно отвяжет job snapshot-detach-retry.

Что ожидает от selections (синхронный m2m, ADR-подтверждён решением №1): create-snapshot возвращает SnapshotId/PublicCode/Title/ItemsCount и статус Active; ошибки-контракты пробрасываются как NEWS_SELECTION_SOURCE_DELETED (подборка-источник удалена), NEWS_SELECTION_SOURCE_EMPTY (пустая подборка), NEWS_SNAPSHOT_INVALID (переданный SnapshotId не Active); недоступность/таймаут → NEWS_SELECTIONS_UNAVAILABLE (503). detach идемпотентен (INV-SNP-005). Что предоставляет наружу: только доменный PostContentChangedEvent (пересчёт SearchText в том же изменении) — интеграционных Kafka-событий нет; связь PostId ↔ SnapshotId news держит в своей транзакции. Что видит пользователь в промежуточном состоянии: для Published-поста новый состав виден в чтении поста сразу после коммита; поисковая проекция догоняет в окне секунд; если detach старого snapshot ещё не выполнен — исторические PostVersion продолжают корректно ссылаться на него.

Acceptance Criteria

  • Замена состава без embed. Дано: автор, пост Draft, Blocks — текст/галерея в пределах границ. Когда: PUT /api/v1/posts/{id}/blocks. Тогда: 200, контент заменён, SearchText пересчитан, selections не вызывается, событий нет.
  • Новый embed по подборке. Дано: блок SelectionEmbed с SourceSelectionId непустой подборки. Когда: PUT. Тогда: снят Active-snapshot, SnapshotId/PublicCode/Title/ItemsCount записаны в блок, EmbedSnapshotIds в ответе, 200.
  • Идемпотентный повтор снятия. Дано: тот же SourceSelectionId при retry/reconnect. Когда: повтор PUT. Тогда: возвращается ранее снятый Active-snapshot, дубля нет.
  • Замена embed целиком. Дано: блок с прежним SnapshotId заменён блоком с новым SourceSelectionId. Когда: PUT. Тогда: снят новый snapshot, прежний попал в detachSet и отвязан после коммита (reason = BlockReplaced); прежний snapshot не удалён (INV-SNP-006).
  • Сохранение живого embed. Дано: блок с SnapshotId существующего Active-snapshot. Когда: PUT. Тогда: блок перенесён как есть, повторного снятия нет.
  • Невалидный snapshot. Дано: SnapshotId не существует/не Active (INV-NWS-B05). Когда: PUT. Тогда: 422 NEWS_SNAPSHOT_INVALID, пост не изменён.
  • Подборка-источник удалена/пуста. Дано: selections сообщает deleted/empty. Когда: PUT. Тогда: 400 NEWS_SELECTION_SOURCE_DELETED/NEWS_SELECTION_SOURCE_EMPTY.
  • selections недоступен. Дано: таймаут/5xx на create или detach в фазе снятия. Когда: PUT. Тогда: 503 NEWS_SELECTIONS_UNAVAILABLE (retry); сбой detach после сохранения ошибки клиенту не возвращает — надёжный повтор job-ом.
  • Превышение состава. Дано: Blocks.Count > 200 (INV-NWS-B01). Когда: PUT. Тогда: 400 NEWS_TOO_MANY_BLOCKS.
  • Пост в архиве. Дано: Status = Archived. Когда: PUT. Тогда: 409 NEWS_POST_ARCHIVED.
  • Конкурентная правка. Дано: параллельная мутация поста (устаревшая версия). Когда: PUT. Тогда: 409 CONCURRENCY_CONFLICT, клиент перечитывает.
  • Не владелец. Дано: актор не автор/не менеджер scope и без news.posts.manage. Когда: PUT. Тогда: 403.

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

  • Модель блоков — additive-only в рамках ContractVersion = 1 (новые optional-поля/новые Kind); удаление/ переименование поля — breaking (миграция reader-а, post-block.md Versioning). Иммутабельность embed (INV-SEL-001): правка embed = замена блока целиком (пересъёмка snapshot + detach старого).
  • Logs: set-blocks received/snapshot-created/committed/detach-* (PostId, число блоков, SnapshotId); metrics: news_set_blocks_total{result}, news_selections_calls_total{op,result}, detach-retry depth; traces — сквозной trace через traceparent; dashboard news-overview; runbook «пост показывает не тот состав» (сверка SnapshotId блока ↔ snapshot в selections), ручной detach осиротевших (README).

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

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

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

  • Сущности: post.md (агрегат, INV-NWS-), post-block.md (typed-jsonb блоки, INV-NWS-B); источник embed: selection-snapshot.md (снятие/отвязка snapshot, INV-SEL-001/INV-SNP-*).
  • Соседние операции: publish (pin.news.post-published.v1, ../events/post-published.md), PUT /posts/{id} (метаданные), DELETE /posts/{id} (detach reason PostDeleted).
  • ADR: ADR-0052 (tenancy), ADR-0056 (simplicity-first), ADR-0059 (Elasticsearch-проекция поиска).