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

UC-SEL-004. Автор поста вставляет подборку в пост (иммутабельный snapshot блока)

Назначение

Описывает сценарий, в котором автор поста news встраивает подборку в блок поста: news (проверив авторство поста и право пользователя) синхронно вызывает internal API selections, тот снимает иммутабельную копию подборки — SelectionSnapshot — и возвращает SnapshotId для блока. Для поста снимается своя копия подборки, чтобы после публикации создатель подборки не мог её изменить, но при необходимости мог заменить блок целиком (инвариант INV-SEL-001). После публикации владелец подборки правит живую подборку сколько угодно — блок поста не меняется; единственная «правка» блока автором поста — замена целиком (новый snapshot + detach старого). Дом полей и алгоритма — ../domain/selection-snapshot.md. Связан с контекстом selections; интеграция — news (владелец поста/блока) и catalog (display-данные объектов недвижимости).

Metadata

ПолеЗначение
IDUC-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 в блок)

Основной Поток

  1. Автор в редакторе поста news выбирает подборку (selectionId) в блок-подборку поста.
  2. news проверяет авторство поста и право пользователя на подборку (INV-SNP-003; зона news).
  3. news синхронно вызывает POST /api/v1/internal/selections/{id}/snapshots с телом { postId, requestedByUserId } (m2m, policy InternalNewsOnly). Контракт и канонический алгоритм снятия../api/internal/create-selection-snapshot.md и ../domain/selection-snapshot.md (здесь не переописываются).
  4. 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.
  5. news сохраняет SnapshotId в typed-jsonb блоке поста (связь PostId ↔ SnapshotId и история версий поста — зона news); пост публикуется.
  6. При рендере поста news читает GET /api/v1/internal/snapshots/{id} (../api/internal/get-selection-snapshot.md) → замороженный Content; display-данные объектов недвижимости (цена, фото, статус, название) фронт/рендер добирает батчем в catalog по списку PropertyObjectId. Цены/статусы в snapshot не заморожены — показываются актуальные (осознанное решение: пост не вводит читателя в заблуждение старой ценой; заморожены только состав, порядок и комментарии — ../README.md).
  7. Пост даёт ссылку «открыть живую подборку» из SourceSelectionPublicCode — переход открывает актуальную версию подборки по коду (UC-SEL-003).

Альтернативные Потоки

A1. Замена блока-подборки целиком (INV-SEL-001)

  1. Автор в редакторе выбирает другую/обновлённую подборку для блока. news снимает новый snapshot (шаг 3–4), переключает SnapshotId в блоке (новая версия поста), затем вызывает POST /api/v1/internal/snapshots/{oldId}/detach { reason: BlockReplaced, requestedByUserId } (../api/internal/detach-selection-snapshot.md): старый snapshot переходит Active → Detached.
  2. Порядок «создать новый → переключить блок → отвязать старый» гарантирует: на каждом шаге пост ссылается на валидный Active-snapshot; при сбое между шагами осиротевший Active-дубль убирает повторный detach (идемпотентно, INV-SNP-005). Завершается.

A2. Удаление поста в news

  1. news в saga удаления поста вызывает detach { reason: PostDeleted } для snapshot-ов поста.
  2. Snapshot переходит в Detached, но не удаляется никогда (INV-SNP-006) — история версий постов news продолжает на него ссылаться. Завершается.

A3. Подборка-источник soft-deleted до снятия

  1. selection.DeletedAt != null (владелец удалил подборку до вставки) → guard INV-SNP-002 → SELECTIONS_SOURCE_DELETED (HTTP 400). Snapshot не создаётся.
  2. news показывает автору «подборка удалена владельцем», блок не добавляется. Завершается.

A4. Пустая подборка

  1. В подборке 0 элементов → guard INV-SNP-004 → SELECTIONS_SNAPSHOT_EMPTY (HTTP 400).
  2. news предлагает наполнить подборку или выбрать другую. Пустая подборка в пост не вставляется. Завершается.

A5. Владелец правит живую подборку-источник после публикации

  1. Владелец меняет состав/порядок/комментарии/название живой подборки (или удаляет её). Snapshot не меняется (INV-SEL-001) — у агрегата нет мутатора Content; пост показывает состав, порядок и комментарии на момент снятия (TakenAt).
  2. Ссылка «открыть живую подборку» (SourceSelectionPublicCode) ведёт на актуальную версию — расхождение «замороженный блок ↔ живая подборка» ожидаемо и является целью INV-SEL-001. Завершается.

A6. Гонка: снятие snapshot и soft delete подборки-источника

  1. Владелец удаляет подборку одновременно с вызовом снятия snapshot от news. Guard INV-SNP-002 читает DeletedAt: если удаление зафиксировалось раньше снятия — SELECTIONS_SOURCE_DELETED (как A3), snapshot не создаётся.
  2. Если 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_ERROR400Пустые Guid-поля (PostId/RequestedByUserId)Некорректный запрос — не ретраить
SELECTIONS_NOT_FOUND404SourceSelectionId не существует«Подборка не найдена»
SELECTIONS_SOURCE_DELETED400Подборка soft-deleted (A3, INV-SNP-002)«Подборка удалена владельцем»
SELECTIONS_SNAPSHOT_EMPTY400Подборка пуста (A4, INV-SNP-004)«Пустую подборку нельзя добавить в пост»
SELECTIONS_SNAPSHOT_NOT_FOUND404detach/get по несуществующему SnapshotId«Snapshot не найден»
FORBIDDEN403Вызыватель не m2m news (INV-SNP-003)Ошибка конфигурации m2m
5xx500/503PostgreSQL/инфраструктура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
AC2Content иммутабелен в статусах Active и Detached: у агрегата нет мутатора контента; правка живой подборки не меняет slot (INV-SEL-001)тест иммутабельности + сквозной тест A5
AC3Замена блока = новый snapshot + Detach(BlockReplaced) старого; порядок «создать → переключить → отвязать» держит валидный Active на каждом шаге (A1)сквозной тест замены блока
AC4Snapshot из удалённой (SELECTIONS_SOURCE_DELETED) или пустой (SELECTIONS_SNAPSHOT_EMPTY) подборки снять нельзянегативные тесты A3/A4
AC5Пост показывает актуальные цену/статус объекта недвижимости из catalog при неизменном составе/порядке/комментариях snapshot-аконтрактный тест (заморожен состав, не цены)
AC6Пост даёт рабочую ссылку «открыть живую подборку» из SourceSelectionPublicCode → UC-SEL-003сквозной тест перехода
AC7Detached-snapshot не удаляется (INV-SNP-006); GET /internal/snapshots/{id} читает его Content и после отвязкитест долговечности
AC8Гонка «снятие ↔ soft delete источника»: снятый до удаления snapshot остаётся читаемым; снятие после удаления → SELECTIONS_SOURCE_DELETED (A6)конкурентный тест

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

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

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