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

POST /api/v1/posts/{id}/unpublish — снять пост с публикации

Назначение

Автор (или менеджер его компании) переводит опубликованный пост из Published в Unpublished: пост исчезает из лент global/subscriptions и из поиска, но остаётся в БД как редактируемый черновик с сохранённой историей публикаций (post-version.md). Снятие — не публикация: новый PostVersion не пишется, CurrentVersionNumber не меняется, embed-snapshot-ы блоков НЕ отвязываются (selections не вызывается) — контент сохраняется целиком и переиспользуется при повторной публикации (publish-post.md).

Metadata

ПолеЗначение
Сервис/контекстnews
API areaauthoring (self + company scope)
Feature groupauthoring
Статусdraft
Документdocs/06-services/news/api/authoring/unpublish-post.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (автор поста; guest/аноним — 401)
Auth policy / PermissionsJWT identity; own-scope news.posts.manage-own, override — news.posts.manage (модерация/менеджер компании)
Scope (RBAC)self (AuthorUserId) + company (AuthorCompanyId) для менеджеров застройщика/агентства
Record-levelINV-NWS-007: AuthorUserId == actor.UserId, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manage; иначе 403
Tenant isolationTenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052); основной гейт мутации — record-level
Auditupdate: actor-stamping UpdatedByUserId; отдельной строки истории нет — снятие не пишет PostVersion (INV-NWS-005 — только публикация)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/posts/{id}/unpublish
Success status200
Idempotency headerне применяется — повторное снятие уже-Unpublished даёт 409 NEWS_INVALID_STATE (не toggle)
Correlationсквозной trace через traceparent

Входные данные / параметры запроса

Единая секция: HTTP-источник + типизированное поле request-модели UnpublishPostCommand.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute (id)GuidДа!= Guid.Empty; существующий неудалённый Post (шаг 2)
ActorUserIdidentity claims (JWT)GuidДаиз контекста актора, не из тела; record-level INV-NWS-007 (шаг 2)

Модель ответа UnpublishPostResult

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPost.IdИдентификатор поста.
StatusPostStatusДаPost.Status (enum строкой)Новый статус — всегда Unpublished (enum — дом post.md).
UnpublishedAtDateTimeOffsetДаPost.UpdatedAt (момент перехода)Момент снятия с публикации.

Алгоритм

  1. Контекст и доступ. Выполняет автор поста или менеджер его компании (User); гость → 401. Требуется news.posts.manage-own (self) либо news.posts.manage (модерация/менеджер компании). Снятие не повторяемо: второй вызов упрётся в проверку состояния.
  2. Проверка входа и доступа. PostId != Guid.Empty, иначе VALIDATION_FAILED. Найти пост по PostId; если не найден (в т.ч. Archived/soft-deleted) → NEWS_POST_NOT_FOUND. Record-level (INV-NWS-007): актор — автор, либо менеджер компании в scope AuthorCompanyId, либо носитель news.posts.manage; иначе → ForbiddenError (403). Допустимость состояния: Status == Published; Draft/Unpublished/Archived не допускаются → NEWS_INVALID_STATE (409).
  3. Снятие с публикации. Перевести пост Published → Unpublished, actor-stamp, инкремент доменной версии. Не меняются: контент и блоки с их SnapshotId (embed-snapshot-ы НЕ отвязываются — INV-SNP-006, контент переиспользуется при повторной публикации), CurrentVersionNumber, FirstPublishedAt, PublishedAt, CategoryId, ProjectRefs. Новый PostVersion не создаётся (снятие — не публикация, вне INV-NWS-005); пересчёт SearchText не нужен (текст не менялся).
  4. Согласованность и эффекты. Изменяется одна строка поста атомарно. Конкурентная правка того же поста (оптимистическая блокировка) → 409 (retry с перечиткой). Интеграционных Kafka-событий нет: pin.news.post-unpublished.v1 отклонён — потребителя снятия нет (уведомления шлёт только post-published). Пост выпадает из лент global/subscriptions автоматически (частичный индекс ix_posts_feed where status='Published') и удаляется из поисковой проекции news-posts (по доменному PostUnpublishedEvent, search/posts.md); отвязки snapshot-ов нет.
  5. Результат. UnpublishPostResult { PostId, Status = Unpublished, UnpublishedAt }200.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (pin.news.post-unpublished.v1 отклонён — потребителя снятия нет; уведомления только по post-published)
External callsnone — embed-snapshot-ы НЕ detached, selections не вызывается (контраст с DELETE /posts/{id})
Cache / projections / searchиз ленты пост выпадает автоматически (частичный ix_posts_feed where status='Published'); из Elasticsearch-проекции news-posts документ удаляется по PostUnpublishedEvent (search/posts.md)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400id == Guid.Empty«Некорректный идентификатор поста»no retry
NEWS_POST_NOT_FOUND404Post не найден или Archived/soft-deleted«Пост не найден»no retry
ForbiddenError403нарушен record-level INV-NWS-007 (не автор / не в scope компании / нет news.posts.manage)«Недостаточно прав»no retry
NEWS_INVALID_STATE409Post.Status != Published (Draft/Unpublished/Archived)«Пост не опубликован»no retry
concurrency (RowVersion)409конфликт оптимистичной блокировки (параллельная мутация поста)«Повторите попытку»retry (перечитка)

