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

UC-NWS-001. Создать черновик и опубликовать пост

Назначение

Сквозной сценарий авторинга ленты новостей: автор создаёт черновик поста, наполняет его типизированными блоками (текст / галерея / embed-подборка) и публикует пост в общую и подписную ленты. Каждая публикация фиксирует полный иммутабельный snapshot поста отдельной версией PostVersion (полный слепок поста, а не diff) и порождает интеграционное событие post-published для рассылки подписчикам. Связан с контекстом news; алгоритмы отдельных операций живут в api-документах области authoring (ссылки в шагах).

Metadata

ПолеЗначение
IDUC-NWS-001
Контекстnews (см. docs/06-services/news/); интеграции — selections (embed-snapshot, m2m), notifications/user-activity (событие)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольАвторInvestor / Agent / AgencyManager / DeveloperManager / PinEmployee (PostAuthorType, дом — ../domain/post-category.md)
Permissions / scopenews.posts.publish (eligibility публикации, INV-NWS-002); каталог разбивает по scope — news.posts.create-personal (self: Investor/Agent/PinEmployee), create-company (AgencyManager/DeveloperManager, scope AuthorCompanyId), create-project (DeveloperManager + ProjectRefs), manage-ownidentity/domain/permission.md
TenantTenantId = AuthorCompanyId; null для Investor/Agent/PinEmployee (ADR-0052 п.4)
Record-levelмутации — только AuthorUserId, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manage (INV-NWS-007)

Предусловие

  • Автор аутентифицирован; AuthorType = snapshot типа пользователя на момент создания.
  • Целевая категория существует и Status = Active; AuthorType ∈ Category.AllowedAuthorTypes (INV-NWS-002 / INV-CAT-002, ../domain/post-category.md).
  • Для embed-блоков доступен selections (иначе добавление блока-подборки блокируется — ../README.md, «Зависимости»).

Триггер

ПолеЗначение
ИсточникUI (редактор поста news) → API calls
Триггерящий элементКнопка «Создать пост» → редактор блоков → «Опубликовать» (area authoring)

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

  1. Создать черновик. POST /api/v1/posts с телом { name, suffix, title, subtitle?, categoryId, authorCompanyId?/projectRefs? } (последние два — только для застройщика). Система проверяет eligibility по типу автора (INV-NWS-002), уникальность suffix (kebab-case, INV-NWS-001) и attribution (INV-NWS-003) и создаёт Post в Status = Draft (CurrentVersionNumber = 0, пустой Content). Алгоритм — ../api/authoring/create-post-draft.md. Ответ — { PostId, Suffix, Status = Draft }.
  2. Наполнить блоки. PUT /api/v1/posts/{id}/blocks заменяет весь PostContentDocument (typed-jsonb): блоки Text / ImageGallery / SelectionEmbed. Для каждого нового embed-блока news синхронно снимает иммутабельную копию подборки в selections (m2m) и сохраняет SnapshotId в блоке (UC-SEL-004); пересчитывается SearchText — источник Elasticsearch-проекции поиска (INV-NWS-008). Алгоритм — ../api/authoring/set-post-blocks.md.
  3. Публикация. POST /api/v1/posts/{id}/publish. Проверка INV-NWS-004: непустой Title>= 1 блок ∧ все SelectionEmbed-блоки ссылаются на Active-snapshot. В одном согласованном изменении: переход Draft → Published; append-only PostVersion (VersionNumber = 1, Reason = Published) с полным слепком PostSnapshotDocument (INV-NWS-005 / INV-PV-002/003); Status = Published, PublishedAt = FirstPublishedAt = now; порождается PostPublishedKafkaEvent. Алгоритм — ../api/authoring/publish-post.md. Событие pin.news.post-published.v1 доставляется в notifications (подписчики автора) и user-activity (таймлайн автора, опц., ADR-0054).

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

A1. Повторная публикация после правок (republish)

  1. Опубликованный пост правится (PUT /posts/{id}/blocks или метаданные) — остаётся Published; версий правки черновика не создают (INV-PV-004).
  2. Повторный POST /posts/{id}/publish: переход Published/Unpublished → Published, пишется новый PostVersion (VersionNumber = CurrentVersionNumber + 1, Reason = Republished) полным snapshot-ом; PublishedAt = now, FirstPublishedAt не меняется; порождается новое post-published. Возврат к основному потоку на шаг 3.

A2. Embed-snapshot недоступен при публикации

  1. На шаге 3 SelectionEmbed-блок ссылается на не-Active/отсутствующий snapshot (проверка статуса в selections, INV-NWS-004) → ошибка NEWS_SNAPSHOT_INVALID (422); пост остаётся Draft.
  2. Клиент предлагает автору обновить блок-подборку (замена целиком — новый snapshot, INV-SEL-001). Если недоступен сам сервис selectionsNEWS_SELECTIONS_UNAVAILABLE (503, retry). Публикация повторяется после исправления (возврат на шаг 2–3).

A3. Категория не разрешена типу автора

  1. На шаге 1 (создание) или шаге 3 (публикация) AuthorType ∉ Category.AllowedAuthorTypes ∨ нет news.posts.publish (INV-NWS-002 / INV-CAT-002) → ошибка NEWS_CATEGORY_NOT_ALLOWED (403).
  2. Клиент показывает eligibility-фильтр категорий под тип автора (GET /api/v1/categories); автор выбирает разрешённую категорию. Возврат к основному потоку на шаг 1.

