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

Entity / Aggregate: Favorite

Дом полей сущности «Избранное» (лайк объекта недвижимости + личная заметка) контекста favorites. Все проекции (модели ответов API, payload Kafka-событий, ActionEvent в user-activity) ссылаются сюда и перечисляют только дельту.

Назначение

Favorite — отметка «нравится» на объекте недвижимости с необязательной личной заметкой (≤ 4000 символов), принадлежащая либо авторизованному пользователю (UserId), либо гостевой сессии (AnonymousId). Гость (аноним) добавляет объекты недвижимости в избранное до входа; после авторизации гостевые записи участвуют в слиянии по одной из трёх стратегий (merge-strategy.md). Избранное доступно владельцу со всех устройств: пользователь видит один и тот же набор в любом сеансе.

MetadataЗначение
Owning teamPIN Engagement
Статус документаdraft
Область действияконтекст favorites, таблица favorites.favorites

Классификация

ПолеЗначение
Контекстfavorites
KindAggregateRoot (маленький агрегат из одной сущности)
Source of truthfavorites (этот контекст)
Таблицаfavorites.favorites
Tenant isolationне tenanted — данные инвестора/гостя вне tenant (ADR-0052 п.2); изоляция record-level по UserId/AnonymousId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор записи избранного.
UserIdGuid?Условно (см. INV-FAV-001)входит в partial-unique ux_favorites_user_property_object; логическая ссылка identity.users(id)без физической FK (чужой контекст)JWT (claim sub)Владелец-пользователь. null, пока запись гостевая.
AnonymousIdGuid?Условно (см. INV-FAV-001)входит в partial-unique ux_favorites_anonymous_property_object; логическая ссылка на сессию user-activity — без FKзаголовок X-Anonymous-Id (выдаёт user-activity)Владелец-гостевая сессия. null, если запись принадлежит пользователю.
PropertyObjectIdGuidДаindex ix_favorites_property_object_id; логическая ссылка на объект недвижимости chessboard/catalog — без FKapp (запрос владельца)Объект недвижимости, добавленный в избранное. Существование объекта недвижимости синхронно не проверяется (см. INV-FAV-004).
Notestring?Нетvarchar(4000), check chk_favorites_note_len (char_length(note) <= 4000)app (запрос владельца)Личная заметка-комментарий к объекту недвижимости. Видна только владельцу; в Kafka-события не попадает.
CreatedAtDateTimeOffsetДаindex ix_favorites_created_at (сортировка списка, retention гостевых)appМомент добавления в избранное.
UpdatedAtDateTimeOffsetДаappПоследнее изменение (заметка, смена владельца при слиянии). Используется правилом LastWriterWins (merge-strategy.md).

CHECK-констрейнт владельца: chk_favorites_owner_xor(user_id IS NULL) <> (anonymous_id IS NULL) (ровно один владелец, INV-FAV-001).

Запись версионируется для оптимистической блокировки: параллельная правка заметки с двух вкладок/устройств не должна затирать чужую (см. «Индексы, хранение, безопасность» → Concurrency). Токен версии передаётся клиенту как RowVersion в операции правки заметки.

Value objects

Value objects не используются: сущность плоская, все поля скалярные.

Модели JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
Favorite → UserEntity (identity)логическая reference (кросс-контекст, без FK)UserIdне загружается — фильтр record-level по актору— (identity не каскадирует сюда; блокировка пользователя не трогает избранное)
Favorite → Session (user-activity)логическая reference (кросс-контекст, без FK)AnonymousIdне загружается — приходит заголовком— (протухшие гостевые записи чистит retention-job)
Favorite → PropertyObject (chessboard/catalog)логическая reference (кросс-контекст, без FK)PropertyObjectIddisplay-данные объекта недвижимости подтягивает фронт/каталог батч-запросом в catalog по списку PropertyObjectId; сам favorites объекты недвижимости не хранит и не реплицирует— (снятый с продажи/скрытый объект недвижимости остаётся в избранном, каталог отдаёт его с признаком недоступности)
Favorite ← FavoritesMergeOperationaudit-referenceUserId+AnonymousIdmerge-strategy.md

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-FAV-001 OwnerXorЗаполнен ровно один из UserId / AnonymousIdпри создании записи + DB check chk_favorites_owner_xorFAVORITES_OWNER_AMBIGUOUS (500 — программная ошибка, недостижимо через API)
INV-FAV-002 UniquePerOwnerОдна запись на пару «владелец + объект недвижимости»: unique (UserId, PropertyObjectId) и (AnonymousId, PropertyObjectId)при добавлении: если запись владельца на этот объект недвижимости уже есть → идемпотентный успех (обновляется только Note, если передана); страховка — partial-unique индексы— (идемпотентность вместо ошибки)
INV-FAV-003 NoteLengthNotenull или 1..4000 символов после trim; пустая строка нормализуется в nullпри установке/правке заметки + DB checkFAVORITES_NOTE_TOO_LONG (ValidationError)
INV-FAV-004 PropertyObjectIdTrustedPropertyObjectId — валидный Guid; существование объекта недвижимости синхронно не проверяется (simplicity-first, ADR-0056: нет sync-вызова в catalog на каждый лайк). Несуществующие/скрытые объекты недвижимости отфильтровываются на выдаче списка батч-запросом фронта в catalogпроверка формата GuidValidationError
INV-FAV-005 OwnerOnlyMutationЧитать/менять/удалять запись может только её владелец: UserId = actor.UserId либо AnonymousId = заголовок X-Anonymous-Id (для гостя)record-level фильтр в каждой операции (обязателен, т.к. сущности вне tenant — ADR-0052)FAVORITES_NOT_FOUND (404 — существование чужой записи не раскрывается)
INV-FAV-006 MergeOnlyСмена владельца записи (AnonymousId → UserId) возможна только операцией слияния/импорта (merge-strategy.md); произвольный «перенос» API не предоставляетсяотсутствие мутатора; операция слияния

