UC-SEL-004. Автор поста вставляет подборку в пост (иммутабельный snapshot блока)
Назначение
Описывает сценарий, в котором автор поста news встраивает подборку в блок поста: news (проверив
авторство поста и право пользователя) синхронно вызывает internal API selections, тот снимает
иммутабельную копию подборки — SelectionSnapshot — и возвращает SnapshotId для блока.
Для поста снимается своя копия подборки, чтобы после публикации создатель подборки не мог её изменить,
но при необходимости мог заменить блок целиком (инвариант INV-SEL-001).
После публикации владелец подборки правит живую подборку сколько угодно — блок поста не меняется;
единственная «правка» блока автором поста — замена целиком (новый snapshot + detach старого).
Дом полей и алгоритма — ../domain/selection-snapshot.md. Связан с
контекстом selections; интеграция — news (владелец поста/блока) и catalog (display-данные объектов недвижимости).
| Поле | Значение |
|---|
| ID | UC-SEL-004 |
| Контекст | selections (см. docs/06-services/selections/); интеграция — news, catalog |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Первичный — Автор поста (инвестор или агент) через фронт news. Вторичный (технический) — m2m-сервис news → internal API selections (ExternalSystem, client-credentials) |
| Permissions / scope | Право автора вставить подборку в свой пост проверяет news до вызова (INV-SNP-003; assumption — docs/06-services/news/domain/post.md); в selections пользовательского пути к операции нет — только authorization policy InternalNewsOnly для internal-области |
| Tenant | не tenanted (ADR-0052 п.3 — контент публикуемого поста — платформенные данные) |
| Record-level | не применяется: news — доверенный вызыватель; RequestedByUserId (автор поста) — аудит-параметр, не авторизация |
Предусловие
- У автора есть активная непустая подборка-источник (
DeletedAt == null, 1..200 элементов —
INV-SNP-002, INV-SNP-004); подборка может быть чужой публичной, если бизнес-правило news это
разрешает — проверку прав ведёт news.
- Авторство поста и право пользователя вставить подборку проверены на стороне
news (INV-SNP-003).
selections доступен (иначе публикация блока-подборки блокируется на стороне news —
../README.md, «Зависимости»).
Триггер
| Поле | Значение |
|---|
| Источник | UI (редактор поста news) → m2m internal API call |
| Триггерящий элемент | Добавление блока-подборки в редакторе поста news (выбор selectionId в блок) |
Основной Поток
- Автор в редакторе поста
news выбирает подборку (selectionId) в блок-подборку поста.
news проверяет авторство поста и право пользователя на подборку (INV-SNP-003; зона news).
news синхронно вызывает POST /api/v1/internal/selections/{id}/snapshots с телом
{ postId, requestedByUserId } (m2m, policy InternalNewsOnly). Контракт и канонический
алгоритм снятия — ../api/internal/create-selection-snapshot.md
и ../domain/selection-snapshot.md (здесь не переописываются).
selections снимает SelectionSnapshot: Status = Active, OwnerUserId — владелец подборки-источника,
Content (typed jsonb SelectionSnapshotContent, ContractVersion = 1, замороженный):
Title / Description / SourceSelectionPublicCode — копии полей Selection;
Items — элементы подборки в порядке SortOrder (PropertyObjectId, SortOrder, Comment); TakenAt = now.
Снятие идемпотентно по паре (PostId, SourceSelectionId, Status = Active): retry news
возвращает существующий Active (200 вместо 201). В ответе — SnapshotId, SourceSelectionId,
PostId, ItemsCount, TakenAt.
news сохраняет SnapshotId в typed-jsonb блоке поста (связь PostId ↔ SnapshotId и история
версий поста — зона news); пост публикуется.
- При рендере поста
news читает GET /api/v1/internal/snapshots/{id}
(../api/internal/get-selection-snapshot.md) →
замороженный Content; display-данные объектов недвижимости (цена, фото, статус, название) фронт/рендер
добирает батчем в catalog по списку PropertyObjectId. Цены/статусы в snapshot не заморожены —
показываются актуальные (осознанное решение: пост не вводит читателя в заблуждение старой ценой;
заморожены только состав, порядок и комментарии — ../README.md).
- Пост даёт ссылку «открыть живую подборку» из
SourceSelectionPublicCode — переход открывает
актуальную версию подборки по коду (UC-SEL-003).
Альтернативные Потоки
A1. Замена блока-подборки целиком (INV-SEL-001)
- Автор в редакторе выбирает другую/обновлённую подборку для блока.
news снимает новый
snapshot (шаг 3–4), переключает SnapshotId в блоке (новая версия поста), затем вызывает
POST /api/v1/internal/snapshots/{oldId}/detach { reason: BlockReplaced, requestedByUserId }
(../api/internal/detach-selection-snapshot.md):
старый snapshot переходит Active → Detached.
- Порядок «создать новый → переключить блок → отвязать старый» гарантирует: на каждом шаге пост
ссылается на валидный Active-snapshot; при сбое между шагами осиротевший Active-дубль убирает
повторный detach (идемпотентно, INV-SNP-005). Завершается.
A2. Удаление поста в news
news в saga удаления поста вызывает detach { reason: PostDeleted } для snapshot-ов поста.
- Snapshot переходит в
Detached, но не удаляется никогда (INV-SNP-006) — история версий постов
news продолжает на него ссылаться. Завершается.
A3. Подборка-источник soft-deleted до снятия
selection.DeletedAt != null (владелец удалил подборку до вставки) → guard INV-SNP-002 →
SELECTIONS_SOURCE_DELETED (HTTP 400). Snapshot не создаётся.
news показывает автору «подборка удалена владельцем», блок не добавляется. Завершается.
A4. Пустая подборка
- В подборке 0 элементов → guard INV-SNP-004 →
SELECTIONS_SNAPSHOT_EMPTY (HTTP 400).
news предлагает наполнить подборку или выбрать другую. Пустая подборка в пост не вставляется.
Завершается.
A5. Владелец правит живую подборку-источник после публикации
- Владелец меняет состав/порядок/комментарии/название живой подборки (или удаляет её). Snapshot
не меняется (INV-SEL-001) — у агрегата нет мутатора
Content; пост показывает состав, порядок
и комментарии на момент снятия (TakenAt).
- Ссылка «открыть живую подборку» (
SourceSelectionPublicCode) ведёт на актуальную версию —
расхождение «замороженный блок ↔ живая подборка» ожидаемо и является целью INV-SEL-001. Завершается.
A6. Гонка: снятие snapshot и soft delete подборки-источника
- Владелец удаляет подборку одновременно с вызовом снятия snapshot от
news. Guard INV-SNP-002
читает DeletedAt: если удаление зафиксировалось раньше снятия — SELECTIONS_SOURCE_DELETED
(как A3), snapshot не создаётся.
- Если snapshot снят раньше фиксации удаления — копия уже иммутабельна и остаётся валидной;
последующий soft delete источника её не трогает (FK
restrict, INV-SNP-006), а SourceSelectionPublicCode
в контенте перестанет открывать живую подборку (её публичное чтение вернёт 404, INV-SEL-009) —
замороженный блок при этом читается штатно. Завершается.
Постусловие
- Создан append-only
SelectionSnapshot (Status = Active, Content иммутабелен — INV-SEL-001);
news хранит SnapshotId в блоке поста.
- Владелец подборки не может изменить содержимое блока опубликованного поста; единственная
«правка» — замена блока целиком автором поста (A1: новый snapshot +
Detach(BlockReplaced)).
- Замена блока / удаление поста переводят прежний snapshot в
Detached (append-only, хранится вечно —
INV-SNP-006); Content остаётся читаемым в обоих статусах.
- Display-данные объектов недвижимости в посте — всегда актуальные из
catalog (не заморожены); заморожены состав,
порядок и комментарии.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента (news) |
|---|
VALIDATION_ERROR | 400 | Пустые Guid-поля (PostId/RequestedByUserId) | Некорректный запрос — не ретраить |
SELECTIONS_NOT_FOUND | 404 | SourceSelectionId не существует | «Подборка не найдена» |
SELECTIONS_SOURCE_DELETED | 400 | Подборка soft-deleted (A3, INV-SNP-002) | «Подборка удалена владельцем» |
SELECTIONS_SNAPSHOT_EMPTY | 400 | Подборка пуста (A4, INV-SNP-004) | «Пустую подборку нельзя добавить в пост» |
SELECTIONS_SNAPSHOT_NOT_FOUND | 404 | detach/get по несуществующему SnapshotId | «Snapshot не найден» |
FORBIDDEN | 403 | Вызыватель не m2m news (INV-SNP-003) | Ошибка конфигурации m2m |
5xx | 500/503 | PostgreSQL/инфраструктура | Retry (идемпотентно по паре / no-op detach); публикация блока блокируется |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
SelectionSnapshotCreatedEvent (SnapshotId, SourceSelectionId, PostId, ItemsCount) | Успешное снятие snapshot-а ([*] → Active) | Внутренние (метрика снятия snapshot-ов) |
SelectionSnapshotDetachedEvent (SnapshotId, DetachReason) | Переход Active → Detached (A1/A2) | Внутренние (метрика отвязок по причине) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
| — (сценарий не публикует) | — | — | — |
Интеграционные Kafka-события не публикуются: единственный потребитель news взаимодействует
синхронно (получает SnapshotId в ответе и владеет своей стороной связи PostId ↔ SnapshotId).
Событие pin.selections.snapshot-created.v1 — осознанно отклонено (../events/README.md,
раздел «Отклонённые события»); появится additive при втором потребителе snapshot-ов.
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | Снятие идемпотентно по паре (PostId, SourceSelectionId, Active): повторный вызов возвращает тот же SnapshotId (200), второй Active не создаётся | интеграционный тест retry |
| AC2 | Content иммутабелен в статусах Active и Detached: у агрегата нет мутатора контента; правка живой подборки не меняет slot (INV-SEL-001) | тест иммутабельности + сквозной тест A5 |
| AC3 | Замена блока = новый snapshot + Detach(BlockReplaced) старого; порядок «создать → переключить → отвязать» держит валидный Active на каждом шаге (A1) | сквозной тест замены блока |
| AC4 | Snapshot из удалённой (SELECTIONS_SOURCE_DELETED) или пустой (SELECTIONS_SNAPSHOT_EMPTY) подборки снять нельзя | негативные тесты A3/A4 |
| AC5 | Пост показывает актуальные цену/статус объекта недвижимости из catalog при неизменном составе/порядке/комментариях snapshot-а | контрактный тест (заморожен состав, не цены) |
| AC6 | Пост даёт рабочую ссылку «открыть живую подборку» из SourceSelectionPublicCode → UC-SEL-003 | сквозной тест перехода |
| AC7 | Detached-snapshot не удаляется (INV-SNP-006); GET /internal/snapshots/{id} читает его Content и после отвязки | тест долговечности |
| AC8 | Гонка «снятие ↔ soft delete источника»: снятый до удаления snapshot остаётся читаемым; снятие после удаления → SELECTIONS_SOURCE_DELETED (A6) | конкурентный тест |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Дом snapshot-а (поля,
Content v1, INV-SEL-001, INV-SNP-002..006, stateDiagram, алгоритм):
../domain/selection-snapshot.md; источник —
../domain/selection.md.
- Internal API (контракты + канонический алгоритм):
../api/internal/create-selection-snapshot.md,
../api/internal/detach-selection-snapshot.md,
../api/internal/get-selection-snapshot.md.
- Открытие живой подборки по коду: UC-SEL-003; шаринг —
UC-SEL-002.
- Отклонённое событие
snapshot-created: ../events/README.md.
- Границы/потоки: ../README.md, service-boundaries.md (поток «Публикация поста»);
ADR-0052, ADR-0056.
- Display-данные объектов недвижимости:
docs/06-services/catalog/README.md (будущий путь); блок поста —
docs/06-services/news/domain/post.md (будущий путь, SnapshotId в typed-jsonb блоке).