A4. Конкурентная публикация / republish (version-guard)

  1. Два параллельных POST /posts/{id}/publish (либо publish во время правки блоков) претендуют на один пост. VersionNumber монотонен в пределах поста и присваивается в согласованном изменении — две версии с одинаковым номером возникнуть не могут: побеждает первое зафиксированное изменение.
  2. Проигравший видит конфликт версий (CONCURRENCY_CONFLICT, 409) и повторяет операцию поверх уже обновлённого состояния — при повторе пишется следующая версия (Reason = Republished, VersionNumber + 1) и переиздаётся post-published. «Потерянных» публикаций и дублей PostVersion с одним номером не возникает. Возврат к основному потоку на шаг 3.

Постусловие

  • Пост в Status = Published: попадает в ленты global и subscriptions и в Elasticsearch-проекцию поиска news-posts (upsert по PostVersionCapturedEvent, INV-NWS-008, search/posts.md).
  • Зафиксирована append-only версия PostVersion (VersionNumber = 1, Reason = Published) с полным иммутабельным слепком поста; embed-блоки заморожены по SnapshotId (цепочка иммутабельности).
  • PublishedAt/FirstPublishedAt заданы; событие pin.news.post-published.v1 порождено (доставка подписчикам — notifications).
  • Видимость по каналам различается по свежести: в лентах global/subscription (PostgreSQL-keyset) и по прямой ссылке пост виден сразу после коммита; в поиске news-posts — после применения upsert проекции (eventual, секунды); подписчики уведомляются асинхронно (notifications, < 1 мин, at-least-once). Republish переиздаёт эти проекции с новым VersionNumber.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400формат Name/Suffix/Title, границы блоков, дубли BlockIdисправить поля — не ретраить
NEWS_SUFFIX_TAKEN409Suffix занят (INV-NWS-001, шаг 1)предложить другой суффикс
NEWS_CATEGORY_NOT_FOUND404категория не найдена / Archived (шаг 1)выбрать активную категорию
NEWS_CATEGORY_NOT_ALLOWED403AuthorType ∉ AllowedAuthorTypes ∨ нет news.posts.publish (A3, INV-NWS-002)показать eligibility-фильтр категорий
NEWS_ATTRIBUTION_NOT_ALLOWED403AuthorCompanyId/ProjectRefs не по типу автора/scope (INV-NWS-003)убрать недопустимую привязку
NEWS_POST_EMPTY422пустой Title0 блоков при публикации (INV-NWS-004)заполнить заголовок и добавить блок
NEWS_SNAPSHOT_INVALID422embed-блок ссылается на не-Active/отсутствующий snapshot (A2, INV-NWS-004)обновить блок-подборку
NEWS_SELECTIONS_UNAVAILABLE503selections недоступен при снятии/проверке snapshot (A2)повторить позже (retry)
403 (record-level)403не автор / не менеджер scope / нет news.posts.manage (INV-NWS-007)скрыть действие
NEWS_POST_NOT_FOUND404пост не найден / Archived (шаг 2–3)обновить список
CONCURRENCY_CONFLICT409конкурентная публикация/republish одного поста (A4, version-guard)повторить публикацию поверх обновлённого состояния

Доменные События

СобытиеКогда поднимаетсяSubscribers
PostContentChangedEventcreate (шаг 1) / set-blocks (шаг 2)внутренний пересчёт SearchText в том же изменении (INV-NWS-008); для Published-поста — upsert Elasticsearch-проекции поиска
PostVersionCapturedEventзахват версии при → Published (шаг 3 / A1)внутренние подписчики (метрики/аудит истории версий)

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
PostPublishedKafkaEventpin.news.post-published.v1Publish/Republish (→ Published), атомарно с переходом; ContractVersion = 1, key = PostIdnotifications (подписчики автора); user-activity (таймлайн автора, опц.)

Payload: { PostId, AuthorUserId, AuthorType, AuthorCompanyId, CategoryId, CategorySlug, Suffix, Title, Subtitle, CoverFileId, ProjectRefs, PublishedAt, VersionNumber, IsFirstPublish } (полный контракт, типы и семантика полей — дом ../events/post-published.md; ФИО автора в payload не входит — берётся потребителем из реплики identity). Черновик и set-blocks событий не порождают.

Acceptance Criteria

#КритерийПроверка
AC1Eligibility соблюдается: автор публикует только в категорию своего AuthorType (INV-NWS-002); иначе NEWS_CATEGORY_NOT_ALLOWEDнегативный тест A3 по каждому типу автора
AC2Каждая публикация пишет полный PostVersion-snapshot (не diff), атомарно с переходом (INV-NWS-005 / INV-PV-003)интеграционный тест: слепок содержит все поля поста; правка Draft версий не создаёт
AC3Событие post-published идемпотентно: доставка at-least-once, консюмер дедуплицирует по (PostId, VersionNumber) — повторная доставка не даёт дублей уведомленийтест повторной доставки
AC4Republish (A1) создаёт новый PostVersion (Reason = Republished, VersionNumber++); FirstPublishedAt неизмененсквозной тест правка → republish
AC5Suffix уникален среди неудалённых (INV-NWS-001) — гонку снимает ux_posts_suffixконкурентный тест create
AC6Публикация блокируется при пустом Title / 0 блоков / не-Active-snapshot (INV-NWS-004) → NEWS_POST_EMPTY / NEWS_SNAPSHOT_INVALIDнегативные тесты шага 3
AC7Конкурентная публикация (A4): два параллельных publish не создают две версии с одним VersionNumber; проигравший получает CONCURRENCY_CONFLICT, при повторе пишет следующий номерконкурентный тест publish/republish

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

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

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