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

DELETE /api/v1/posts/{id} — Удалить пост (soft + detach snapshots)

Терминальный переход поста Draft/Published/UnpublishedArchived (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 areaauthoring
Feature groupauthoring
Статусdraft
Документdocs/06-services/news/api/authoring/delete-post.md

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

ПроверкаЗначение
Actor typeUser (автор поста) либо 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-levelINV-NWS-007: actor.UserId == Post.AuthorUserId, либо менеджер компании в scope Post.AuthorCompanyId, либо permission news.posts.manage; иначе 403
Tenant isolationTenantId = AuthorCompanyId (nullable — пост инвестора/сотрудника вне tenant, ADR-0052 п.4); фильтр загрузки по tenant/record-level
Auditdelete: actor-stamp (DeletedByUserId) + DeletedAt + переход Status → Archived

HTTP-контракт

ПолеЗначение
MethodDELETE
Route/api/v1/posts/{id:guid}
Success status204 (No Content)
Idempotency headerне применяется (естественная идемпотентность: повтор на терминальном Archived204 без мутации и без повторного detach)
Correlationсквозной trace через traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
Idroute ({id})GuidДа!= Guid.Empty; существующий пост (шаг 2)record-level фильтр по автору/scope
TenantIdidentity claimsGuid?Нетtenant актора (nullable — вне tenant для инвестора/сотрудника)Tenant isolation
ActorUserIdidentity claims (sub)GuidДаRequired; определяет ветку record-level (шаг 2)

Body отсутствует.

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

Тела ответа нет — 204 No Content. Списочная модель не применяется (единичная операция). Клиент уже знает id; новое состояние получает при перечитке (GET /api/v1/authoring/posts — пост пропадает из активной выборки).

Алгоритм

  1. Контекст и доступ. Операцию выполняет автор поста (User) либо оператор-модератор (m2m, ADR-0053). Идемпотентность обеспечивается терминальным статусом Archived — Idempotency-header не применяется.
  2. Проверка входа и доступа. Id != Guid.Empty, иначе VALIDATION_FAILED. Найти пост по Id (в scope/tenant актора); если не найден → NEWS_POST_NOT_FOUND (404). Проверить record-level (INV-NWS-007): актор — автор поста, либо менеджер компании в scope AuthorCompanyId, либо носитель news.posts.manage; иначе → FORBIDDEN (403). Пост загружается вместе с контентом — блоки нужны, чтобы собрать SnapshotId встроенных подборок для пост-commit-отвязки (post-block.md); комментарии и история версий не читаются (не мутируются).
  3. Допустимость состояния. Переход разрешён из Draft/Published/Unpublished. Если пост уже Archived — идемпотентный повтор: без мутации и без повторной отвязки, сразу 204.
  4. Удаление (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.
  5. Отвязка snapshot-ов (после фиксации удаления). Порядок «сначала фиксируем удаление, затем отвязываем» гарантирует, что пост уже выпал из лент, даже если selections недоступен. Для каждого запомненного SnapshotId вызывается внутренняя отвязка в selections (reason = PostDeleted, идемпотентно — INV-SNP-005, internal-detach-selection-snapshot.md). Сами snapshot-ы не удаляются (INV-SNP-006) — история PostVersion продолжает на них ссылаться. Сбой отвязки (selections недоступен / таймаут / 5xx) не откатывает закоммиченное удаление: SnapshotId ставится в job snapshot-detach-retry для надёжного повтора.
  6. Результат. Тела нет — 204 No Content. Терминальный повтор (Archived) — тот же 204 без отвязки. Конкурентная правка (гонка с параллельной операцией) → ConcurrencyConflict (409), один повтор с перечиткой. Интеграционных Kafka-событий нет: post-deleted осознанно отклонён (нет потребителя).

Диаграмма

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

ТипДетали
Integration eventsnone (осознанно: 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-история цела
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Id == Guid.Empty«Некорректный идентификатор»no retry
NEWS_POST_NOT_FOUND404пост не найден по Id (или вне scope/tenant актора)«Пост не найден»no retry
FORBIDDEN403актор не автор, не менеджер в scope и без news.posts.manage (INV-NWS-007)«Недостаточно прав»no retry
ConcurrencyConflict409конкурентная правка (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-stamp DeletedByUserId; строки поста, комментариев и 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; dashboard news-overview; runbook — ручной detach осиротевших snapshot-ов и разбор очереди snapshot-detach-retry (README).

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

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

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