Entity / Aggregate: PropertyObjectView
Дом полей сущности «Просмотр объекта недвижимости» контекста user-activity (модуль views). Проекции (payload
pin.user-activity.property-object-view-ended.v1, история просмотров в API, facts dev-stats) ссылаются сюда и
перечисляют только дельту.
Назначение
PropertyObjectView — запись одного открытия карточки объекта недвижимости в рамках сессии, с измерением
длительности просмотра и контекстом применённого фильтра (какие объекты были просмотрены в рамках
этих фильтров). Пользователь видит свою историю; администратор платформы / менеджер / агент — по id инвестора.
Жизненный цикл повторяет Session: открытие карточки создаёт Active-запись, пока карточка
открыта фронт шлёт heartbeat (LastActivityAt), закрывает запись фоновый polling-worker activity-closer по
порогу неактивности; Duration = LastActivityAt - CreatedAt. Событие закрытия — источник facts dev-stats
(ADR-0057).
| Metadata | Значение |
|---|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст user-activity, таблица user_activity.property_object_views |
| Change context | greenfield |
Классификация
| Поле | Значение |
|---|---|
| Контекст | user-activity |
| Kind | AggregateRoot (маленький агрегат из одной сущности) |
| Source of truth | user-activity (этот контекст) |
| Таблица | user_activity.property_object_views |
| Tenant isolation | не tenanted (ADR-0052 п.2); record-level по UserId/AnonymousId/SessionId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app (генерируется, сортируемый по времени) | Идентификатор просмотра. |
SessionId | Guid | Да | FK user_activity.sessions(id) (Restrict); ix_property_object_views_session btree(session_id, created_at); partial-unique ux_property_object_views_active_session_property_object (session_id, property_object_id) where status = 'Active' (INV-LV-001) | app (активная сессия, ensure при track-запросе) | Сессия, в которой открыт объект недвижимости. |
AnonymousId | Guid | Да | — (денормализация для выдачи без join) | copy из Session.AnonymousId при создании | Устройство-владелец. |
UserId | Guid? | Нет | ix_property_object_views_user btree(user_id, created_at desc) where user_id is not null; логическая ссылка identity.users(id) без FK | copy из Session.UserId при создании (снимок на момент открытия) | Пользователь на момент открытия. null — гостевой просмотр; просмотры до входа находятся через связанную сессию (правило чтения ниже). |
PropertyObjectId | Guid | Да | ix_property_object_views_property_object btree(property_object_id, created_at desc); логическая ссылка на объект недвижимости chessboard/catalog — без FK | app (запрос фронта) | Просматриваемый объект. Существование синхронно не проверяется (INV-LV-006, как favorites INV-FAV-004). |
ViewSource | ViewSource | Да | varchar(16), enum как text: Catalog / Search / Selection / Favorites / News / DirectLink / Unknown | app (запрос фронта; недоверенный — вне enum → Unknown) | Откуда открыта карточка. |
ApplyFilterEventId | Guid? | Нет | FK user_activity.action_events(id) (SetNull); ix_property_object_views_filter_event btree where not null | app: последний ApplyFilterActionEvent текущей сессии на момент открытия (по SessionId + ActionType = ApplyFilter, OccurredAt max) | Связь «просмотрен по фильтру». null — просмотр без применённого фильтра (прямая ссылка, подборка). |
FilterSnapshot | AppliedFilterModel? (jsonb) | Нет | jsonb; typed-модель — дом в action-event.md; ContractVersion = 1 | copy из связанного ApplyFilterActionEvent.Filter при создании | Иммутабельный снимок фильтра на момент открытия (INV-LV-005) — история читается без join даже после retention событий. null ⇔ ApplyFilterEventId = null при создании. |
Status | PropertyObjectViewStatus | Да | varchar(16), enum как text; partial-индексы | app (Active) / worker (Inactive) | Открыта ли карточка. |
CreatedAt | DateTimeOffset | Да | в составе индексов сортировки | app (now() сервера) | Момент открытия карточки объекта недвижимости. |
LastActivityAt | DateTimeOffset | Да | partial ix_property_object_views_active_last_activity btree(last_activity_at) where status = 'Active' | app: heartbeat карточки | Последний сигнал «карточка открыта» (heartbeat раз в 30–60 с). |
EndedAt | DateTimeOffset? | Нет (до закрытия) | — | worker activity-closer | Момент закрытия записи. |
Duration | TimeSpan? | Нет (до закрытия) | PG interval; check chk_property_object_views_duration_nonneg | worker: LastActivityAt - CreatedAt | Длительность просмотра объекта недвижимости. |
Правило чтения истории по пользователю: выборка возвращает просмотры, у которых UserId равен id
пользователя или SessionId входит в множество сессий пользователя (сессии с этим UserId, см.
Session). Так гостевые просмотры, сделанные до входа в той же (позже связанной) сессии,
попадают в историю пользователя без backfill-а UserId (simplicity-first, ADR-0056).
Value objects
VO не используются: контекст фильтра хранится typed jsonb (FilterSnapshot), остальные поля скалярные.
Модели JSONB
FilterSnapshot → модель AppliedFilterModel (ContractVersion = 1). Дом модели — раздел
«Модели JSONB» в action-event.md
(происхождение — событие применения фильтра); здесь — та же модель без дельты.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
PropertyObjectView → Session | reference (внутри контекста, FK) | SessionId | сессия читается при создании (ensure Active); при закрытии worker берёт актуальный UserId для payload события | Restrict (retention удаляет property_object_views раньше sessions) |
PropertyObjectView → ApplyFilterActionEvent | reference (внутри контекста, FK) | ApplyFilterEventId | не загружается при чтении (снимок в FilterSnapshot) | SetNull (retention событий не ломает историю просмотров) |
PropertyObjectView → PropertyObject (chessboard/catalog) | логическая reference (кросс-контекст, без FK) | PropertyObjectId | display-данные объекта недвижимости фронт добирает батчем в catalog | — |
PropertyObjectView ← PropertyObjectViewStats | агрегируется консюмером | PropertyObjectId | инкремент по событию property-object-view-ended (property-object-view-stats.md) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-LV-001 SingleActivePerSessionPropertyObject | Не более одной Active-записи на пару (SessionId, PropertyObjectId): повторное открытие того же объекта недвижимости в открытом просмотре — heartbeat, а не новая запись | открытие просмотра; страховка — ux_property_object_views_active_session_property_object | — (идемпотентный успех) |
| INV-LV-002 SessionMustBeActive | Просмотр создаётся только в Active-сессии; если сессии нет/закрыта — track-endpoint сначала выполняет ensure-логику startSession (INV-SES-003) | открытие просмотра | — (авто-создание сессии) |
| INV-LV-003 HeartbeatOnlyActive | Heartbeat обновляет только Active-просмотр; если затронуто 0 строк — карточка считается переоткрытой (создаётся новая запись через open-flow) | обработка heartbeat | — |
| INV-LV-004 CloseComputesDuration | Переход Active → Inactive — только worker activity-closer: в одном согласованном изменении Status → Inactive, EndedAt = now(), Duration = LastActivityAt - CreatedAt, публикация property-object-view-ended (ADR-0056 п.3) | worker | — |
| INV-LV-005 FilterSnapshotImmutable | ApplyFilterEventId/FilterSnapshot фиксируются при создании и не обновляются (смена фильтра при открытой карточке — контекстом остаётся фильтр, по которому объект недвижимости был найден) | отсутствие мутатора | — |
| INV-LV-006 PropertyObjectIdTrusted | PropertyObjectId — валидный Guid; существование объекта недвижимости синхронно не проверяется (ADR-0056: нет sync-вызова в catalog на каждый просмотр); битые id отфильтрует выдача каталога/dev-stats | валидатор (формат Guid) | ValidationError |
| INV-LV-007 NoActiveViewInInactiveSession | У Inactive-сессии нет Active-просмотров: worker в одном прогоне закрывает сначала протухшие property_object_views, затем sessions; при форс-закрытии сессии (INV-SES-002) её Active-просмотры закрываются тем же согласованным изменением | worker / связывание | — |
| INV-LV-008 InactiveImmutable | Inactive-запись не изменяется (идемпотентность worker-а: обрабатываются только Active-просмотры) | worker | — |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
PropertyObjectViewStartedEvent | доменное | Создание записи | PropertyObjectViewId, SessionId, PropertyObjectId — по нему в том же согласованном изменении создаётся ViewPropertyObjectActionEvent (action-event.md) |
PropertyObjectViewEndedEvent | доменное | Active → Inactive | PropertyObjectViewId, PropertyObjectId, Duration |
PropertyObjectViewEndedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.user-activity.property-object-view-ended.v1, AggregateId = PropertyObjectViewId, AggregateType = "PropertyObjectView") | Закрытие worker-ом; публикуется атомарно с закрытием (ADR-0056 п.3) | PropertyObjectViewId, PropertyObjectId, SessionId, AnonymousId, UserId? (актуальный из Session на момент закрытия), ViewSource, ViewedAt (= CreatedAt), LastActivityAt, DurationSeconds (int), FilterSnapshot? (AppliedFilterModel). Consumers: user-activity сам (property-object-view-stats.md — инкремент агрегатов), dev-stats (ClickHouse facts, ADR-0057), crm (лента карточки лида). Контракт — ../events/property-object-view-ended.md |
Интеграционные события публикуются атомарно со сменой состояния агрегата. PII в payload нет (только id).
Снимок на открытии vs. значение в событии закрытия. В строке PropertyObjectView.UserId хранится
значение на момент открытия карточки (для гостя — null), тогда как PropertyObjectViewEndedKafkaEvent
несёт UserId, перечитанный из Session на момент закрытия: если гость вошёл, пока карточка была
открыта, событие атрибутирует просмотр пользователю, хотя строка сохраняет null. Историю это не
рассинхронизирует — чтение по пользователю всё равно находит гостевые строки через связанную сессию
(«Правило чтения» выше), backfill UserId не требуется (simplicity-first, ADR-0056).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree(id); ux_property_object_views_active_session_property_object unique btree(session_id, property_object_id) where status = 'Active'; ix_property_object_views_session btree(session_id, created_at) — история сессии/лида; ix_property_object_views_user btree(user_id, created_at desc) where user_id is not null — история пользователя; ix_property_object_views_property_object btree(property_object_id, created_at desc) — служебные выборки по объекту недвижимости; ix_property_object_views_active_last_activity btree(last_activity_at) where status = 'Active' — выборка worker-а; ix_property_object_views_filter_event btree(apply_filter_event_id) where not null |
| Партиционирование / retention | Без партиционирования (ADR-0056). Retention: job activity-retention удаляет Inactive-записи старше 12 мес (конфиг) до очистки sessions; долгосрочная аналитика (3 года) живёт в dev-stats/ClickHouse (ADR-0057) |
| Concurrency | Heartbeat — условное обновление по предикату Status = Active; worker берёт Active-строки под блокировкой с пропуском занятых; partial-unique — гонка двойного open |
| Permissions / PII | Self-scope владельца (JWT / X-Anonymous-Id). Чужая история по id инвестора: агент/менеджер — через crm в рамках активного лида (m2m internal API, авторизацию лида делает CRM); Менеджер ПИН — permission user-activity.history.view-by-user; администратор платформы — m2m admin API (ADR-0053). PII в записи нет (id и длительности) |
| Audit | Append-only жизненный цикл (CreatedAt/EndedAt); факт просмотра дублируется ViewPropertyObjectActionEvent; закрытия воспроизводимы по property-object-view-ended |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: ActionEvent (TPH-иерархия событий действий), Entity / Aggregate: CatalogPropertyObjectCard, Entity / Aggregate: PropertyObjectViewStats, Entity / Aggregate: Session
- API (9): GET /api/internal/user-activity/property-objects/view-stats — Батч платформенных счётчиков просмотров объектов недвижимости, GET /api/internal/user-activity/users/{userId}/property-object-activity — Активность инвестора по объектам недвижимости (карточка лида CRM), GET /api/internal/user-activity/users/{userId}/property-objects/view-stats — Батч просмотров инвестора по объектам недвижимости (обогащение карточек CRM), GET /api/internal/user-activity/users/{userId}/timeline — Хронология действий пользователя (карточка лида CRM), GET /api/user-activity/me/history — Своя история активности (просмотры + действия), POST /api/user-activity/actions — Зафиксировать действие пользователя (track-action), POST /api/user-activity/property-object-views — Открыть просмотр объекта недвижимости, POST /api/user-activity/sessions/link — Связывание гостевой сессии с пользователем при входе, PUT /api/user-activity/property-object-views/{id}/heartbeat — Продление активности просмотра объекта недвижимости
- Use Cases (6): UC-ACT-001. Трекинг сессии гостя и пользователя, UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости, UC-ACT-003. Инвестор просматривает свою историю просмотров и действий, UC-ACT-004. Агент/менеджер видит активность инвестора (для карточки лида), UC-ACT-005. Слияние гостевой и пользовательской активности при входе, UC-UAC-001. Агрегированная активность нескольких инвесторов-участников лида
- События (2): Event Contract: pin.user-activity.action-tracked (ActionTrackedKafkaEvent), Event Contract: pin.user-activity.property-object-view-ended (PropertyObjectViewEndedKafkaEvent)
- Консюмеры (3): Consumer: Identity Sign-Ins (UserActivitySignedInConsumer), Consumer: User-Activity Actions (crm-user-activity-actions), Consumer: User-Activity Popularity (pin-catalog-popularity)
- Фоновые задачи (3): Background Job: ActivityCloserJob, Background Job: ActivityRetentionJob, Background Job: PopularityRecomputeJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): user-activity Service
Связанные документы
- session.md — родительская сессия и механизм heartbeat/закрытия;
property-object-view-stats.md — агрегаты по объекту недвижимости; action-event.md —
ViewPropertyObjectActionEvent, домAppliedFilterModel. - ../README.md; будущие доки: ../background/activity-closer.md, ../events/property-object-view-ended.md, ../api/ (track-endpoint-ы, история).
- Решения: ADR-0052, ADR-0054 (модуль
views), ADR-0056 п.2–3, ADR-0057 (factsdev-stats). - Смежные:
../../dev-stats/README.md(consumer),../../crm/README.md(карточка лида),../../catalog/README.md(display-данные объектов недвижимости).