Guest без JWT → 401 (пайплайн авторизации, до handler-а).

Предусловия и постусловия

Предусловия (истинны до сценария):

  • актор — автор поста, менеджер компании в scope AuthorCompanyId либо носитель news.posts.manage (INV-NWS-007);
  • пост существует, неудалён и в статусе Published (снятие возможно только из опубликованного).

Постусловия (гарантированы после успешного снятия):

  • пост в Unpublished, actor-stamp UpdatedByUserId, доменная версия +1;
  • не изменены: контент и блоки с их SnapshotId (embed-snapshot-ы не отвязываются — INV-SNP-006), CurrentVersionNumber, FirstPublishedAt, PublishedAt, CategoryId, ProjectRefs; новый PostVersion не создан (INV-NWS-005);
  • пост выпал из лент global/subscriptions (частичный индекс ix_posts_feed where status='Published') и удалён из поисковой проекции news-posts; интеграционных Kafka-событий нет.

Интеграционная последовательность (после коммита)

Синхронно в запросе: проверки доступа и состояния → переход Published → Unpublished (одна строка агрегата) → 200 клиенту (пост сразу вне лент — source of truth news). Кросс-сервисных синхронных вызовов нет: selections не вызывается (контраст с DELETE /posts/{id}), контент переиспользуется при повторной публикации. Что предоставляет наружу: только доменный PostUnpublishedEvent — search-индексатор удаляет документ из проекции news-posts (окно свежести — секунды; в промежутке поиск может краткий момент ещё находить пост, лента уже нет). Интеграционных Kafka-событий (pin.news.post-unpublished.v1) осознанно нет — потребителя снятия нет, уведомления шлёт только post-published. Конкурентность: оптимистическая блокировка версии поста → 409 при устаревшей версии, проигравший перечитывает и повторяет; компенсаций нет.

Acceptance Criteria

  • Снятие опубликованного. Дано: автор, пост Published. Когда: POST /api/v1/posts/{id}/unpublish. Тогда: 200, Status = Unpublished, UnpublishedAt заполнен, CurrentVersionNumber/PublishedAt/FirstPublishedAt неизменны, PostVersion не пишется, embed-snapshot-ы не отвязаны.
  • Выпадение из лент и поиска. Дано: снятый пост. Когда: перечитка ленты/поиска. Тогда: пост отсутствует в global/subscriptions и в news-posts (поиск догоняет в окне секунд).
  • Повторное снятие. Дано: пост уже Unpublished. Когда: POST unpublish. Тогда: 409 NEWS_INVALID_STATE (снятие — не toggle).
  • Снятие из недопустимого статуса. Дано: пост Draft/Archived. Когда: POST unpublish. Тогда: 409 NEWS_INVALID_STATE.
  • Архивный/несуществующий пост. Дано: пост Archived/soft-deleted или отсутствует. Когда: POST unpublish. Тогда: 404 NEWS_POST_NOT_FOUND.
  • Не владелец. Дано: актор не автор/не менеджер scope и без news.posts.manage (INV-NWS-007). Когда: POST unpublish. Тогда: 403 ForbiddenError.
  • Конкурентная мутация. Дано: пост изменён параллельно (устаревшая версия). Когда: POST unpublish. Тогда: 409, клиент перечитывает и повторяет.
  • Гость. Дано: запрос без JWT. Когда: POST unpublish. Тогда: 401.

Совместимость и наблюдаемость

  • Status — versioned enum строкой; ответ UnpublishPostResult стабилен. Новые optional поля — additive; удаление/переименование/смена типа — breaking (migration + rollout).
  • Logs: post unpublish requested/succeeded/rejected (PostId, ActorUserId, from-status); metrics: news_post_unpublish_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard news-overview; runbook — docs/06-services/news/runbooks/ (целевой путь по конвенции).

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

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

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

  • Сущность/инварианты/переходы: post.md (enum PostStatus, переход Published → Unpublished, INV-NWS-005 [почему снятие не пишет версию], INV-NWS-007 [record-level], индексы ленты + search-проекция); история публикаций — post-version.md (при снятии не пишется); блоки/embed — post-block.md и selection-snapshot.md (SnapshotId не detached).
  • Обратная и смежные операции: publish-post.md (Draft/Unpublished → Published, пишет PostVersion + post-published); delete-post.md (soft-delete + detach snapshots — контраст с снятием). Карта области — README.
  • Permission-каталог: identity/domain/permission.md (news.posts.manage-own, news.posts.manage).
  • Сервис: news/README.md.
  • ADR: ADR-0052 (tenancy, nullable TenantId), ADR-0056 (simplicity-first, без detach на снятии), ADR-0059 (Elasticsearch-проекция поиска).