PUT /api/v1/favorites/{propertyObjectId}/note — Установить/очистить заметку к объекту недвижимости
Полностью заменяет личную заметку (Note, ≤ 4000 символов, INV-FAV-003) на записи избранного текущего
владельца. note = null или пустая строка — очистка заметки. PUT-семантика: передаётся целевое значение,
а не diff.
Назначение
Поле «собственная заметка к объекту недвижимости» на странице избранного: владелец редактирует свой
комментарий к сохранённому объекту недвижимости (до 4000 символов) или очищает его. Заметка видна только
владельцу и никогда не покидает контекст favorites (../../domain/favorite.md).
| Поле | Значение |
|---|
| Сервис/контекст | favorites |
| API area | public |
| Feature group | favorites |
| Статус | draft |
| Документ | docs/06-services/favorites/api/public/update-favorite-note.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (гость по X-Anonymous-Id) или User (JWT) |
| Auth policy / Permissions | none (self-scope) |
| Scope (RBAC) | не применяется |
| Record-level | запись ищется строго с предикатом владельца (INV-FAV-005); чужая запись → 404 FAVORITES_NOT_FOUND (существование не раскрывается) |
| Tenant isolation | не применяется — сущности вне tenant (ADR-0052 п.2) |
| Audit | update: UpdatedAt обновляется; текст заметки в аудит/логи/события не попадает |
HTTP-контракт
| Поле | Значение |
|---|
| Method | PUT |
| Route | /api/v1/favorites/{propertyObjectId}/note |
| Success status | 200 |
| Idempotency header | не применяется — PUT естественно идемпотентен (повтор с тем же значением — no-op) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity (JWT claim sub) | Guid? | Условно | заполнен при авторизованном запросе; приоритет над AnonymousId | владелец — только сам актор |
AnonymousId | header X-Anonymous-Id | Guid? | Условно | валидный Guid ≠ Guid.Empty; используется при отсутствии JWT | недоверенный вход |
PropertyObjectId | route {propertyObjectId} | Guid | Да | != Guid.Empty | ключ поиска записи владельца |
Note | body note | string? | Нет | после trim — null или 1..4000 символов; пустая строка нормализуется в null (INV-FAV-003) | приватные данные: не логируются, не попадают в события |
RowVersion | body rowVersion | uint? | Нет | если передан — сверяется с версией записи | защита от потерянного обновления между вкладками/устройствами |
Модель ответа FavoriteResult
Проекция та же, что в add-favorite.md; дельта: WasCreated всегда false.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
FavoriteId | Guid | Да | Favorite.Id | — | Идентификатор записи. |
PropertyObjectId | Guid | Да | Favorite.PropertyObjectId | — | Объект недвижимости. |
Note | string? | Нет | Favorite.Note | видна только владельцу | Заметка после операции (null — очищена). |
CreatedAt | DateTimeOffset | Да | Favorite.CreatedAt | — | Дата добавления. |
UpdatedAt | DateTimeOffset | Да | Favorite.UpdatedAt | — | Обновлена этой операцией (участвует в LastWriterWins при слиянии — ../../domain/merge-strategy.md). |
WasCreated | bool | Да | константа false | — | Запись не создавалась. |
Алгоритм
- Контекст и доступ. Разрешить владельца:
UserId из JWT, иначе гостевая сессия AnonymousId из
заголовка. Не разрешён ни тот, ни другой — отказ FAVORITES_OWNER_UNRESOLVED.
- Проверка входа.
PropertyObjectId != Guid.Empty; Note — null или 1..4000 символов после trim,
иначе отказ FAVORITES_NOTE_TOO_LONG (INV-FAV-003). Статуса у Favorite нет.
- Что читаем. Запись владельца по
PropertyObjectId (предикат жёстко включает владельца). Записи нет
→ отказ FAVORITES_NOT_FOUND (запись не существует или чужая — не различаем, INV-FAV-005). Если
клиент прислал RowVersion и он не совпадает с версией записи — немедленно отказ
FAVORITES_CONCURRENCY_CONFLICT без мутаций.
- Бизнес-правила и маппинг. Нормализация: пустая/пробельная заметка →
null, иначе trim. Если
нормализованное значение равно текущей заметке — no-op (изменений и событий нет, сразу результат). Иначе
заметка заменяется (проверка INV-FAV-003), поднимается доменное событие FavoriteNoteUpdatedEvent
(FavoriteId, HasNote — текст заметки в событие не входит); UpdatedAt обновляется.
- Что меняется. Обновляется заметка записи. Конкурентная защита — оптимистическая блокировка по
версии записи: при параллельной правке с другого устройства фиксация конфликтует → отказ
FAVORITES_CONCURRENCY_CONFLICT.
- Согласованность. Интеграционные события не публикуются: правка заметки — приватное действие, не
попадает в ленту действий
user-activity (Kafka-события определены только для add/remove/merge —
../../domain/favorite.md). Доменное FavoriteNoteUpdatedEvent — только в
памяти процесса.
- Результат. Модель
FavoriteResult из актуального состояния записи; Note = null при очистке.
HTTP 200 OK (и для no-op ветки — целевое состояние уже достигнуто).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
FAVORITES_OWNER_UNRESOLVED | 400 | Нет ни JWT, ни валидного X-Anonymous-Id | «Не удалось определить владельца избранного» | no retry |
FAVORITES_NOTE_TOO_LONG | 400 | Note после trim длиннее 4000 символов (INV-FAV-003) | «Заметка не может превышать 4000 символов» | no retry |
ValidationError | 400 | PropertyObjectId = Guid.Empty / невалидный body | детали валидации | no retry |
FAVORITES_NOT_FOUND | 404 | Записи владельца по PropertyObjectId нет (или запись чужая — не различается, INV-FAV-005) | «Объект недвижимости не найден в избранном» | no retry (сначала POST /favorites) |
FAVORITES_CONCURRENCY_CONFLICT | 409 | Версия записи изменилась между чтением и фиксацией | «Заметка изменена с другого устройства — обновите страницу» | retry после перечитывания GET /favorites |
InternalError | 500 | Отказ хранилища | generic message | retry — PUT идемпотентен |
Предусловия и постусловия
- Предусловие. Разрешён владелец (JWT или гость по
X-Anonymous-Id); запись пары «владелец + объект
недвижимости» уже существует (заметка ставится на созданную запись — иначе сначала POST /favorites);
Note после trim — null или 1..4000 символов.
- Постусловие (значение изменилось).
Note записи равна целевому значению (или null при очистке);
UpdatedAt = now (участвует в LastWriterWins при последующем слиянии); интеграционные события не
публикуются (правка заметки приватна).
- Постусловие (значение не изменилось / no-op). Запись и
UpdatedAt не тронуты; событий нет.
Acceptance-критерии
- Given запись владельца на
X с Note = N1, When PUT /favorites/{X}/note {note: N2} (N2 ≠ N1),
Then Note = N2, UpdatedAt обновлён, ответ 200; в Kafka ничего не публикуется.
- Given запись владельца на
X с непустой Note, When PUT {note: null} (или пустая строка),
Then Note = null (очищена), ответ 200.
- Given запись владельца на
X с Note = N1, When PUT {note: N1} (то же значение), Then
no-op: запись и UpdatedAt не меняются, ответ 200, событие не поднимается.
- Given записи владельца на
X нет (или запись чужая), When PUT, Then отказ
FAVORITES_NOT_FOUND (404) — существование чужой записи не раскрывается (INV-FAV-005).
- Given
note длиннее 4000 символов после trim, When PUT, Then отказ
FAVORITES_NOTE_TOO_LONG (400), запись не изменена.
- Given клиент прислал устаревший
rowVersion, When PUT, Then отказ
FAVORITES_CONCURRENCY_CONFLICT (409) без мутаций.
- Given две параллельные правки заметки одной записи (обе с валидным
rowVersion), When обе
фиксируются, Then одна побеждает, проигравшая получает FAVORITES_CONCURRENCY_CONFLICT (409) и должна
перечитать GET /favorites.
- Given запрос без JWT и без валидного
X-Anonymous-Id, When PUT, Then отказ
FAVORITES_OWNER_UNRESOLVED (400).
Совместимость и наблюдаемость
rowVersion — optional additive поле; клиенты без него работают в режиме last-writer-wins.
- Logs: info
FavoriteNoteUpdated {FavoriteId} {HasNote} — текст заметки не логируется никогда.
- Metrics:
favorites_note_update_total{owner_type, cleared}, favorites_note_conflicts_total (409).
- Traces: сквозной correlation; dashboard/alerts/runbook — ../../README.md.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы