DELETE /api/v1/posts/{id} — Удалить пост (soft + detach snapshots)
Терминальный переход поста Draft/Published/Unpublished → Archived (soft delete: DeletedAt := now).
Строки поста, комментариев и версий сохраняются; пост выпадает из лент, поиска и публичного чтения. После
commit активные snapshot-ы блоков-подборок отвязываются в selections (reason = PostDeleted); сами
snapshot-ы не удаляются (INV-SNP-006) — история PostVersion продолжает на них ссылаться. Дом полей,
статусной машины и инвариантов агрегата — post.md.
Назначение
Дать автору (или менеджеру компании в scope, или оператору с news.posts.manage) убрать пост из ленты и
поиска, не разрушая append-only историю публикаций (PostVersion) и иммутабельные копии подборок
(SelectionSnapshot). Owning-контекст — news, feature group authoring.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | news |
| API area | authoring |
| Feature group | authoring |
| Статус | draft |
| Документ | docs/06-services/news/api/authoring/delete-post.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (автор поста) либо Operator(Admin) (модерация, m2m — ADR-0053) |
| Auth policy / Permissions | аутентифицированный User c news.posts.manage-own (self + company scope) или permission news.posts.manage (permission.md) |
| Scope (RBAC) | self + company (AuthorCompanyId) для менеджера застройщика/агентства |
| Record-level | INV-NWS-007: actor.UserId == Post.AuthorUserId, либо менеджер компании в scope Post.AuthorCompanyId, либо permission news.posts.manage; иначе 403 |
| Tenant isolation | TenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052 п.4); фильтр загрузки по tenant/record-level |
| Audit | delete: actor-stamp (DeletedByUserId) + DeletedAt + переход Status → Archived |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | DELETE |
| Route | /api/v1/posts/{id:guid} |
| Success status | 204 (No Content) |
| Idempotency header | не применяется (естественная идемпотентность: повтор на терминальном Archived → 204 без мутации и без повторного detach) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
Id | route ({id}) | Guid | Да | != Guid.Empty; существующий пост (шаг 2) | record-level фильтр по автору/scope |
TenantId | identity claims | Guid? | Нет | tenant актора (nullable — вне tenant для инвестора/сотрудника) | Tenant isolation |
ActorUserId | identity claims (sub) | Guid | Да | Required; определяет ветку record-level (шаг 2) | — |
Body отсутствует.
Модель ответа
Тела ответа нет — 204 No Content. Списочная модель не применяется (единичная операция).
Клиент уже знает id; новое состояние получает при перечитке (GET /api/v1/authoring/posts — пост
пропадает из активной выборки).
Алгоритм
- Контекст и доступ. Операцию выполняет автор поста (
User) либо оператор-модератор (m2m, ADR-0053). Идемпотентность обеспечивается терминальным статусомArchived— Idempotency-header не применяется. - Проверка входа и доступа.
Id != Guid.Empty, иначеVALIDATION_FAILED. Найти пост поId(в scope/tenant актора); если не найден →NEWS_POST_NOT_FOUND(404). Проверить record-level (INV-NWS-007): актор — автор поста, либо менеджер компании в scopeAuthorCompanyId, либо носительnews.posts.manage; иначе →FORBIDDEN(403). Пост загружается вместе с контентом — блоки нужны, чтобы собратьSnapshotIdвстроенных подборок для пост-commit-отвязки (post-block.md); комментарии и история версий не читаются (не мутируются). - Допустимость состояния. Переход разрешён из
Draft/Published/Unpublished. Если пост ужеArchived— идемпотентный повтор: без мутации и без повторной отвязки, сразу204. - Удаление (soft delete). Перевести пост
… → Archived:Status = Archived,DeletedAt = now, actor-stamp. Комментарии и история версий (PostVersion) остаются строками (append-only, restrict — не трогаются). Эффекты: пост выпадает из ленты (частичный индексix_posts_feed where status='Published') и из публичного чтения; из поисковой проекцииnews-postsдокумент удаляется (поPostArchivedDomainEvent);Suffixосвобождается. Список активныхSnapshotIdвстроенных подборок текущей версии запоминается для шага 5. - Отвязка snapshot-ов (после фиксации удаления). Порядок «сначала фиксируем удаление, затем отвязываем»
гарантирует, что пост уже выпал из лент, даже если
selectionsнедоступен. Для каждого запомненногоSnapshotIdвызывается внутренняя отвязка вselections(reason = PostDeleted, идемпотентно — INV-SNP-005, internal-detach-selection-snapshot.md). Сами snapshot-ы не удаляются (INV-SNP-006) — историяPostVersionпродолжает на них ссылаться. Сбой отвязки (selectionsнедоступен / таймаут / 5xx) не откатывает закоммиченное удаление:SnapshotIdставится в jobsnapshot-detach-retryдля надёжного повтора. - Результат. Тела нет —
204 No Content. Терминальный повтор (Archived) — тот же204без отвязки. Конкурентная правка (гонка с параллельной операцией) →ConcurrencyConflict(409), один повтор с перечиткой. Интеграционных Kafka-событий нет:post-deletedосознанно отклонён (нет потребителя).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (осознанно: post-deleted отклонён — нет потребителя, README) |
| External calls | внутренняя отвязка snapshot в selections (m2m, после фиксации удаления) для каждого активного SnapshotId встроенной подборки; timeout + retry; сбой → job snapshot-detach-retry |
| Cache / projections / search | пост выпадает из партиального ix_posts_feed (where status='Published') и из чтения; из Elasticsearch-проекции news-posts документ удаляется по PostArchivedDomainEvent; Suffix освобождается; snapshot-ы блоков не удаляются (INV-SNP-006), PostVersion-история цела |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | Id == Guid.Empty | «Некорректный идентификатор» | no retry |
NEWS_POST_NOT_FOUND | 404 | пост не найден по Id (или вне scope/tenant актора) | «Пост не найден» | no retry |
FORBIDDEN | 403 | актор не автор, не менеджер в scope и без news.posts.manage (INV-NWS-007) | «Недостаточно прав» | no retry |
ConcurrencyConflict | 409 | конкурентная правка (RowVersion/xmin) | «Повторите попытку» | retry (перечитка GetById) |
Сбой detach snapshot-а не возвращает ошибку клиенту (удаление уже закоммичено, 7b выполняется после
транзакции) — обрабатывается идемпотентным job snapshot-detach-retry (INV-SNP-005/006).
Предусловия и постусловия
Предусловия (истинны до сценария):
- актор — автор поста, менеджер компании в scope
AuthorCompanyId, либо оператор-модератор сnews.posts.manage(INV-NWS-007); - пост существует и в статусе
Draft/Published/Unpublished(терминальныйArchived— идемпотентный no-op).
Постусловия (гарантированы после успешного удаления):
- пост в
Archived,DeletedAt = now, actor-stampDeletedByUserId; строки поста, комментариев иPostVersionсохранены (append-only, restrict); - пост выпал из ленты (частичный индекс
ix_posts_feed where status='Published'), из публичного чтения и из поисковой проекцииnews-posts(поPostArchivedDomainEvent);Suffixосвобождён; - активные
SnapshotIdвстроенных подборок текущей версии отвязаны вselections(reason = PostDeleted) после коммита либо поставлены вsnapshot-detach-retry; сами snapshot-ы не удалены (INV-SNP-006) — историяPostVersionпродолжает на них ссылаться; - интеграционных Kafka-событий нет (
post-deletedосознанно отклонён — нет потребителя).
Интеграционная последовательность (после коммита)
Синхронно в запросе (порядок «сначала фиксируем удаление, затем отвязываем»): загрузка поста с контентом → проверки существования/record-level/состояния → перевод в Archived + сбор списка активных SnapshotId → атомарное сохранение удаления → 204 клиенту (пост уже вне лент, даже если selections недоступен). После коммита, для каждого запомненного SnapshotId — синхронный внутренний вызов selections отвязать snapshot (reason = PostDeleted, идемпотентно, INV-SNP-005); сбой (недоступность/таймаут/5xx) не откатывает удаление — SnapshotId ставится в job snapshot-detach-retry для надёжного повтора.
Что ожидает от selections (синхронный m2m): detach идемпотентен по SnapshotId (повтор безопасен), snapshot остаётся иммутабельным и не удаляется (INV-SNP-006). Что предоставляет наружу: только доменный PostArchivedDomainEvent (удаление документа из news-posts); интеграционных Kafka-событий нет. Что видит пользователь в промежуточном состоянии: пост исчезает из лент/чтения/поиска сразу после коммита; если detach ещё не выполнен — исторические PostVersion (доступные автору) продолжают корректно рендерить состав по SnapshotId. Конкурентность/компенсации: оптимистическая блокировка версии → 409 при устаревшей версии; отдельной компенсации detach-фазы нет — её роль выполняет идемпотентный retry-job.
Acceptance Criteria
- Удаление опубликованного. Дано: автор, пост
Publishedс embed-блоками. Когда:DELETE /api/v1/posts/{id}. Тогда:204,Status = Archived,DeletedAtзаполнен, пост вне лент/поиска,Suffixсвободен, активные snapshot-ы отвязаны (reason = PostDeleted),PostVersion-история цела. - Удаление черновика. Дано: пост
Draftбез блоков. Когда:DELETE. Тогда:204,Archived, detach-целей нет. - Идемпотентный повтор. Дано: пост уже
Archived. Когда: повторDELETE. Тогда:204без мутации и без повторной отвязки. selectionsнедоступен на detach. Дано: 5xx/таймаут при отвязке после коммита. Когда:DELETE. Тогда: клиент всё равно получает204(удаление закоммичено);SnapshotIdуходит вsnapshot-detach-retry, ошибка клиенту не возвращается.- Иммутабельность snapshot-ов. Дано: пост с embed удалён. Когда: автор открывает старую
PostVersion. Тогда: состав поSnapshotIdдоступен (snapshot не удалён, INV-SNP-006). - Несуществующий/чужой пост. Дано: пост отсутствует или вне scope/tenant актора. Когда:
DELETE. Тогда:404 NEWS_POST_NOT_FOUND. - Не владелец. Дано: актор не автор/не менеджер scope и без
news.posts.manage(INV-NWS-007). Когда:DELETE. Тогда:403 FORBIDDEN. - Конкурентная правка. Дано: параллельная мутация поста (устаревшая версия). Когда:
DELETE. Тогда:409 ConcurrencyConflict, клиент перечитывает и повторяет. - Невалидный вход. Дано:
Id == Guid.Empty. Когда:DELETE. Тогда:400 VALIDATION_FAILED.
Совместимость и наблюдаемость
- Контракт стабилен; undelete в API нет (восстановление — runbook, ручное снятие
DeletedAt). Удаление/ переименование route или смена статус-семантики — breaking (migration + rollout);post-deletedкак Kafka-событие — additive при появлении потребителя. - Logs:
post archived(PostId,AuthorUserId, actor-kind, число detach-целей и no-op/failed); metrics:news_post_deleted_total,news_snapshot_detach_total{result}; traces — сквозной trace черезtraceparent, со сквозным span вselections; dashboardnews-overview; runbook — ручной detach осиротевших snapshot-ов и разбор очередиsnapshot-detach-retry(README).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): POST /api/v1/posts/{id}/unpublish — снять пост с публикации
- Use Cases (1): UC-NWS-002. Встроить подборку в пост (иммутабельный snapshot)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: post.md (дом полей,
PostStatus, INV-NWS-007, переход →Archived), post-block.md (SelectionEmbedBlock.SnapshotId), post-version.md (append-only история — restrict). - Snapshot-ы: selection-snapshot.md (INV-SNP-005/006), detach-контракт: internal-detach-selection-snapshot.md.
- Permission
news.posts.manage: permission.md. - Сервис: News README.
- Tenancy: ADR-0052.