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

PUT /api/v1/favorites/{propertyObjectId}/note — Установить/очистить заметку к объекту недвижимости

Полностью заменяет личную заметку (Note, ≤ 4000 символов, INV-FAV-003) на записи избранного текущего владельца. note = null или пустая строка — очистка заметки. PUT-семантика: передаётся целевое значение, а не diff.

Назначение

Поле «собственная заметка к объекту недвижимости» на странице избранного: владелец редактирует свой комментарий к сохранённому объекту недвижимости (до 4000 символов) или очищает его. Заметка видна только владельцу и никогда не покидает контекст favorites (../../domain/favorite.md).

Metadata

ПолеЗначение
Сервис/контекстfavorites
API areapublic
Feature groupfavorites
Статусdraft
Документdocs/06-services/favorites/api/public/update-favorite-note.md

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

ПроверкаЗначение
Actor typeAnonymous (гость по X-Anonymous-Id) или User (JWT)
Auth policy / Permissionsnone (self-scope)
Scope (RBAC)не применяется
Record-levelзапись ищется строго с предикатом владельца (INV-FAV-005); чужая запись → 404 FAVORITES_NOT_FOUND (существование не раскрывается)
Tenant isolationне применяется — сущности вне tenant (ADR-0052 п.2)
Auditupdate: UpdatedAt обновляется; текст заметки в аудит/логи/события не попадает

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/v1/favorites/{propertyObjectId}/note
Success status200
Idempotency headerне применяется — PUT естественно идемпотентен (повтор с тем же значением — no-op)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity (JWT claim sub)Guid?Условнозаполнен при авторизованном запросе; приоритет над AnonymousIdвладелец — только сам актор
AnonymousIdheader X-Anonymous-IdGuid?Условновалидный Guid ≠ Guid.Empty; используется при отсутствии JWTнедоверенный вход
PropertyObjectIdroute {propertyObjectId}GuidДа!= Guid.Emptyключ поиска записи владельца
Notebody notestring?Нетпосле trim — null или 1..4000 символов; пустая строка нормализуется в null (INV-FAV-003)приватные данные: не логируются, не попадают в события
RowVersionbody rowVersionuint?Нетесли передан — сверяется с версией записизащита от потерянного обновления между вкладками/устройствами

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

Проекция та же, что в add-favorite.md; дельта: WasCreated всегда false.

ПолеТипОбязательностьИсточникMaskingОписание
FavoriteIdGuidДаFavorite.IdИдентификатор записи.
PropertyObjectIdGuidДаFavorite.PropertyObjectIdОбъект недвижимости.
Notestring?НетFavorite.Noteвидна только владельцуЗаметка после операции (null — очищена).
CreatedAtDateTimeOffsetДаFavorite.CreatedAtДата добавления.
UpdatedAtDateTimeOffsetДаFavorite.UpdatedAtОбновлена этой операцией (участвует в LastWriterWins при слиянии — ../../domain/merge-strategy.md).
WasCreatedboolДаконстанта falseЗапись не создавалась.

Алгоритм

  1. Контекст и доступ. Разрешить владельца: UserId из JWT, иначе гостевая сессия AnonymousId из заголовка. Не разрешён ни тот, ни другой — отказ FAVORITES_OWNER_UNRESOLVED.
  2. Проверка входа. PropertyObjectId != Guid.Empty; Notenull или 1..4000 символов после trim, иначе отказ FAVORITES_NOTE_TOO_LONG (INV-FAV-003). Статуса у Favorite нет.
  3. Что читаем. Запись владельца по PropertyObjectId (предикат жёстко включает владельца). Записи нет → отказ FAVORITES_NOT_FOUND (запись не существует или чужая — не различаем, INV-FAV-005). Если клиент прислал RowVersion и он не совпадает с версией записи — немедленно отказ FAVORITES_CONCURRENCY_CONFLICT без мутаций.
  4. Бизнес-правила и маппинг. Нормализация: пустая/пробельная заметка → null, иначе trim. Если нормализованное значение равно текущей заметке — no-op (изменений и событий нет, сразу результат). Иначе заметка заменяется (проверка INV-FAV-003), поднимается доменное событие FavoriteNoteUpdatedEvent (FavoriteId, HasNote — текст заметки в событие не входит); UpdatedAt обновляется.
  5. Что меняется. Обновляется заметка записи. Конкурентная защита — оптимистическая блокировка по версии записи: при параллельной правке с другого устройства фиксация конфликтует → отказ FAVORITES_CONCURRENCY_CONFLICT.
  6. Согласованность. Интеграционные события не публикуются: правка заметки — приватное действие, не попадает в ленту действий user-activity (Kafka-события определены только для add/remove/merge — ../../domain/favorite.md). Доменное FavoriteNoteUpdatedEvent — только в памяти процесса.
  7. Результат. Модель FavoriteResult из актуального состояния записи; Note = null при очистке. HTTP 200 OK (и для no-op ветки — целевое состояние уже достигнуто).

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
FAVORITES_OWNER_UNRESOLVED400Нет ни JWT, ни валидного X-Anonymous-Id«Не удалось определить владельца избранного»no retry
FAVORITES_NOTE_TOO_LONG400Note после trim длиннее 4000 символов (INV-FAV-003)«Заметка не может превышать 4000 символов»no retry
ValidationError400PropertyObjectId = Guid.Empty / невалидный bodyдетали валидацииno retry
FAVORITES_NOT_FOUND404Записи владельца по PropertyObjectId нет (или запись чужая — не различается, INV-FAV-005)«Объект недвижимости не найден в избранном»no retry (сначала POST /favorites)
FAVORITES_CONCURRENCY_CONFLICT409Версия записи изменилась между чтением и фиксацией«Заметка изменена с другого устройства — обновите страницу»retry после перечитывания GET /favorites
InternalError500Отказ хранилищаgeneric messageretry — 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.

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

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

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

  • Сущность: ../../domain/favorite.md (INV-FAV-003, INV-FAV-005, FavoriteNoteUpdatedEvent).
  • Слияние заметок при авторизации: ../../domain/merge-strategy.md (LastWriterWins использует UpdatedAt, который обновляет эта операция).
  • Соседние операции: add-favorite.md (дом модели FavoriteResult).