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 area | authoring (self + company scope) |
| Feature group | authoring |
| Статус | draft |
| Документ | docs/06-services/news/api/authoring/set-post-blocks.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (автор поста; либо менеджер компании) |
| Auth policy / Permissions | news.posts.manage-own; для чужого поста компании — news.posts.create-company в scope AuthorCompanyId |
| Scope (RBAC) | self (AuthorUserId) + организация (AuthorCompanyId) |
| Record-level | AuthorUserId == actor, либо менеджер в scope AuthorCompanyId, либо permission news.posts.manage (INV-NWS-007) → иначе 403 |
| Tenant isolation | TenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052) |
| Audit | update: UpdatedByUserId/UpdatedAt; PII нет |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/v1/posts/{id}/blocks |
| Success status | 200 (содержимое заменено) |
| Idempotency header | не применяется; внешние вызовы selections идемпотентны по паре (PostId, SourceSelectionId) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Полиморфные блоки — по дискриминатору Kind (enum-строка), дом полей — post-block.md;
ниже — только input-дельта (без SnapshotId/PlainText, вычисляемых сервером).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
PostId | route {id} | Guid | Да | существующий пост (шаг 2); Status != Archived | record-level (INV-NWS-007) |
Blocks | body | IReadOnlyList<PostBlockInput> | Да | 0..200 (INV-NWS-B01); уникальные BlockId (B02); порядок = SortOrder | — |
ActorUserId | identity claims | Guid | Да | JWT автора/менеджера | — |
PostBlockInput (полиморфный, discriminator Kind : PostBlockKind enum-string)
| Поле | Тип | Обязательность | Ограничения |
|---|---|---|---|
BlockId | Guid? | Нет | null — новый блок (сервер присвоит идентификатор); задан — стабильный id между версиями |
Kind | PostBlockKind | Да | Text / ImageGallery / SelectionEmbed |
SortOrder | int | Да | >= 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 | Описание |
|---|---|---|---|---|---|
PostId | Guid | Да | Post.Id | — | Идентификатор поста. |
BlocksCount | int | Да | computed | — | Число блоков в новом составе. |
EmbedSnapshotIds | IReadOnlyList<Guid> | Да | блоки SelectionEmbed нового состава | — | Активные SnapshotId встроенных подборок (ограниченная inline-коллекция). |
Алгоритм
- Контекст и доступ. Правит автор поста либо менеджер компании в scope (
User). Требуется правоnews.posts.manage-own(для чужого поста компании —news.posts.create-companyв scopeAuthorCompanyId). Безопасность повтора обеспечивают идемпотентные вызовыselections. - Проверка входа и доступа. Границы состава: не более 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): актор — автор, либо менеджер в scopeAuthorCompanyId, либо носительnews.posts.manage; иначе →403. - Синхронизация встроенных подборок. Запомнить прежние
SnapshotIdвстроенных блоков (для вычисления снятых). Построить новый состав контента:Text/ImageGallery— прямой перенос input в блок (PlainTextдеривится из текста;BlockId— из входа либо новый);SelectionEmbed: если заданSnapshotIdживого блока — переносится как есть (не существует/неActive→NEWS_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-ы, исчезнувшие из состава.
- Сохранение состава. Заменить контент поста новым составом; пересчитать денормализованный
SearchTextиз текстовых блоков (INV-NWS-008; дляPublished-поста — обновить поисковую проекциюnews-posts), actor-stamp. Пост и пересчётSearchTextсохраняются атомарно. Конкурентная правка →CONCURRENCY_CONFLICT(409, retry с перечиткой). Интеграционных Kafka-событий нет: публикация в ленту — отдельная операцияPOST /posts/{id}/publish; встроенные подборки синхронизируются вызовомselections, не через Kafka. - Отвязка снятых snapshot-ов (после сохранения). Для каждого
SnapshotId ∈ detachSetвызвать отвязку вselections(reason = BlockReplaced). Сбой отвязки не откатывает пост (новый состав уже валиден) — осиротевшийActive-snapshot надёжно отвяжет jobsnapshot-detach-retry(README, Jobs). - Результат.
SetPostBlocksResult { PostId, BlocksCount, EmbedSnapshotIds }→200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (доменное PostContentChangedEvent — пересчёт SearchText в том же изменении; публикация в ленту — отдельная операция) |
| External calls | selections (m2m): снять snapshot (на каждый новый embed) + отвязать (после сохранения); timeout 5 c, retry 2; идемпотентно по (PostId,SourceSelectionId) |
| Cache / projections / search | Post.SearchText (app) пересчитывается в транзакции; для Published-поста — upsert Elasticsearch-проекции news-posts (INV-NWS-008, search/posts.md) |
| Notifications | none (уведомления шлёт publish-операция) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
NEWS_TOO_MANY_BLOCKS | 400 | Blocks.Count > 200 (INV-NWS-B01) | «Не более 200 блоков в посте» | no retry |
NEWS_SELECTION_SOURCE_DELETED | 400 | selections: подборка-источник удалена (проброс) | «Подборка недоступна» | no retry |
NEWS_SELECTION_SOURCE_EMPTY | 400 | selections: пустая подборка (проброс) | «В подборке нет объектов» | no retry |
NEWS_SNAPSHOT_INVALID | 422 | SnapshotId живого блока не существует / не Active (INV-NWS-B05) | «Некорректная встроенная подборка» | no retry |
NEWS_SELECTIONS_UNAVAILABLE | 503 | selections недоступен/timeout при create/detach | «Сервис подборок временно недоступен» | retry |
VALIDATION_FAILED | 400 | границы текста/галереи, дубли BlockId, конфликт SourceSelectionId/SnapshotId | «Проверьте содержимое блоков» | no retry |
NEWS_POST_NOT_FOUND | 404 | пост не найден | «Пост не найден» | no retry |
NEWS_POST_ARCHIVED | 409 | Status = Archived | «Пост в архиве» | no retry |
CONCURRENCY_CONFLICT | 409 | гонка 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-stampUpdatedByUserId/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.mdVersioning). Иммутабельность 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; dashboardnews-overview; runbook «пост показывает не тот состав» (сверкаSnapshotIdблока ↔ snapshot вselections), ручной detach осиротевших (README).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности:
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 reasonPostDeleted). - ADR: ADR-0052 (tenancy), ADR-0056 (simplicity-first), ADR-0059 (Elasticsearch-проекция поиска).