DELETE /api/v1/profile/persons/{personId}/documents/{documentId}/scans/{fileId} — Удаление скана документа (removeDocumentScan)
Назначение
Отвязать скан от документа персоны: элемент удаляется из открытого списка сканов ScanList
(person-document.md). Сам файл в storage немедленно не удаляется — он
может фигурировать в иммутабельных снапшотах (сделки CRM, KYC-заявки); очистку нессылаемых файлов выполняет
retention-политика storage. Вызывает инвестор-владелец документа.
| Поле | Значение |
|---|
| Сервис/контекст | profile |
| API area | public |
| Feature group | documents |
| Статус | draft |
| Документ | docs/06-services/profile/api/public/remove-document-scan.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT), UserType = Investor |
| Auth policy / Permissions | authenticated; permission profile.documents.manage-self |
| Scope (RBAC) | self |
| Record-level | OwnerUserId = UserId; чужие id → 404 |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | update: UpdatedByUserId = UserId, Version++, событие PersonDocumentUpdatedEvent(["ScanList"]) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | DELETE |
| Route | /api/v1/profile/persons/{personId}/documents/{documentId}/scans/{fileId} |
| Success status | 200 |
| Idempotency header | не применяется (повтор для отсутствующего скана — no-op 200) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
personId | route | Guid | Да | NotEmpty | чужая → 404 |
documentId | route | Guid | Да | NotEmpty; активный документ персоны | чужой → 404 |
fileId | route | Guid | Да | NotEmpty | — |
Модель ответа PersonDocumentModel
Дом модели — list-person-documents.md; документ с обновлённым scans.
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
UserId из токена); tenant не применяется;
требуется право profile.documents.manage-self.
- Проверка входа.
personId/documentId/fileId заданы. Нарушение → ValidationError.
- Проверка предусловий. Профиль владельца существует; иначе
PROFILE_NOT_FOUND. Профиль активен
(Status = Active, иначе PROFILE_USER_BLOCKED, INV-PRF-3), в режиме самоуправления
(ContactsManagedBy = SelfService, иначе PROFILE_MANAGED_EXTERNALLY, INV-PRF-2), тип пользователя —
Investor (иначе PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE, INV-PRF-4). Персона принадлежит владельцу;
иначе PROFILE_PERSON_NOT_FOUND. Документ documentId принадлежит персоне; иначе
PROFILE_DOCUMENT_NOT_FOUND; документ активен (Status = Active), иначе PROFILE_DOCUMENT_ARCHIVED.
Если fileId отсутствует в ScanList — идемпотентный успех без изменений (шаг 5).
- Бизнес-правила и переходы. Скан
fileId удаляется из ScanList документа; у документа
UpdatedByUserId = UserId, Version++; у персоны Version++; фиксируется факт
PersonDocumentUpdatedEvent(PersonId, DocumentId, ["ScanList"]). В одном согласованном изменении
сохраняются изменение документа и интеграционное событие. Конкурентная правка (конфликт версий) →
PROFILE_CONCURRENCY_CONFLICT.
- Результат. Модель
PersonDocumentModel с обновлённым scans; HTTP 200. Storage-вызовов нет (файл
остаётся до retention).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | PersonUpsertedKafkaEvent / pin.profile.persons.v1 / key = PersonId (без PII) |
После коммита событие персоны потребляется асинхронно контекстом crm (SLA < 5 мин,
person-upserted.md): незавершённые брони/сделки, чей снапшот реквизитов
ссылается на эту персону, помечаются «реквизиты могли устареть»; иммутабельные снапшоты и завершённые сделки
не изменяются. Файл в storage продолжает существовать до retention — уже выданные снапшоты/ссылки остаются
валидными.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | Пустые route-id | перечень невалидных полей | no retry |
PROFILE_NOT_FOUND | 404 | Профиль не создан | «Профиль ещё создаётся» | retry |
PROFILE_USER_BLOCKED | 400 | Status != Active | «Учётная запись заблокирована» | no retry |
PROFILE_MANAGED_EXTERNALLY | 403 | TalentSync | «Данные управляются внешней системой» | no retry |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | не Investor | «Доступно только инвесторам» | no retry |
PROFILE_PERSON_NOT_FOUND | 404 | Нет персоны / чужая | «Персона не найдена» | no retry |
PROFILE_DOCUMENT_NOT_FOUND | 404 | Нет документа | «Документ не найден» | no retry |
PROFILE_DOCUMENT_ARCHIVED | 400 | Документ в архиве | «Документ в архиве» | no retry |
PROFILE_CONCURRENCY_CONFLICT | 409 | Конкурентная правка (конфликт версий) | «Данные изменены, повторите» | retry |
Совместимость и наблюдаемость
- Семантика стабильна; расширения — additive.
- Logs:
UserId, DocumentId, FileId; metrics:
profile_api_operations_total{operation="removeDocumentScan",result}; traces — OTel.
Acceptance Criteria
- Given активный документ владельца, содержащий скан
fileId, When DELETE, Then 200; скан
удалён из ScanList, у документа и персоны Version++; опубликован PersonUpsertedKafkaEvent
(pin.profile.persons.v1, без PII); файл в storage не удаляется (retention).
- Given
fileId отсутствует в ScanList, When DELETE, Then 200 идемпотентный no-op; без
изменений и без события.
- Given документ в архиве, When
DELETE, Then PROFILE_DOCUMENT_ARCHIVED (400).
- Given
personId/documentId чужие, When DELETE, Then PROFILE_PERSON_NOT_FOUND /
PROFILE_DOCUMENT_NOT_FOUND (404).
- Given профиль заблокирован /
TalentSync / UserType != Investor, When DELETE, Then
PROFILE_USER_BLOCKED (400) / PROFILE_MANAGED_EXTERNALLY (403) /
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE (403) соответственно.
- Given конкурентная правка документа (конфликт версий), When
DELETE, Then
PROFILE_CONCURRENCY_CONFLICT (409), retry.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы