DELETE /api/v1/selections/{id} — Удалить подборку (soft delete)
Мягкое удаление подборки владельцем: DeletedAt := now. Строки и связи сохраняются — snapshot-ы
постов и ссылки CRM остаются разрешимыми; публичная ссылка и списки перестают её отдавать (INV-SEL-009).
Назначение
Владелец удаляет подборку. Физическое удаление невозможно by design: на SelectionId ссылаются CRM
(лид/сделка) и SelectionSnapshot (FK restrict). Дом полей —
../domain/selection.md.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | selections |
| API area | public |
| Feature group | selections |
| Статус | draft |
| Документ | docs/06-services/selections/api/public/delete-selection.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (JWT) |
| Auth policy / Permissions | authenticated user; отдельных permissions нет (self-scope) |
| Scope (RBAC) | self |
| Record-level | OwnerUserId == actor.UserId в предикате загрузки (INV-SEL-002) |
| Tenant isolation | не применяется — вне tenant (ADR-0052 п.2) |
| Audit | delete: DeletedAt + SelectionDeletedKafkaEvent → user-activity; восстановление — runbook (ручное снятие DeletedAt, ../README.md) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | DELETE |
| Route | /api/v1/selections/{id:guid} |
| Success status | 204 |
| Idempotency header | не применяется; повторный DELETE возвращает 404 (после soft delete запись невидима — INV-SEL-009), фронт трактует 404 после первого 204 как успех |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
Id | route | Guid | Да | != Guid.Empty | record-level фильтр по владельцу |
UserId | identity claims (sub) | Guid | Да | Required | — |
Body отсутствует.
Модель ответа
Отсутствует — 204 No Content.
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь; удалять можно только собственную подборку (по владельцу-актору).
- Проверка входа и предусловий.
Idнепустой — иначеVALIDATION_ERROR. Подборка должна существовать, принадлежать актору и быть не удалённой (DeletedAt == null); иначе — единый отказSELECTIONS_NOT_FOUND(чужое существование не раскрывается, INV-SEL-002/009). - Бизнес-правила и переходы. Переход
Active → Deleted(INV-SEL-002): проставляютсяDeletedAtиUpdatedAt, фиксируется факт удаления. Snapshot-ы постов не отвязываются (restrict, INV-SNP-006): постnewsпродолжает показывать замороженную копию; живая ссылка/collection/{publicCode}начинает отвечать 404 (INV-SEL-009). - Что меняется. Подборка помечается удалённой (soft delete — физического удаления нет); элементы
остаются под удалённым корнем. Изменение и факт удаления фиксируются в одном согласованном изменении.
Конфликт с параллельной правкой →
SELECTIONS_CONCURRENCY_CONFLICT(409); фронт повторяет DELETE (попадёт в 404-ветку, если первый запрос успел). - Результат.
204 No Content.
Публикуемое интеграционное событие — в разделе «Побочные эффекты».
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | SelectionDeletedKafkaEvent / pin.selections.selection-deleted.v1 / key = SelectionId |
| Проекции / поиск | публичная ссылка /collection/{publicCode} → 404 с момента коммита (partial-index исключает строку); snapshot-ы постов живут (INV-SNP-006); новые snapshot-ы из удалённой запрещены (INV-SNP-002) |
Интеграционные последствия (что видят соседи после удаления)
Удаление меняет поведение сразу нескольких границ; порядок и окно рассинхронизации важны для UX:
- Публичная ссылка (синхронно): с момента коммита
GET /selections/{publicCode}отвечает404(partial-indexix_selections_public_code_activeисключает строку) — INV-SEL-009. Разосланные ранее ссылки перестают открываться немедленно. - CRM (синхронно, pull): ссылка в лиде/сделке остаётся разрешимой —
crmчитает подборку через internalGET /internal/selections/{id}(get-selection.md), который для soft-deleted резолвит запись сIsDeleted = true(не 404). CRM показывает «подборка недоступна/удалена», не теряя контекст лида. Событиеselection-deletedдля этого не обязано дойти — состояние виден по флагу при следующем чтении. - Посты
news(не затрагиваются): уже снятыеSelectionSnapshotне отвязываются (FK restrict, INV-SNP-006) — опубликованный пост продолжает показывать замороженную копию состава. Снять новый snapshot из удалённой подборки нельзя (INV-SNP-002 →SELECTIONS_SOURCE_DELETED). - История действий (асинхронно, через outbox):
selection-deleted→user-activityфиксирует ActionEvent «удалил подборку» (окно eventual-consistency — секунды). Потребитель идемпотентен поSelectionId; при простое шины подборка уже удалена и невидима публично, отстаёт только лента действий.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_ERROR | 400 | Id == Guid.Empty | «Некорректный идентификатор» | no retry |
SELECTIONS_NOT_FOUND | 404 | Нет записи / чужая (INV-SEL-002) / уже удалена (повтор DELETE) | «Подборка не найдена» | no retry |
SELECTIONS_CONCURRENCY_CONFLICT | 409 | Конфликт оптимистической блокировки | «Подборка изменена параллельно» | retry (один раз) |
UNAUTHORIZED | 401 | Нет/просрочен access-токен | — | retry после refresh |
Предусловия и постусловия
- Предусловия: актор авторизован (валидный JWT); подборка существует, принадлежит актору
(INV-SEL-002) и ещё не удалена (
DeletedAt == null). - Постусловия при
204: у подборки проставленDeletedAt(переходActive → Deleted),UpdatedAtпродвинут; строки подборки и элементов физически сохранены (soft delete); публичное чтение по коду отвечает404(INV-SEL-009); ссылки CRM и snapshot-ы постов остаются разрешимыми (INV-SNP-006); счётчик не удалённых подборок владельца уменьшился на 1 (освобождает место под лимит INV-SEL-007); в outbox поставленоselection-deleted(key =SelectionId). - Постусловия при отказе (400/401/404/409): статус подборки не изменён, событий нет.
Acceptance Criteria
- Given не удалённая подборка владельца, When
DELETE, Then204; повторныйGET /selections/{publicCode}→404(INV-SEL-009); поставленоselection-deleted(key =SelectionId). - Given только что удалённая подборка, When повторный
DELETE, Then404 SELECTIONS_NOT_FOUND(запись уже невидима); фронт трактует 404 после первого 204 как успех (идемпотентность на клиенте). - Given подборка, на которую в лиде ссылается CRM, When владелец её удалил, Then ссылка в CRM остаётся
разрешимой:
GET /internal/selections/{id}вернёт200сIsDeleted = true— CRM покажет «подборка недоступна». - Given подборка с уже снятым snapshot-ом поста, When владелец её удалил, Then пост
newsпродолжает показывать замороженный snapshot (INV-SNP-006), а попытка снять новый snapshot из неё →400 SELECTIONS_SOURCE_DELETED(INV-SNP-002). - Given чужая или несуществующая подборка, When
DELETE, Then404 SELECTIONS_NOT_FOUND(существование чужой не раскрывается, INV-SEL-002). - Given параллельные
DELETEи правка состава, When обе доходят, Then удаление и правка конкурируют по оптимистической блокировке: победитель фиксируется, проигравший →409 SELECTIONS_CONCURRENCY_CONFLICT; фронт повторяетDELETE(попадёт в 404-ветку, если удаление уже прошло). - Given
Id == Guid.Empty, WhenDELETE, Then400 VALIDATION_ERROR. - Given отсутствующий/просроченный токен, When
DELETE, Then401 UNAUTHORIZED.
Совместимость и наблюдаемость
- Контракт стабилен; undelete-операции в API нет (runbook — ручное восстановление).
- Logs:
UserId,SelectionId; metrics:selections_deleted_total; traces: spanDeleteSelectionCommand. Dashboard —selections-overview; runbook — восстановление случайно удалённой подборки (../README.md).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (3): API контекста selections — индекс, GET /api/v1/internal/selections/{id} — Подборка для карточки клиента/сделки (m2m crm), PUT /api/v1/selections/{id} — Изменить название/описание подборки
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: ../domain/selection.md (INV-SEL-002, 009;
ISoftDeleteEntity); snapshot-ы: ../domain/selection-snapshot.md (INV-SNP-002, 006). - Соседние: get-selection.md (поведение CRM после удаления).