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

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 (дискриминатор)ДействиеОткуда возникает
ApplyFilterActionEventApplyFilterПрименён фильтр каталога (typed-поля фильтра)public track-API (POST /tracking/filters)
SearchActionEventSearchВыполнен текстовый поискpublic track-API (POST /tracking/search)
ViewPropertyObjectActionEventViewPropertyObjectОткрыта карточка объекта недвижимостивнутри контекста — тем же согласованным изменением, что создание PropertyObjectView (PropertyObjectViewStartedEvent)
AddToFavoritesActionEventAddToFavoritesОбъект недвижимости добавлен в избранноеconsumer pin.favorites.favorite-added.v1
RemoveFromFavoritesActionEventRemoveFromFavoritesОбъект недвижимости удалён из избранногоconsumer pin.favorites.favorite-removed.v1
AddToSelectionActionEventAddToSelectionОбъект недвижимости добавлен в подборкуconsumer pin.selections.selection-item-added.v1 (контракт selectionsdocs/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 teamPIN Engagement
Статус документаdraft
Область действияконтекст user-activity, таблица user_activity.action_events
Change contextgreenfield

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

ПолеЗначение
Контекстuser-activity
KindAggregateRoot (каждое событие — самостоятельный маленький агрегат, immutable после создания)
Source of truthuser-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)ИсточникОписание
IdGuidДаPKapp (генерируется, сортируемый по времени)Идентификатор события действия.
ActionTypeActionEventTypeДаTPH-дискриминатор, varchar(32), enum как text: ApplyFilter / Search / ViewPropertyObject / AddToFavorites / RemoveFromFavorites / AddToSelection; ix_action_events_type btree(action_type, occurred_at)app (по типу действия)Тип действия.
SessionIdGuidДа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)Сессия, в которой совершено действие.
AnonymousIdGuidДа— (денормализация)copy из Session.AnonymousIdУстройство actor-а.
UserIdGuid?Нетix_action_events_user btree(user_id, occurred_at desc) where user_id is not null; логическая ссылка identity.users(id) без FKcopy из Session.UserId на момент действияПользователь на момент действия; null — гость. Чтение истории по пользователю — то же правило через связанные сессии, что у PropertyObjectView («Правило чтения»).
OccurredAtDateTimeOffsetДаix_action_events_occurred btree(occurred_at) — retention; в составе композитных индексовapp (now() сервера); для consumer-записей — OccurredAt из payload исходного событияМомент действия.
SourceMessageIdGuid?Нет (только 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)

ПолеТипОбязательность (в подтипе)ОграниченияИсточникОписание
FilterAppliedFilterModelДаjsonb, колонка filter_snapshot; typed-модель ниже, ContractVersion = 1app (запрос фронта, провалидированный)Полный typed-снимок применённого фильтра (город/застройщик/проект/цена и так далее).
ResultsCountint?Нет>= 0app (запрос фронта: сколько объектов недвижимости выдал каталог)Размер выдачи по фильтру (полезно агенту: «фильтр без результатов»).
ПолеТипОбязательность (в подтипе)ОграниченияИсточникОписание
SearchQuerystringДаvarchar(512), колонка search_query; trim, пустая строка отвергаетсяapp (запрос фронта)Текст поискового запроса.
ResultsCountint?Нет>= 0 (общая колонка с ApplyFilter)appРазмер выдачи по запросу.

ViewPropertyObjectActionEvent (ActionType = ViewPropertyObject)

ПолеТипОбязательность (в подтипе)ОграниченияИсточникОписание
PropertyObjectIdGuidДаколонка property_object_id, ix_action_events_property_object btree where not null; логическая ссылка без FKPropertyObjectViewStartedEvent.PropertyObjectIdОткрытый объект недвижимости.
PropertyObjectViewIdGuidДаколонка property_object_view_id; FK user_activity.property_object_views(id) (SetNull при retention)PropertyObjectViewStartedEvent.PropertyObjectViewIdСсылка на запись просмотра — длительность и фильтр-контекст читаются оттуда, здесь не дублируются.

AddToFavoritesActionEvent (ActionType = AddToFavorites)

ПолеТипОбязательность (в подтипе)ОграниченияИсточникОписание
PropertyObjectIdGuidДаобщая колонка property_object_idpayload pin.favorites.favorite-added.v1 (PropertyObjectId)Объект недвижимости, добавленный в избранное.
FavoriteIdGuidДаколонка favorite_id; логическая ссылка на favorites.favorites(id) без FKpayload (FavoriteId)Запись избранного-источник. Заметка (Note) сюда не попадает — приватна (контракт favorites).

RemoveFromFavoritesActionEvent (ActionType = RemoveFromFavorites)

ПолеТипОбязательность (в подтипе)ОграниченияИсточникОписание
PropertyObjectIdGuidДаобщая колонка property_object_idpayload pin.favorites.favorite-removed.v1Объект недвижимости, удалённый из избранного.
FavoriteIdGuidДаобщая колонка favorite_idpayloadУдалённая запись избранного.

AddToSelectionActionEvent (ActionType = AddToSelection)

ПолеТипОбязательность (в подтипе)ОграниченияИсточникОписание
PropertyObjectIdGuidДаобщая колонка property_object_idpayload pin.selections.selection-item-added.v1 (контракт selections)Объект недвижимости, добавленный в подборку.
SelectionIdGuidДаколонка selection_id; логическая ссылка на подборку selections без FKpayloadПодборка-приёмник.