Диаграмма состояний неприменима: у сущности нет поля Status — жизненный цикл ограничен созданием, правкой заметки, сменой владельца при слиянии и физическим удалением (см. erDiagram выше и sequenceDiagram слияния в merge-strategy.md).

Жизненный цикл записи (переходы без поля Status)

ПереходИнициаторУсловиеЭффект над записьюСобытие
Нет записи → Созданавладелец (add / import)пары «владелец + объект недвижимости» ещё нет (INV-FAV-002); ровно один владелец (INV-FAV-001)новая запись; CreatedAt = UpdatedAt = now; при add — Note из запроса, при import — Note = nullFavoriteAddedKafkaEvent (topic pin.favorites.favorite-added.v1, key FavoriteId)
Создана → Заметка измененавладелец (update-note)нормализованная заметка ≠ текущей (INV-FAV-003); при переданном RowVersion — совпадение версииNote заменён (или null при очистке); UpdatedAt = nowFavoriteNoteUpdatedEventдоменное, без Kafka (правка заметки приватна)
Гостевая → Пользовательская (смена владельца)операция слияния / импорт (INV-FAV-006)стратегия переносит S-запись; целевой пары (UserId, PropertyObjectId) ещё нетAnonymousId → null, UserId := actor, UpdatedAt := nowFavoritesMergedKafkaEvent — один на операцию (topic pin.favorites.favorites-merged.v1, key UserId); отдельных add/remove на запись нет
Любая → Удалена (пользователем)владелец (remove)владелец снял лайкфизическое удаление (soft delete не применяется)FavoriteRemovedKafkaEvent (topic pin.favorites.favorite-removed.v1, key FavoriteId)
Гостевая → Удалена (retention)retention-job (только user_id IS NULL)updated_at < now − RetentionDays (365 дней)физическое удалениесобытий нет — техническая чистка не идёт в ленту действий (../background/guest-favorites-cleanup.md)

Переход «смена владельца» и «удаление по слиянию» никогда не пересекают чужого владельца (INV-FAV-105, merge-strategy.md); произвольный перенос владельца вне слияния/импорта не предоставляется (INV-FAV-006).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
FavoriteAddedEventдоменноеУспешное создание новой записиFavoriteId, UserId?, AnonymousId?, PropertyObjectId
FavoriteRemovedEventдоменноеУспешное удаление записиFavoriteId, UserId?, AnonymousId?, PropertyObjectId
FavoriteNoteUpdatedEventдоменноеПравка заметки изменила значениеFavoriteId, HasNote (текст заметки в события не попадает — приватные данные)
FavoriteAddedKafkaEventинтеграционное (ContractVersion = 1, topic pin.favorites.favorite-added.v1, AggregateId = FavoriteId, AggregateType = "Favorite")Тот же переход, публикуется атомарно со сменой состоянияFavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAt. Consumers: user-activity (actions → ActionEvent «добавлено в избранное» → карточка лида CRM, ADR-0054)
FavoriteRemovedKafkaEventинтеграционное (ContractVersion = 1, topic pin.favorites.favorite-removed.v1, AggregateId = FavoriteId)Удаление из избранногоFavoriteId, UserId?, AnonymousId?, PropertyObjectId, OccurredAt. Consumers: user-activity (actions)
FavoritesMergedKafkaEventинтеграционное (ContractVersion = 1, topic pin.favorites.favorites-merged.v1, AggregateId = MergeOperationId)Завершение операции слияниясм. merge-strategy.md — payload из FavoritesMergeOperation

Интеграционные события публикуются атомарно со сменой состояния агрегата. Note никогда не включается в интеграционные события (личные данные владельца).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK btree(id); ux_favorites_user_property_object unique btree(user_id, property_object_id) where user_id is not null; ux_favorites_anonymous_property_object unique btree(anonymous_id, property_object_id) where anonymous_id is not null (оба покрывают выборку списка по владельцу); ix_favorites_property_object_id btree(property_object_id) — служебная аналитика «сколько раз объект недвижимости в избранном»; ix_favorites_created_at btree(created_at) — retention-job гостевых записей
Партиционирование / retentionБез партиционирования (объём: ≤ 1 млн пользователей × десятки записей — ADR-0056). Retention: background job guest-favorites-cleanup раз в сутки удаляет гостевые записи (user_id is null) с updated_at < now() - 365 дней; пользовательские записи не чистятся
ConcurrencyОптимистическая блокировка (правка не должна затирать чужую) — параллельная правка заметки; unique-индексы — страховка гонки двойного add
Permissions / PIIДоступ — только self (guest по X-Anonymous-Id, user по JWT); отдельные permissions не требуются (нет привилегированных операций). Note — приватные данные владельца: не логируется, не попадает в события, недоступна агенту/менеджеру (агент видит только факт «добавил в избранное» через user-activity)
AuditCreatedAt/UpdatedAt; факт add/remove — события в user-activity (ActionEvent); слияния — FavoritesMergeOperation

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

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

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

  • merge-strategy.md — три стратегии слияния при авторизации + FavoritesMergeOperation.
  • ../README.md — границы контекста, карта API.
  • ADR-0052 (tenancy: инвестор вне tenant), ADR-0054 (user-activity — потребитель событий), ADR-0056 (simplicity-first: PostgreSQL-only, без Redis).
  • Смежные (будущие пути): docs/06-services/user-activity/README.md (anonymous_id, ActionEvent), docs/06-services/catalog/README.md (батч display-данных объектов недвижимости).