UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot)
Назначение
Автор поста news встраивает подборку объектов в блок поста как иммутабельную копию: news
проверяет авторство поста и право пользователя на подборку, синхронно снимает SelectionSnapshot в
selections (m2m) и сохраняет SnapshotId в typed-jsonb блоке (SelectionEmbed). «Для поста нужно
сделать свою копию подборки, чтобы после публикации создатель подборки не мог её изменить, но при
необходимости мог заменить один блок подборки на другой» (инвариант INV-SEL-001). Связан с контекстом
news; зеркальный сценарий со стороны владельца копии —
UC-SEL-004.
| Поле | Значение |
|---|
| ID | UC-NWS-002 |
| Контекст | news (см. docs/06-services/news/); интеграция — selections (m2m), catalog (рендер) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Первичный — Автор поста (User: Investor/Agent/DeveloperManager/AgencyManager/PinEmployee) через редактор news. Вторичный (технический) — m2m-интеграция news → selections internal API (ExternalSystem, client-credentials) |
| Permissions / scope | news.posts.manage-own; для поста компании — news.posts.create-company в scope AuthorCompanyId; либо news.posts.manage (каталог — identity/domain/permission.md). Право пользователя на подборку проверяет news до вызова selections |
| Tenant | TenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052 п.4) |
| Record-level | AuthorUserId == actor, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manage (INV-NWS-007) → иначе 403 |
Предусловие
- Пост существует и
Status ∈ {Draft, Published, Unpublished} (правка Archived запрещена — post.md).
- Актор — автор поста либо менеджер компании в scope
AuthorCompanyId (INV-NWS-007).
- У автора есть активная непустая подборка-источник (
SourceSelectionId, DeletedAt == null, 1..200 элементов); подборка может быть чужой публичной, если бизнес-правило news это разрешает.
selections доступен (иначе публикация блока блокируется — NEWS_SELECTIONS_UNAVAILABLE, README, «Зависимости»).
Триггер
| Поле | Значение |
|---|
| Источник | UI button → API call |
| Триггерящий элемент | Добавление блока-подборки в редакторе поста news: PUT /api/v1/posts/{id}/blocks с блоком SelectionEmbed (SourceSelectionId) — set-post-blocks.md |
Основной Поток
Алгоритм операции set-blocks — set-post-blocks.md; ниже —
пользовательский сценарий.
- Автор в редакторе поста
news выбирает подборку (SourceSelectionId) в блок SelectionEmbed и отправляет полный новый состав блоков (PUT /api/v1/posts/{id}/blocks).
news проверяет авторство поста и право пользователя на подборку: record-level (INV-NWS-007) + бизнес-правило доступа к подборке. Право проверяется до вызова selections (INV-SNP-003 — selections доверяет вызывателю news).
news синхронно вызывает POST /api/v1/internal/selections/{id}/snapshots с телом { postId, requestedByUserId } (m2m, internal-create-selection-snapshot.md). Снятие идемпотентно по паре (PostId, SourceSelectionId, Status = Active): retry возвращает существующий Active (200 вместо 201). Ответ — { SnapshotId, SourceSelectionId, SourceSelectionPublicCode, Title, ItemsCount, TakenAt }.
news сохраняет в typed-jsonb блоке SelectionEmbedBlock (дом — post-block.md): SnapshotId, echo SourceSelectionId, кэш SourceSelectionPublicCode (12 base62), Title (≤256), ItemsCount (1..200). Связь PostId ↔ SnapshotId и SearchText пересчитываются в одном согласованном изменении (PostContentChangedEvent, INV-NWS-008; для Published-поста — upsert Elasticsearch-проекции); detach снятых блоков — после фиксации изменения (A1). Пост позже публикуется отдельной операцией POST /posts/{id}/publish.
- При рендере поста
news читает замороженный состав GET /api/v1/internal/snapshots/{SnapshotId} (internal-get-selection-snapshot.md) → Items[PropertyObjectId, SortOrder, Comment]; display-данные объектов недвижимости (цена, статус, фото) фронт/рендер добирает батчем в catalog по списку PropertyObjectId. Цены/статусы в snapshot не заморожены — показываются актуальные (заморожены только состав, порядок и комментарии).
- Пост даёт ссылку «открыть живую подборку» из
SourceSelectionPublicCode → переход открывает актуальную версию подборки по коду (UC-SEL-003).
Альтернативные Потоки
A1. Замена блока-подборки целиком (INV-SEL-001)
- Автор выбирает другую/обновлённую подборку для блока.
news снимает новый snapshot (шаги 3–4), переключает SnapshotId в блоке (новая версия содержимого), затем — после фиксации изменения — вызывает POST /api/v1/internal/snapshots/{oldId}/detach { reason: BlockReplaced } (internal-detach-selection-snapshot.md): старый snapshot Active → Detached.
- Порядок «создать новый → переключить блок → отвязать старый» гарантирует, что на каждом шаге блок ссылается на валидный
Active-snapshot; осиротевший Active-дубль при сбое между шагами убирает job snapshot-detach-retry (идемпотентно, INV-SNP-005). Завершается.
A2. Удаление поста (DELETE /posts/{id}, delete-post.md)
news при soft-delete поста (→ Archived) вызывает detach { reason: PostDeleted } для всех Active-snapshot-ов блоков поста.
- Snapshot-ы переходят в
Detached, но не удаляются никогда (INV-SNP-006) — история версий поста (PostVersion) продолжает на них ссылаться. Завершается.
A3. Подборка удалена / пуста
- Подборка-источник soft-deleted →
selections возвращает SELECTIONS_SOURCE_DELETED, news пробрасывает NEWS_SELECTION_SOURCE_DELETED (400); пустая подборка → NEWS_SELECTION_SOURCE_EMPTY (400).
- Snapshot не создаётся, блок не добавляется;
news предлагает выбрать другую или наполнить подборку. Завершается.
A4. Владелец правит живую подборку после публикации
- Владелец меняет состав/порядок/комментарии/название живой подборки (или удаляет её). Блок поста не меняется (INV-SEL-001) — у
SelectionSnapshot нет мутатора Content; пост показывает состав на момент TakenAt.
- Ссылка «открыть живую подборку» (
SourceSelectionPublicCode) ведёт на актуальную версию — расхождение «замороженный блок ↔ живая подборка» ожидаемо и является целью INV-SEL-001. Завершается.
A5. catalog недоступен при рендере блока-подборки
- При рендере (шаг 5) фронт/рендер добирает актуальные цену/статус/фото объектов недвижимости батчем в
catalog по PropertyObjectId. Отказ/таймаут catalog не ломает пост: блок деградирует до замороженного состава из snapshot-а (Items[PropertyObjectId, SortOrder, Comment], кэш Title/ItemsCount) без живых display-данных; повтор батча — при следующем рендере.
- Иммутабельная часть блока (состав/порядок/комментарии из snapshot-а) доступна независимо от
catalog — от catalog берутся только незамороженные display-поля. Это деградация чтения, не бизнес-ошибка (мутаций и событий нет). Завершается.
Постусловие
- Блок
SelectionEmbed ссылается на Active-snapshot; в typed-jsonb хранятся SnapshotId + кэш (SourceSelectionPublicCode/Title/ItemsCount), display-данные объектов недвижимости не заморожены.
- Содержимое блока иммутабельно: владелец подборки не может изменить состав опубликованного поста; единственная «правка» автором поста — замена блока целиком (A1: новый snapshot +
Detach(BlockReplaced)).
- Исторические
PostVersion рендерят исторический состав: каждая версия ссылается на свой SnapshotId, а snapshot-ы иммутабельны и не удаляются (INV-SNP-006) — post-version.md.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
NEWS_SELECTION_SOURCE_DELETED | 400 | Подборка-источник soft-deleted (A3, INV-SNP-002) | «Подборка недоступна» — не ретраить |
NEWS_SELECTION_SOURCE_EMPTY | 400 | Пустая подборка (A3, INV-SNP-004) | «В подборке нет объектов» — не ретраить |
NEWS_SNAPSHOT_INVALID | 422 | SnapshotId живого блока не существует / не Active (INV-NWS-B05) | «Некорректная встроенная подборка» |
NEWS_SELECTIONS_UNAVAILABLE | 503 | selections недоступен/timeout при create/detach | «Сервис подборок временно недоступен» — retry (идемпотентно) |
NEWS_POST_NOT_FOUND | 404 | Пост не найден | «Пост не найден» |
NEWS_POST_ARCHIVED | 409 | Status = Archived | «Пост в архиве» |
403 | 403 | Актор не автор и не менеджер в scope (INV-NWS-007) | Скрыть действие |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
PostContentChangedEvent | Set-blocks с новым/заменённым SelectionEmbed | Триггер пересчёта SearchText в том же изменении (INV-NWS-008); для Published-поста — upsert Elasticsearch-проекции поиска |
Со стороны selections (m2m) — внутренние SelectionSnapshotCreatedEvent / SelectionSnapshotDetachedEvent
(selection-snapshot.md); наружу не публикуются.
Kafka Интеграционные События
Встраивание/замена блока Kafka-событий не порождает: интеграция с selections синхронная (решение №1,
README). Публикация поста в ленту — отдельная операция POST /posts/{id}/publish
(PostPublishedKafkaEvent, topic pin.news.post-published.v1, ContractVersion = 1, key = PostId —
publish-post.md).
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Снятие идемпотентно по паре (PostId, SourceSelectionId, Active): повтор при retry возвращает тот же SnapshotId, второй Active не создаётся | интеграционный тест retry |
| AC2 | Содержимое блока иммутабельно: правка живой подборки владельцем не меняет блок опубликованного поста (INV-SEL-001, A4) | сквозной тест A4 + тест иммутабельности |
| AC3 | Исторические PostVersion рендерят исторический состав: старая версия читает свой SnapshotId (Detached после замены), состав/порядок/комментарии соответствуют моменту снятия | сквозной тест версии после замены блока |
| AC4 | Замена блока = новый snapshot + Detach(BlockReplaced) старого; порядок «создать → переключить → отвязать» держит валидный Active на каждом шаге (A1) | сквозной тест замены блока |
| AC5 | Пост показывает актуальные цену/статус объекта недвижимости из catalog при неизменном составе/порядке/комментариях snapshot-а | контрактный тест (заморожен состав, не цены) |
| AC6 | Удалённая (NEWS_SELECTION_SOURCE_DELETED) или пустая (NEWS_SELECTION_SOURCE_EMPTY) подборка в пост не встраивается | негативные тесты A3 |
| AC7 | Удаление поста переводит snapshot-ы в Detached (не удаляет, INV-SNP-006); GET /internal/snapshots/{id} читает Content и после отвязки | тест долговечности (A2) |
| AC8 | Отказ catalog при рендере (A5) деградирует блок до замороженного состава/порядка/комментариев без живых цены/статуса — пост открывается, ошибка не возвращается | тест деградации рендера при недоступном catalog |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- API метод (алгоритм set-blocks):
set-post-blocks.md;
соседние — publish-post.md, delete-post.md,
чтение/рендер — get-post.md.
- Domain entity:
post.md (агрегат, INV-NWS-), post-block.md
(typed-jsonb SelectionEmbedBlock, INV-NWS-B), post-version.md (история).
- Дом иммутабельной копии:
selection-snapshot.md
(INV-SEL-001, INV-SNP-002..006); зеркальный UC — UC-SEL-004;
открытие живой подборки — UC-SEL-003.
- Internal API
selections (m2m): create,
detach,
get.
- Display-данные объектов недвижимости при рендере —
catalog/README.md; permission-каталог —
identity/domain/permission.md; границы/решения — README.
- ADR: ADR-0052 (tenancy),
ADR-0056 (simplicity-first),
ADR-0059 (Elasticsearch-проекция поиска).