Итоговый набор физических колонок таблицы: базовые + 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
ContractVersionintДа= 1v1
CityCityRefModel?Нетref-модель города (CityId + CityName); справочник городов pin.handbook (city.md)v1
DeveloperDeveloperRefModel?Нетref-модель застройщика (DeveloperId + DeveloperName); справочник chessboard.Developerv1
ProjectProjectRefModel?Нетref-модель проекта (ProjectId + ProjectName); справочник chessboard.Projectv1
PriceNullableRange<decimal>?Нетценовой диапазон, RUB; границы включительные, null-граница — открытый интервал; Min <= Max; Min/Max >= 0v1
RoomsList<int>?Нетзначения 0..10 (0 = студия); контейнер — именованная коллекция моделиv1
AreaNullableRange<decimal>?Нетдиапазон площади, м²; границы включительные, null-граница — открытый интервал; Min <= Max; Min/Max >= 0v1
PropertyObjectStatusesList<string>?Нетзначения enum «Статус объекта» chessboard (Free/Reserved/Booked/Sold); unknown-значения отбрасываются валидаторомv1
SortBystring?Нет<= 64; код сортировки каталога (напр. price-asc)v1

Вложенные ref-модели (пара «id + name», полные имена полей сохраняются намеренно — модель читается вне контекста родителя):

МодельПоля
CityRefModelCityId: long (справочник городов pin.handbook, city.md), CityName: string (<= 256)
DeveloperRefModelDeveloperId: Guid (chessboard.Developer), DeveloperName: string (<= 256)
ProjectRefModelProjectId: 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/AreaNullableRange<decimal> (VO с границами Min/Max, обе nullable, включительные; NullableRange<T>.Any — фильтр по диапазону не задан).

Совместимость: additive-only в рамках ContractVersion = 1 (новые optional-поля фильтров каталога — без bump); breaking (переименование/смена типа) — ContractVersion = 2 + чтение обеих версий на выдаче.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
ActionEvent → Sessionreference (внутри контекста, FK)SessionIdвыборка ленты сессии по SessionIdRestrict (retention-порядок: события → просмотры → сессии)
ViewPropertyObjectActionEvent → PropertyObjectViewreference (внутри контекста, FK)PropertyObjectViewIdjoin в query истории (длительность просмотра)SetNull
ApplyFilterActionEvent ← PropertyObjectViewreference (внутри контекста, FK со стороны PropertyObjectView)PropertyObjectView.ApplyFilterEventId«объекты, просмотренные по фильтру» — выборка просмотров с этим ApplyFilterEventIdSetNull (см. property-object-view.md)
AddToFavorites*/RemoveFromFavorites* → Favorite (favorites)логическая reference (кросс-контекст, без FK)FavoriteIdне загружается
AddToSelection → Selection (selections)логическая reference (кросс-контекст, без FK)SelectionIdне загружается
*_ → PropertyObject (chessboard/catalog)логическая reference (кросс-контекст, без FK)PropertyObjectIddisplay-данные добирает фронт/CRM батчем в catalog

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

ИнвариантУсловиеГде проверяетсяОшибка
INV-ACT-001 AppendOnlyImmutableСобытие действия не изменяется и не удаляется поштучно после записи (только retention-job батчами)отсутствие update/delete-API; записи не обновляются
INV-ACT-002 SubtypeFieldsRequiredОбязательные поля подтипа заполнены (таблицы выше): ApplyFilterFilter; SearchSearchQuery; ViewPropertyObjectPropertyObjectId+PropertyObjectViewId; AddToFavorites/RemoveFromFavoritesPropertyObjectId+FavoriteId; AddToSelectionPropertyObjectId+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 ConsumerDedupConsumer-записи уникальны по исходному сообщению: SourceMessageId = MessageId исходного события; повтор доставки → запись уже существует → ack (no-op)проверка существования записи по SourceMessageId; страховка — partial-unique ux_action_events_source_message— (идемпотентность)
INV-ACT-005 ServerClockOnlyOccurredAt для track-API ставит сервер; клиентские timestamp-ы не принимаются (недоверенный вход); консюмеры берут OccurredAt исходного событияобработка track-API/консюмеровValidationError
INV-ACT-006 FilterNotEmptyApplyFilter: в AppliedFilterModel заполнено ≥ 1 фильтр-поле (пустой фильтр «сброс» не трекается)валидатор track-командыValidationError (USER_ACTIVITY_FILTER_EMPTY)
INV-ACT-007 QueryBoundsSearch.SearchQuery — 1..512 символов после trim; ResultsCount >= 0; диапазоны AppliedFilterModel (Price/AreaNullableRange<decimal>) — Min ≤ Max, границы >= 0валидаторValidationError

Диаграмма состояний неприменима: у события действия нет поля Status и переходов — оно создаётся и живёт неизменным до retention.

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

СобытиеТипКогдаSnapshot/поля
ActionRecordedEventдоменноеСоздание любой записи ActionEventActionEventId, ActionType
ActionTrackedKafkaEventинтеграционное (ContractVersion = 1, topic pin.user-activity.action-tracked.v1, AggregateId = ActionEventId, AggregateType = "ActionEvent")То же согласованное изменение, что запись ActionEventActionEventId, 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 / PIISelf-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-записей

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

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

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