Entity / Aggregate: ActionEvent (TPH-иерархия событий действий)
Дом полей TPH-иерархии «Событие действия пользователя» контекста user-activity (модуль actions) и
дом typed-модели AppliedFilterModel (снимок применённого фильтра — на него ссылается
property-object-view.md). Проекции (payload pin.user-activity.action-tracked.v1, лента
/profile/events, лента карточки лида в CRM) ссылаются сюда и перечисляют только дельту.
Назначение
ActionEvent — append-only запись одного действия пользователя/гостя на сайте: применён фильтр каталога
(город/застройщик/проект/ценовой диапазон и т.д.), выполнен поиск, открыт объект недвижимости, добавлено/
удалено избранное, добавлено в подборку. Записи привязываются к сессии; история действий читается по id
пользователя (в том числе гостевые действия «до входа» — через связанную сессию). Лента используется на
странице «История событий» пользователя и в карточке лида у агента.
Иерархия — TPH (одна таблица, дискриминатор ActionType). Подтипы:
| Подтип | ActionType (дискриминатор) | Действие | Откуда возникает |
|---|
ApplyFilterActionEvent | ApplyFilter | Применён фильтр каталога (typed-поля фильтра) | public track-API (POST /tracking/filters) |
SearchActionEvent | Search | Выполнен текстовый поиск | public track-API (POST /tracking/search) |
ViewPropertyObjectActionEvent | ViewPropertyObject | Открыта карточка объекта недвижимости | внутри контекста — тем же согласованным изменением, что создание PropertyObjectView (PropertyObjectViewStartedEvent) |
AddToFavoritesActionEvent | AddToFavorites | Объект недвижимости добавлен в избранное | consumer pin.favorites.favorite-added.v1 |
RemoveFromFavoritesActionEvent | RemoveFromFavorites | Объект недвижимости удалён из избранного | consumer pin.favorites.favorite-removed.v1 |
AddToSelectionActionEvent | AddToSelection | Объект недвижимости добавлен в подборку | consumer pin.selections.selection-item-added.v1 (контракт selections — docs/06-services/selections/events/selection-item-added.md) |
Частота ApplyFilter/Search (дебаунс на фронте). На витрине каталога кнопки «Найти» нет — фильтры
применяются на лету: фронт дебаунсит изменения фильтра 200 мс и по срабатыванию перезапрашивает выдачу.
Трекинг же (track-action) шлётся вторым запросом с бо́льшим trailing-дебаунсом 5000–7000 мс —
в ленту action_events попадает только устоявшийся фильтр (после паузы во вводе), а не каждое
промежуточное изменение слайдера/чекбокса. Так одно ApplyFilterActionEvent = один осмысленный набор
параметров (INV-ACT-006 «фильтр не пуст»), а не флуд по бэкенду/БД. Search трекается по факту
выполненного поиска аналогично.
| Metadata | Значение |
|---|
| Owning team | PIN Engagement |
| Статус документа | draft |
| Область действия | контекст user-activity, таблица user_activity.action_events |
| Change context | greenfield |
Классификация
| Поле | Значение |
|---|
| Контекст | user-activity |
| Kind | AggregateRoot (каждое событие — самостоятельный маленький агрегат, immutable после создания) |
| Source of truth | user-activity (для AddToFavorites*/AddToSelection — производная запись от событий favorites/selections; исходный факт владеют те контексты) |
| Таблица | user_activity.action_events (TPH, дискриминатор action_type) |
| Tenant isolation | не tenanted (ADR-0052 п.2); record-level по UserId/AnonymousId/SessionId |
Поля
Базовые поля (все подтипы)
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app (генерируется, сортируемый по времени) | Идентификатор события действия. |
ActionType | ActionEventType | Да | TPH-дискриминатор, varchar(32), enum как text: ApplyFilter / Search / ViewPropertyObject / AddToFavorites / RemoveFromFavorites / AddToSelection; ix_action_events_type btree(action_type, occurred_at) | app (по типу действия) | Тип действия. |
SessionId | Guid | Да | FK user_activity.sessions(id) (Restrict); ix_action_events_session btree(session_id, occurred_at) | app: активная сессия actor-а (track-API — ensure, консюмеры — поиск по AnonymousId/UserId, INV-ACT-003) | Сессия, в которой совершено действие. |
AnonymousId | Guid | Да | — (денормализация) | copy из Session.AnonymousId | Устройство actor-а. |
UserId | Guid? | Нет | ix_action_events_user btree(user_id, occurred_at desc) where user_id is not null; логическая ссылка identity.users(id) без FK | copy из Session.UserId на момент действия | Пользователь на момент действия; null — гость. Чтение истории по пользователю — то же правило через связанные сессии, что у PropertyObjectView («Правило чтения»). |
OccurredAt | DateTimeOffset | Да | ix_action_events_occurred btree(occurred_at) — retention; в составе композитных индексов | app (now() сервера); для consumer-записей — OccurredAt из payload исходного события | Момент действия. |
SourceMessageId | Guid? | Нет (только consumer-записи) | partial-unique ux_action_events_source_message (source_message_id) where source_message_id is not null (INV-ACT-004) | MessageId исходного события | Дедупликация consumer-происхождения (AddToFavorites/RemoveFromFavorites/AddToSelection). null для действий из track-API. |
Подтипные поля (TPH: nullable-колонки одной таблицы)
Колонки подтипов физически nullable (TPH); логическую обязательность в рамках подтипа обеспечивают
required-поля при создании подтипа + check-констрейнт chk_action_events_subtype_fields (по action_type
требуемые колонки IS NOT NULL, см. INV-ACT-002).
ApplyFilterActionEvent (ActionType = ApplyFilter)
| Поле | Тип | Обязательность (в подтипе) | Ограничения | Источник | Описание |
|---|
Filter | AppliedFilterModel | Да | jsonb, колонка filter_snapshot; typed-модель ниже, ContractVersion = 1 | app (запрос фронта, провалидированный) | Полный typed-снимок применённого фильтра (город/застройщик/проект/цена и так далее). |
ResultsCount | int? | Нет | >= 0 | app (запрос фронта: сколько объектов недвижимости выдал каталог) | Размер выдачи по фильтру (полезно агенту: «фильтр без результатов»). |
SearchActionEvent (ActionType = Search)
| Поле | Тип | Обязательность (в подтипе) | Ограничения | Источник | Описание |
|---|
SearchQuery | string | Да | varchar(512), колонка search_query; trim, пустая строка отвергается | app (запрос фронта) | Текст поискового запроса. |
ResultsCount | int? | Нет | >= 0 (общая колонка с ApplyFilter) | app | Размер выдачи по запросу. |
ViewPropertyObjectActionEvent (ActionType = ViewPropertyObject)
| Поле | Тип | Обязательность (в подтипе) | Ограничения | Источник | Описание |
|---|
PropertyObjectId | Guid | Да | колонка property_object_id, ix_action_events_property_object btree where not null; логическая ссылка без FK | PropertyObjectViewStartedEvent.PropertyObjectId | Открытый объект недвижимости. |
PropertyObjectViewId | Guid | Да | колонка property_object_view_id; FK user_activity.property_object_views(id) (SetNull при retention) | PropertyObjectViewStartedEvent.PropertyObjectViewId | Ссылка на запись просмотра — длительность и фильтр-контекст читаются оттуда, здесь не дублируются. |
AddToFavoritesActionEvent (ActionType = AddToFavorites)
| Поле | Тип | Обязательность (в подтипе) | Ограничения | Источник | Описание |
|---|
PropertyObjectId | Guid | Да | общая колонка property_object_id | payload pin.favorites.favorite-added.v1 (PropertyObjectId) | Объект недвижимости, добавленный в избранное. |
FavoriteId | Guid | Да | колонка favorite_id; логическая ссылка на favorites.favorites(id) без FK | payload (FavoriteId) | Запись избранного-источник. Заметка (Note) сюда не попадает — приватна (контракт favorites). |
RemoveFromFavoritesActionEvent (ActionType = RemoveFromFavorites)
| Поле | Тип | Обязательность (в подтипе) | Ограничения | Источник | Описание |
|---|
PropertyObjectId | Guid | Да | общая колонка property_object_id | payload pin.favorites.favorite-removed.v1 | Объект недвижимости, удалённый из избранного. |
FavoriteId | Guid | Да | общая колонка favorite_id | payload | Удалённая запись избранного. |
AddToSelectionActionEvent (ActionType = AddToSelection)
| Поле | Тип | Обязательность (в подтипе) | Ограничения | Источник | Описание |
|---|
PropertyObjectId | Guid | Да | общая колонка property_object_id | payload pin.selections.selection-item-added.v1 (контракт selections) | Объект недвижимости, добавленный в подборку. |
SelectionId | Guid | Да | колонка selection_id; логическая ссылка на подборку selections без FK | payload | Подборка-приёмник. |
Итоговый набор физических колонок таблицы: базовые + filter_snapshot (jsonb), results_count (int),
search_query (varchar 512), property_object_id (uuid), property_object_view_id (uuid), favorite_id (uuid),
selection_id (uuid) — все подтипные nullable.
Value objects
VO не используются: подтипные данные — скалярные колонки TPH + typed jsonb ниже.
Модели JSONB
AppliedFilterModel (jsonb, ContractVersion = 1)
Дом модели — этот раздел. Использования: колонка filter_snapshot здесь; колонка
FilterSnapshot в property-object-view.md; поле FilterSnapshot payload-ов
pin.user-activity.property-object-view-ended.v1 и pin.user-activity.action-tracked.v1. Все поля optional —
пользователь применяет любое подмножество фильтров; минимум одно поле заполнено (INV-ACT-006).
Ссылки на справочники chessboard/pin.handbook — вложенные ref-модели (пара «id + name»); диапазоны —
NullableRange<T>, а не плоские *Min/*Max. Id-поля — логические ссылки с мягкой валидацией:
значения приходят с клиента из public lookup справочника/каталога, существование синхронно не проверяется,
невалидный id не блокирует запись факта; *Name-поля внутри ref-моделей — денормализованные
display-значения на момент применения (карточка лида читается без lookup-ов в каталог).
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
ContractVersion | int | Да | = 1 | v1 |
City | CityRefModel? | Нет | ref-модель города (CityId + CityName); справочник городов pin.handbook (city.md) | v1 |
Developer | DeveloperRefModel? | Нет | ref-модель застройщика (DeveloperId + DeveloperName); справочник chessboard.Developer | v1 |
Project | ProjectRefModel? | Нет | ref-модель проекта (ProjectId + ProjectName); справочник chessboard.Project | v1 |
Price | NullableRange<decimal>? | Нет | ценовой диапазон, RUB; границы включительные, null-граница — открытый интервал; Min <= Max; Min/Max >= 0 | v1 |
Rooms | List<int>? | Нет | значения 0..10 (0 = студия); контейнер — именованная коллекция модели | v1 |
Area | NullableRange<decimal>? | Нет | диапазон площади, м²; границы включительные, null-граница — открытый интервал; Min <= Max; Min/Max >= 0 | v1 |
PropertyObjectStatuses | List<string>? | Нет | значения enum «Статус объекта» chessboard (Free/Reserved/Booked/Sold); unknown-значения отбрасываются валидатором | v1 |
SortBy | string? | Нет | <= 64; код сортировки каталога (напр. price-asc) | v1 |
Вложенные ref-модели (пара «id + name», полные имена полей сохраняются намеренно — модель читается вне
контекста родителя):
| Модель | Поля |
|---|
CityRefModel | CityId: long (справочник городов pin.handbook, city.md), CityName: string (<= 256) |
DeveloperRefModel | DeveloperId: Guid (chessboard.Developer), DeveloperName: string (<= 256) |
ProjectRefModel | ProjectId: Guid (chessboard.Project), ProjectName: string (<= 256) |
CityRefModel.CityId — ссылка на реплику справочника городов pin.handbook
(city.md); значение клиент выбирает из публичного lookup
(lookup-references.md) и передаёт вместе с display-именем.
Валидация мягкая: сервис не резолвит id в handbook на write-path, битый/архивный CityId не блокирует
запись факта (append-only трекинг, ADR-0056) — display карточки лида строится по CityName.
Price/Area — NullableRange<decimal> (VO с границами Min/Max, обе nullable, включительные;
NullableRange<T>.Any — фильтр по диапазону не задан).
Совместимость: additive-only в рамках ContractVersion = 1 (новые optional-поля фильтров каталога —
без bump); breaking (переименование/смена типа) — ContractVersion = 2 + чтение обеих версий на выдаче.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|
ActionEvent → Session | reference (внутри контекста, FK) | SessionId | выборка ленты сессии по SessionId | Restrict (retention-порядок: события → просмотры → сессии) |
ViewPropertyObjectActionEvent → PropertyObjectView | reference (внутри контекста, FK) | PropertyObjectViewId | join в query истории (длительность просмотра) | SetNull |
ApplyFilterActionEvent ← PropertyObjectView | reference (внутри контекста, FK со стороны PropertyObjectView) | PropertyObjectView.ApplyFilterEventId | «объекты, просмотренные по фильтру» — выборка просмотров с этим ApplyFilterEventId | SetNull (см. property-object-view.md) |
AddToFavorites*/RemoveFromFavorites* → Favorite (favorites) | логическая reference (кросс-контекст, без FK) | FavoriteId | не загружается | — |
AddToSelection → Selection (selections) | логическая reference (кросс-контекст, без FK) | SelectionId | не загружается | — |
*_ → PropertyObject (chessboard/catalog) | логическая reference (кросс-контекст, без FK) | PropertyObjectId | display-данные добирает фронт/CRM батчем в catalog | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|
| INV-ACT-001 AppendOnlyImmutable | Событие действия не изменяется и не удаляется поштучно после записи (только retention-job батчами) | отсутствие update/delete-API; записи не обновляются | — |
| INV-ACT-002 SubtypeFieldsRequired | Обязательные поля подтипа заполнены (таблицы выше): ApplyFilter → Filter; Search → SearchQuery; ViewPropertyObject → PropertyObjectId+PropertyObjectViewId; AddToFavorites/RemoveFromFavorites → PropertyObjectId+FavoriteId; AddToSelection → PropertyObjectId+SelectionId | требуется при создании записи подтипа; страховка — DB check chk_action_events_subtype_fields (CASE по action_type) | USER_ACTIVITY_ACTION_INVALID (500 — программная ошибка, недостижимо через API) |
| INV-ACT-003 SessionResolved | Каждое событие привязано к существующей сессии. Track-API: активная сессия actor-а — при отсутствии создаётся (ensure по INV-SES-003, в запросе есть Device). Консюмеры (favorites/selections): резолв Active-сессии устройства по AnonymousId/UserId, фоллбэк — последняя сессия владельца по LastActivityAt; сессию не синтезируют (в payload нет Device) — при полном отсутствии сессии запись не создаётся (skipped-no-session, ack) | обработка track-API/консюмеров | — |
| INV-ACT-004 ConsumerDedup | Consumer-записи уникальны по исходному сообщению: SourceMessageId = MessageId исходного события; повтор доставки → запись уже существует → ack (no-op) | проверка существования записи по SourceMessageId; страховка — partial-unique ux_action_events_source_message | — (идемпотентность) |
| INV-ACT-005 ServerClockOnly | OccurredAt для track-API ставит сервер; клиентские timestamp-ы не принимаются (недоверенный вход); консюмеры берут OccurredAt исходного события | обработка track-API/консюмеров | ValidationError |
| INV-ACT-006 FilterNotEmpty | ApplyFilter: в AppliedFilterModel заполнено ≥ 1 фильтр-поле (пустой фильтр «сброс» не трекается) | валидатор track-команды | ValidationError (USER_ACTIVITY_FILTER_EMPTY) |
| INV-ACT-007 QueryBounds | Search.SearchQuery — 1..512 символов после trim; ResultsCount >= 0; диапазоны AppliedFilterModel (Price/Area — NullableRange<decimal>) — Min ≤ Max, границы >= 0 | валидатор | ValidationError |
Диаграмма состояний неприменима: у события действия нет поля Status и переходов — оно создаётся и
живёт неизменным до retention.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|
ActionRecordedEvent | доменное | Создание любой записи ActionEvent | ActionEventId, ActionType |
ActionTrackedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.user-activity.action-tracked.v1, AggregateId = ActionEventId, AggregateType = "ActionEvent") | То же согласованное изменение, что запись ActionEvent | ActionEventId, ActionType (string), SessionId, AnonymousId, UserId?, OccurredAt + подтипные optional-поля зеркально TPH: FilterSnapshot? (AppliedFilterModel), ResultsCount?, SearchQuery?, PropertyObjectId?, PropertyObjectViewId?, FavoriteId?, SelectionId?. Consumers: crm (лента карточки лида — проброс действий агенту, поток «ACT → CRM» service-boundaries.md); будущий вход рекомендаций news (ADR-0054). Контракт — ../events/action-tracked.md |
Один топик с дискриминатором ActionType (не топик-на-подтип): потребителю (CRM) нужна вся лента лида
разом; фильтрация по типу — на стороне consumer-а. Payload не содержит PII и приватных данных
(Favorite.Note, тексты пользователя кроме SearchQuery; SearchQuery — классификация Internal,
доступен агенту в карточке лида как «что искал клиент»). Интеграционное событие публикуется атомарно с
записью ActionEvent.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | PK btree(id); ix_action_events_session btree(session_id, occurred_at) — лента сессии; ix_action_events_user btree(user_id, occurred_at desc) where user_id is not null — лента пользователя (/profile/events, карточка лида); ix_action_events_type btree(action_type, occurred_at) — выборки по типу; ix_action_events_property_object btree(property_object_id) where property_object_id is not null — «действия по объекту недвижимости»; ux_action_events_source_message unique btree(source_message_id) where source_message_id is not null — дедуп консюмеров; ix_action_events_occurred btree(occurred_at) — retention. GIN по filter_snapshot не создаётся (ADR-0056: запросов «по содержимому фильтра» нет — jsonb только читается в составе записи) |
| Партиционирование / retention | Без партиционирования (ADR-0056: при 1–10k активных — порядка миллионов строк/год, btree достаточно). Retention: job activity-retention удаляет записи старше 12 мес (конфиг) батчами до очистки property_object_views/sessions; долгосрочная аналитика — dev-stats |
| Concurrency | Записи immutable — конкуренции нет; дедуп консюмеров — partial-unique |
| Permissions / PII | Self-scope владельца (JWT / X-Anonymous-Id) — своя история. Чужая история по id инвестора: агент — через crm в рамках активного лида (события action-tracked + m2m internal API); Менеджер ПИН — permission user-activity.history.view-by-user; администратор платформы — m2m admin API (ADR-0053). PII нет; SearchQuery — Internal (в логах не пишется полностью, первые 32 символа) |
| Audit | Сущность сама является журналом действий; SourceMessageId фиксирует происхождение consumer-записей |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: PropertyObjectView, Entity / Aggregate: Session
- API (4): 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 — Открыть просмотр объекта недвижимости
- Use Cases (5): 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: Favorites Events (UserActivityFavoriteAddedConsumer / ...RemovedConsumer), Consumer: Selections Events (UserActivitySelectionItemAddedConsumer), Consumer: User-Activity Actions (crm-user-activity-actions)
- Фоновые задачи (1): Background Job: ActivityRetentionJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): user-activity Service
Связанные документы
- session.md — сессия-владелец и ensure-логика; property-object-view.md —
PropertyObjectView,
использование AppliedFilterModel и связь «просмотрено по фильтру».
- ../README.md; ../consumers/favorites-events.md,
../consumers/selections-events.md,
../events/action-tracked.md, ../api/ (track-endpoint-ы, история действий).
- Источники consumer-событий: ../../favorites/domain/favorite.md
(
favorite-added.v1 / favorite-removed.v1),
../../selections/events/selection-item-added.md.
- Справочник городов (
CityRefModel): ../../handbook/domain/city.md,
публичный lookup — ../../handbook/api/public/lookup-references.md.
- Решения: ADR-0052, ADR-0054 (модуль
actions, typed ActionEvent), ADR-0056 (simplicity-first,
retention), service-boundaries.md (поток «ACT → CRM»).