POST /api/v1/posts/{id}/unpublish — снять пост с публикации
Назначение
Автор (или менеджер его компании) переводит опубликованный пост из Published в Unpublished: пост
исчезает из лент global/subscriptions и из поиска, но остаётся в БД как редактируемый черновик
с сохранённой историей публикаций (post-version.md). Снятие —
не публикация: новый PostVersion не пишется, CurrentVersionNumber не меняется, embed-snapshot-ы
блоков НЕ отвязываются (selections не вызывается) — контент сохраняется целиком и переиспользуется
при повторной публикации (publish-post.md).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | news |
| API area | authoring (self + company scope) |
| Feature group | authoring |
| Статус | draft |
| Документ | docs/06-services/news/api/authoring/unpublish-post.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (автор поста; guest/аноним — 401) |
| Auth policy / Permissions | JWT identity; own-scope news.posts.manage-own, override — news.posts.manage (модерация/менеджер компании) |
| Scope (RBAC) | self (AuthorUserId) + company (AuthorCompanyId) для менеджеров застройщика/агентства |
| Record-level | INV-NWS-007: AuthorUserId == actor.UserId, либо менеджер компании в scope AuthorCompanyId, либо permission news.posts.manage; иначе 403 |
| Tenant isolation | TenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052); основной гейт мутации — record-level |
| Audit | update: actor-stamping UpdatedByUserId; отдельной строки истории нет — снятие не пишет PostVersion (INV-NWS-005 — только публикация) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/posts/{id}/unpublish |
| Success status | 200 |
| Idempotency header | не применяется — повторное снятие уже-Unpublished даёт 409 NEWS_INVALID_STATE (не toggle) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Единая секция: HTTP-источник + типизированное поле request-модели UnpublishPostCommand.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
PostId | route (id) | Guid | Да | != Guid.Empty; существующий неудалённый Post (шаг 2) | — |
ActorUserId | identity claims (JWT) | Guid | Да | из контекста актора, не из тела; record-level INV-NWS-007 (шаг 2) | — |
Модель ответа UnpublishPostResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
PostId | Guid | Да | Post.Id | — | Идентификатор поста. |
Status | PostStatus | Да | Post.Status (enum строкой) | — | Новый статус — всегда Unpublished (enum — дом post.md). |
UnpublishedAt | DateTimeOffset | Да | Post.UpdatedAt (момент перехода) | — | Момент снятия с публикации. |
Алгоритм
- Контекст и доступ. Выполняет автор поста или менеджер его компании (
User); гость →401. Требуетсяnews.posts.manage-own(self) либоnews.posts.manage(модерация/менеджер компании). Снятие не повторяемо: второй вызов упрётся в проверку состояния. - Проверка входа и доступа.
PostId != Guid.Empty, иначеVALIDATION_FAILED. Найти пост поPostId; если не найден (в т.ч.Archived/soft-deleted) →NEWS_POST_NOT_FOUND. Record-level (INV-NWS-007): актор — автор, либо менеджер компании в scopeAuthorCompanyId, либо носительnews.posts.manage; иначе →ForbiddenError(403). Допустимость состояния:Status == Published;Draft/Unpublished/Archivedне допускаются →NEWS_INVALID_STATE(409). - Снятие с публикации. Перевести пост
Published → Unpublished, actor-stamp, инкремент доменной версии. Не меняются: контент и блоки с ихSnapshotId(embed-snapshot-ы НЕ отвязываются — INV-SNP-006, контент переиспользуется при повторной публикации),CurrentVersionNumber,FirstPublishedAt,PublishedAt,CategoryId,ProjectRefs. НовыйPostVersionне создаётся (снятие — не публикация, вне INV-NWS-005); пересчётSearchTextне нужен (текст не менялся). - Согласованность и эффекты. Изменяется одна строка поста атомарно. Конкурентная правка того же поста
(оптимистическая блокировка) →
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-ов нет. - Результат.
UnpublishPostResult { PostId, Status = Unpublished, UnpublishedAt }→200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (pin.news.post-unpublished.v1 отклонён — потребителя снятия нет; уведомления только по post-published) |
| External calls | none — embed-snapshot-ы НЕ detached, selections не вызывается (контраст с DELETE /posts/{id}) |
| Cache / projections / search | из ленты пост выпадает автоматически (частичный ix_posts_feed where status='Published'); из Elasticsearch-проекции news-posts документ удаляется по PostUnpublishedEvent (search/posts.md) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | id == Guid.Empty | «Некорректный идентификатор поста» | no retry |
NEWS_POST_NOT_FOUND | 404 | Post не найден или Archived/soft-deleted | «Пост не найден» | no retry |
ForbiddenError | 403 | нарушен record-level INV-NWS-007 (не автор / не в scope компании / нет news.posts.manage) | «Недостаточно прав» | no retry |
NEWS_INVALID_STATE | 409 | Post.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-stampUpdatedByUserId, доменная версия +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; dashboardnews-overview; runbook —docs/06-services/news/runbooks/(целевой путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность/инварианты/переходы:
post.md(enumPostStatus, переход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-проекция поиска).