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

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 teamPIN Engagement
Статус документаdraft
Область действияконтекст user-activity, таблица user_activity.property_object_views
Change contextgreenfield

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

ПолеЗначение
Контекстuser-activity
KindAggregateRoot (маленький агрегат из одной сущности)
Source of truthuser-activity (этот контекст)
Таблицаuser_activity.property_object_views
Tenant isolationне tenanted (ADR-0052 п.2); record-level по UserId/AnonymousId/SessionId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKapp (генерируется, сортируемый по времени)Идентификатор просмотра.
SessionIdGuidДа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-запросе)Сессия, в которой открыт объект недвижимости.
AnonymousIdGuidДа— (денормализация для выдачи без join)copy из Session.AnonymousId при созданииУстройство-владелец.
UserIdGuid?Нетix_property_object_views_user btree(user_id, created_at desc) where user_id is not null; логическая ссылка identity.users(id) без FKcopy из Session.UserId при создании (снимок на момент открытия)Пользователь на момент открытия. null — гостевой просмотр; просмотры до входа находятся через связанную сессию (правило чтения ниже).
PropertyObjectIdGuidДаix_property_object_views_property_object btree(property_object_id, created_at desc); логическая ссылка на объект недвижимости chessboard/catalog — без FKapp (запрос фронта)Просматриваемый объект. Существование синхронно не проверяется (INV-LV-006, как favorites INV-FAV-004).
ViewSourceViewSourceДаvarchar(16), enum как text: Catalog / Search / Selection / Favorites / News / DirectLink / Unknownapp (запрос фронта; недоверенный — вне enum → Unknown)Откуда открыта карточка.
ApplyFilterEventIdGuid?НетFK user_activity.action_events(id) (SetNull); ix_property_object_views_filter_event btree where not nullapp: последний ApplyFilterActionEvent текущей сессии на момент открытия (по SessionId + ActionType = ApplyFilter, OccurredAt max)Связь «просмотрен по фильтру». null — просмотр без применённого фильтра (прямая ссылка, подборка).
FilterSnapshotAppliedFilterModel? (jsonb)Нетjsonb; typed-модель — дом в action-event.md; ContractVersion = 1copy из связанного ApplyFilterActionEvent.Filter при созданииИммутабельный снимок фильтра на момент открытия (INV-LV-005) — история читается без join даже после retention событий. nullApplyFilterEventId = null при создании.
StatusPropertyObjectViewStatusДаvarchar(16), enum как text; partial-индексыapp (Active) / worker (Inactive)Открыта ли карточка.
CreatedAtDateTimeOffsetДав составе индексов сортировкиapp (now() сервера)Момент открытия карточки объекта недвижимости.
LastActivityAtDateTimeOffsetДаpartial ix_property_object_views_active_last_activity btree(last_activity_at) where status = 'Active'app: heartbeat карточкиПоследний сигнал «карточка открыта» (heartbeat раз в 30–60 с).
EndedAtDateTimeOffset?Нет (до закрытия)worker activity-closerМомент закрытия записи.
DurationTimeSpan?Нет (до закрытия)PG interval; check chk_property_object_views_duration_nonnegworker: 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 → Sessionreference (внутри контекста, FK)SessionIdсессия читается при создании (ensure Active); при закрытии worker берёт актуальный UserId для payload событияRestrict (retention удаляет property_object_views раньше sessions)
PropertyObjectView → ApplyFilterActionEventreference (внутри контекста, FK)ApplyFilterEventIdне загружается при чтении (снимок в FilterSnapshot)SetNull (retention событий не ломает историю просмотров)
PropertyObjectView → PropertyObject (chessboard/catalog)логическая reference (кросс-контекст, без FK)PropertyObjectIddisplay-данные объекта недвижимости фронт добирает батчем в 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 HeartbeatOnlyActiveHeartbeat обновляет только 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 FilterSnapshotImmutableApplyFilterEventId/FilterSnapshot фиксируются при создании и не обновляются (смена фильтра при открытой карточке — контекстом остаётся фильтр, по которому объект недвижимости был найден)отсутствие мутатора
INV-LV-006 PropertyObjectIdTrustedPropertyObjectId — валидный 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 InactiveImmutableInactive-запись не изменяется (идемпотентность worker-а: обрабатываются только Active-просмотры)worker

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

СобытиеТипКогдаSnapshot/поля
PropertyObjectViewStartedEventдоменноеСоздание записиPropertyObjectViewId, SessionId, PropertyObjectId — по нему в том же согласованном изменении создаётся ViewPropertyObjectActionEvent (action-event.md)
PropertyObjectViewEndedEventдоменноеActive → InactivePropertyObjectViewId, 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)
ConcurrencyHeartbeat — условное обновление по предикату Status = Active; worker берёт Active-строки под блокировкой с пропуском занятых; partial-unique — гонка двойного open
Permissions / PIISelf-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 и длительности)
AuditAppend-only жизненный цикл (CreatedAt/EndedAt); факт просмотра дублируется ViewPropertyObjectActionEvent; закрытия воспроизводимы по property-object-view-ended

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

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

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

  • session.md — родительская сессия и механизм heartbeat/закрытия; property-object-view-stats.md — агрегаты по объекту недвижимости; action-event.mdViewPropertyObjectActionEvent, дом 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 (facts dev-stats).
  • Смежные: ../../dev-stats/README.md (consumer), ../../crm/README.md (карточка лида), ../../catalog/README.md (display-данные объектов недвижимости).