Entity / Aggregate: Favorite
Дом полей сущности «Избранное» (лайк объекта недвижимости + личная заметка) контекста favorites. Все проекции
(модели ответов API, payload Kafka-событий, ActionEvent в user-activity) ссылаются сюда и перечисляют
только дельту.
Назначение
Favorite — отметка «нравится» на объекте недвижимости с необязательной личной заметкой
(≤ 4000 символов), принадлежащая либо авторизованному пользователю (UserId), либо гостевой сессии
(AnonymousId). Гость (аноним) добавляет объекты недвижимости в избранное до входа; после авторизации
гостевые записи участвуют в слиянии по одной из трёх стратегий (merge-strategy.md).
Избранное доступно владельцу со всех устройств: пользователь видит один и тот же набор в любом сеансе.
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст favorites, таблица favorites.favorites |
Классификация
| Поле | Значение |
|---|---|
| Контекст | favorites |
| Kind | AggregateRoot (маленький агрегат из одной сущности) |
| Source of truth | favorites (этот контекст) |
| Таблица | favorites.favorites |
| Tenant isolation | не tenanted — данные инвестора/гостя вне tenant (ADR-0052 п.2); изоляция record-level по UserId/AnonymousId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор записи избранного. |
UserId | Guid? | Условно (см. INV-FAV-001) | входит в partial-unique ux_favorites_user_property_object; логическая ссылка identity.users(id) — без физической FK (чужой контекст) | JWT (claim sub) | Владелец-пользователь. null, пока запись гостевая. |
AnonymousId | Guid? | Условно (см. INV-FAV-001) | входит в partial-unique ux_favorites_anonymous_property_object; логическая ссылка на сессию user-activity — без FK | заголовок X-Anonymous-Id (выдаёт user-activity) | Владелец-гостевая сессия. null, если запись принадлежит пользователю. |
PropertyObjectId | Guid | Да | index ix_favorites_property_object_id; логическая ссылка на объект недвижимости chessboard/catalog — без FK | app (запрос владельца) | Объект недвижимости, добавленный в избранное. Существование объекта недвижимости синхронно не проверяется (см. INV-FAV-004). |
Note | string? | Нет | varchar(4000), check chk_favorites_note_len (char_length(note) <= 4000) | app (запрос владельца) | Личная заметка-комментарий к объекту недвижимости. Видна только владельцу; в Kafka-события не попадает. |
CreatedAt | DateTimeOffset | Да | index ix_favorites_created_at (сортировка списка, retention гостевых) | app | Момент добавления в избранное. |
UpdatedAt | DateTimeOffset | Да | — | 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) | PropertyObjectId | display-данные объекта недвижимости подтягивает фронт/каталог батч-запросом в catalog по списку PropertyObjectId; сам favorites объекты недвижимости не хранит и не реплицирует | — (снятый с продажи/скрытый объект недвижимости остаётся в избранном, каталог отдаёт его с признаком недоступности) |
Favorite ← FavoritesMergeOperation | audit-reference | UserId+AnonymousId | merge-strategy.md | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-FAV-001 OwnerXor | Заполнен ровно один из UserId / AnonymousId | при создании записи + DB check chk_favorites_owner_xor | FAVORITES_OWNER_AMBIGUOUS (500 — программная ошибка, недостижимо через API) |
| INV-FAV-002 UniquePerOwner | Одна запись на пару «владелец + объект недвижимости»: unique (UserId, PropertyObjectId) и (AnonymousId, PropertyObjectId) | при добавлении: если запись владельца на этот объект недвижимости уже есть → идемпотентный успех (обновляется только Note, если передана); страховка — partial-unique индексы | — (идемпотентность вместо ошибки) |
| INV-FAV-003 NoteLength | Note — null или 1..4000 символов после trim; пустая строка нормализуется в null | при установке/правке заметки + DB check | FAVORITES_NOTE_TOO_LONG (ValidationError) |
| INV-FAV-004 PropertyObjectIdTrusted | PropertyObjectId — валидный Guid; существование объекта недвижимости синхронно не проверяется (simplicity-first, ADR-0056: нет sync-вызова в catalog на каждый лайк). Несуществующие/скрытые объекты недвижимости отфильтровываются на выдаче списка батч-запросом фронта в catalog | проверка формата Guid | ValidationError |
| 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 = null | FavoriteAddedKafkaEvent (topic pin.favorites.favorite-added.v1, key FavoriteId) |
| Создана → Заметка изменена | владелец (update-note) | нормализованная заметка ≠ текущей (INV-FAV-003); при переданном RowVersion — совпадение версии | Note заменён (или null при очистке); UpdatedAt = now | FavoriteNoteUpdatedEvent — доменное, без Kafka (правка заметки приватна) |
| Гостевая → Пользовательская (смена владельца) | операция слияния / импорт (INV-FAV-006) | стратегия переносит S-запись; целевой пары (UserId, PropertyObjectId) ещё нет | AnonymousId → null, UserId := actor, UpdatedAt := now | FavoritesMergedKafkaEvent — один на операцию (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) |
| Audit | CreatedAt/UpdatedAt; факт add/remove — события в user-activity (ActionEvent); слияния — FavoritesMergeOperation |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: ActionEvent (TPH-иерархия событий действий), Entity / Aggregate: Selection (+ child SelectionItem), Merge Strategy: слияние избранного при авторизации (device-wins / account-wins / merge)
- API (10): DELETE /api/v1/favorites/{propertyObjectId} — Удалить объект недвижимости из избранного, Favorites API — индекс операций, GET /api/v1/favorites — Список избранного владельца, GET /internal/favorites — Чтение избранного пользователя (m2m для selections), POST /api/agent/crm/leads/{leadId}/property-object-context — Контекст инвестора по объектам недвижимости, POST /api/v1/favorites — Добавить объект недвижимости в избранное, POST /api/v1/favorites/import — Bulk-импорт избранного с устройства, POST /api/v1/selections/from-favorites — Создать подборку из текущего избранного, POST /internal/favorites/status — Пакетный статус избранного (3-state, m2m для CRM), PUT /api/v1/favorites/{propertyObjectId}/note — Установить/очистить заметку к объекту недвижимости
- Use Cases (6): UC-CRM-019. Агент просматривает каталог в контексте инвестора активного лида, UC-FAV-001. Гость добавляет объект недвижимости в избранное (гостевая сессия), UC-FAV-002. Авторизация → слияние гостевого и аккаунтного избранного (3 стратегии), UC-FAV-003. Заметка к объекту недвижимости в избранном (установка/правка/очистка), UC-FAV-004. Первичная миграция избранного с устройства (localStorage → бэкенд), UC-SEL-001. Создание подборки из текущего избранного
- События (4): Event Contract: pin.favorites.favorite-added (FavoriteAddedKafkaEvent), Event Contract: pin.favorites.favorite-removed (FavoriteRemovedKafkaEvent), Event Contract: pin.favorites.favorites-merged (FavoritesMergedKafkaEvent), Event Contract: pin.selections.selection-created (SelectionCreatedKafkaEvent)
- Консюмеры (2): Consumer: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer), Consumers контекста favorites: решение «consumed-событий нет»
- Фоновые задачи (1): Background Job: GuestFavoritesCleanupJob (retention гостевого избранного)
- index.md (1): Favorites Service (Pin.Favorites)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- 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-данных объектов недвижимости).