GET /api/user-activity/me/history — Своя история активности (просмотры + действия)
Публичная self-scope точка «моя история»: единая хронологическая лента поведения текущего гостя/пользователя
— просмотренные объекты недвижимости и действия (применён фильтр, поиск, избранное, подборки). Покрывает
историю просмотров и действий пользователя (/profile/events). Лента строится над append-only реестром
ActionEvent (модуль actions): каждый просмотр карточки объекта недвижимости уже порождает
ViewPropertyObjectActionEvent в одном согласованном изменении с записью PropertyObjectView — поэтому один реестр
даёт и «просмотренные объекты недвижимости» (тип ViewPropertyObject), и остальные действия. Длительность и ViewSource просмотра
дочитываются из связанного PropertyObjectView (принцип «один дом»: они не дублируются в событии).
Назначение
Вернуть текущему актору его собственную ленту активности с пагинацией (страницами, не сырым массивом) и
необязательными фильтрами по дате (FromDate/ToDate) и типу действия (ActionTypes). Гостю доступна
история его устройства (X-Anonymous-Id), пользователю — единая история «до и после входа» по правилу чтения
через связанные сессии (property-object-view.md, раздел «Правило чтения истории по
пользователю»).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | user-activity |
| API area | public (self-scope: гость по X-Anonymous-Id, пользователь по JWT sub) |
| Feature group | views |
| Статус | draft |
| Документ | docs/06-services/user-activity/api/public/get-my-history.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (JWT sub) или Anonymous (заголовок X-Anonymous-Id) |
| Auth policy / Permissions | none (публичный self-scope); rate limit по актору/IP. Чужая история по id инвестора — не здесь (internal crm / permission user-activity.history.view-by-user) |
| Scope (RBAC) | не применяется — контекст вне tenant (ADR-0052 п.2) |
| Record-level | self-scope, обязателен: пользователь — UserId == actor.UserId ИЛИ SessionId ∈ сессии actor-а (правило чтения, шаг 4); гость — AnonymousId == actor.AnonymousId. Предикат подставляется handler-ом из actor-контекста, не из query/body |
| Tenant isolation | вне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются, TenantId в запросе не участвует |
| Audit | read (не sensitive) — своя история; PII в ответе нет (Device.IpAddress/UserAgent исключены); SearchQuery — Internal, но принадлежит самому актору |
Actor-контекст даёт UserId (JWT sub), AnonymousId и SessionId текущего запроса; для выборки истории
используются UserId/AnonymousId, SessionId (текущая сессия) на состав ленты не влияет. Актор обязателен:
нет ни JWT, ни X-Anonymous-Id → 401 (fail closed, БД не запрашивается).
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/user-activity/me/history |
| Success status | 200 (всегда для валидных параметров; пустая история — Items = [], TotalCount = 0, не 404) |
| Idempotency header | не применяется (GET безопасен и идемпотентен по природе) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
Paging | query (page, pageSize) | PagingQuery | Да | page >= 1; pageSize ∈ 1..100 (default 20) | — |
FromDate | query | DateTimeOffset? | Нет | если задан с ToDate — FromDate <= ToDate; трактуется как UTC-граница фильтра по OccurredAt (read-side, не сохраняемый timestamp — конфликта с server-clock-only нет) | — |
ToDate | query | DateTimeOffset? | Нет | верхняя UTC-граница фильтра OccurredAt | — |
ActionTypes | query (повторяемый types) | List<ActionEventType>? | Нет | значения enum ViewPropertyObject/ApplyFilter/Search/AddToFavorites/RemoveFromFavorites/AddToSelection; unknown-значение → ValidationError (строго, чтобы не отдать всё молча). Пусто/нет → все типы | — |
UserId / AnonymousId | identity (actor-контекст) / header X-Anonymous-Id | Guid | Да (один из) | из actor-контекста, не из query/body (правило безопасности «scope не из тела») | ключ self-scope |
Клиентские timestamp сущностей не принимаются (INV-ACT-005): OccurredAt реестра проставлен сервером;
FromDate/ToDate — только границы выборки.
Модель ответа — страница UserHistoryItemModel
Ответ — страница ленты: Items (List<UserHistoryItemModel>), Page (int), PageSize (int),
TotalCount (long), TotalPages (int). Элемент — проекция строки реестра ActionEvent (дом
полей и семантика подтипов — action-event.md; поля просмотра —
property-object-view.md); заполненность подтипных полей зависит от ActionType:
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | ActionEvent.Id | — | Идентификатор записи ленты. |
ActionType | ActionEventType (string) | Да | ActionEvent.ActionType | — | Тип: ViewPropertyObject / ApplyFilter / Search / AddToFavorites / RemoveFromFavorites / AddToSelection (дискриминатор ленты). |
OccurredAt | DateTimeOffset | Да | ActionEvent.OccurredAt (серверное время) | — | Момент действия. |
PropertyObjectId | Guid? | Нет | ActionEvent.PropertyObjectId | — | Объект недвижимости для ViewPropertyObject/AddToFavorites/RemoveFromFavorites/AddToSelection. Display-данные фронт добирает батчем в catalog. |
ViewSource | ViewSource (string?) | Нет | PropertyObjectView.ViewSource (join по PropertyObjectViewId) | — | Только ViewPropertyObject: откуда открыта карточка. |
DurationSeconds | int? | Нет | PropertyObjectView.Duration (join) | — | Только ViewPropertyObject: длительность просмотра. null — просмотр ещё Active (не закрыт worker-ом) либо запись PropertyObjectView вычищена retention. |
FilterSnapshot | AppliedFilterModel? | Нет | ApplyFilterActionEvent.Filter (jsonb, дом — action-event.md) | — | Только ApplyFilter: typed-снимок применённого фильтра. |
ResultsCount | int? | Нет | ActionEvent.ResultsCount | — | ApplyFilter/Search: размер выдачи. |
SearchQuery | string? | Нет | SearchActionEvent.SearchQuery | none (своя история актора) | Только Search: текст запроса. |
FavoriteId | Guid? | Нет | ActionEvent.FavoriteId | — | AddToFavorites/RemoveFromFavorites: запись избранного. |
SelectionId | Guid? | Нет | ActionEvent.SelectionId | — | AddToSelection: подборка-приёмник. |
Приватные данные не отдаются: Favorite.Note, Device.*, id сессий во внутреннем виде наружу не выходят.
Алгоритм
- Контекст и доступ. Актор —
User(JWTsub) илиAnonymous(X-Anonymous-Id), self-scope, вне tenant (ADR-0052 п.2). Нет ни JWT, ниX-Anonymous-Id→unauthenticated(401), хранилище не запрашивается. - Проверка входа.
PageSize ∈ 1..100,Page >= 1;FromDate <= ToDate, если оба заданы; каждое значениеActionTypes— валидное значениеActionEventType(unknown →VALIDATION_FAILED, 400). Входных id нет — актор берётся из контекста; реестр immutable, статуса нет. Обязателен record-level self-scope (шаг 4): для гостя проверяется наличиеAnonymousId. - Что читаем.
- для
User: идентификаторы его сессий поUserId— чтобы поднять до-входные гостевые записи связанных сессий (правило чтения); дляAnonymousшаг не нужен; - страница ленты
ActionEventпод record-level предикатом (шаг 4) с фильтрами по дате/типу, отсортированная поOccurredAt(новые сверху), с пагинацией; - обогащение просмотров: для элементов
ViewPropertyObject—Duration/ViewSourceсвязанногоPropertyObjectView(батч по странице; отсутствующий после retention → поляnull).
- для
- Правила чтения (record-level). Переходов состояний нет. Self-scope предикат:
User: запись принадлежит пользователю напрямую (UserId) или через его сессию (SessionId);Anonymous:AnonymousId == actor.AnonymousId; плюс необязательные границыOccurredAt(FromDate/ToDate) иActionTypes. Предикат берётся из actor-контекста, не из query/body. Маппинг поле-в-полеActionEvent → UserHistoryItemModel(таблица ответа); дляViewPropertyObject—DurationSeconds/ViewSourceиз связанногоPropertyObjectView.
- Результат. Страница (
Items,Page,PageSize,TotalCount,TotalPages) →200. Freshness:DurationSecondseventual (workeractivity-closerзакрывает просмотр с лагом до порога + цикл, ADR-0056 п.3); consumer-действия (AddToFavorites*/AddToSelection) появляются в ленте после доставки событияfavorites/selections. Пустая история —Items = [],TotalCount = 0(не404).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (read-only) |
| External calls | none (только чтение user_activity.action_events + user_activity.property_object_views; display-данные объекта недвижимости фронт добирает в catalog) |
| Cache / projections / search | none (btree-выборка по ix_action_events_user/ix_action_events_session; кеш не вводится — лента растёт при каждом действии) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | pageSize вне 1..100, FromDate > ToDate, неизвестный ActionTypes | «Проверьте параметры запроса истории» | no retry |
unauthenticated | 401 | нет ни JWT, ни X-Anonymous-Id (актор не разрешён) | «Требуется идентификация» | no retry |
| — (не ошибка) | 200 | нет записей под фильтром | тело Items = [], TotalCount = 0 | — |
404 не возвращается: пустая история — валидный результат (Items = []), а не отсутствие ресурса.
Acceptance-критерии
- Given гость с
X-Anonymous-Idи записями, WhenGET /me/history, Then200, страница своей ленты (новые сверху), только записи сAnonymousIdактора. - Given пользователь, который ранее был гостем на связанной сессии, When
GETпосле входа, Then лента включает до-входные гостевые записи связанных сессий (единая история «до и после входа»). - Given
ActionTypes = [Search], WhenGET, Then в ленте только записи типаSearch. - Given элемент
ViewPropertyObjectпо ещё не закрытому просмотру, WhenGET, ThenDurationSeconds = null(заполнитactivity-closer); после retentionPropertyObjectView— тожеnull. - Given
FromDate > ToDate, WhenGET, ThenVALIDATION_FAILED(400). - Given
pageSizeвне1..100, WhenGET, ThenVALIDATION_FAILED(400). - Given неизвестное значение в
ActionTypes, WhenGET, ThenVALIDATION_FAILED(400) (строгая проверка входа — чтобы не отдать всё молча). - Given нет ни JWT, ни
X-Anonymous-Id, WhenGET, Thenunauthenticated(401), хранилище не запрашивается (fail closed). - Given нет записей под фильтром, When
GET, Then200,Items = [],TotalCount = 0(не 404).
Совместимость и наблюдаемость
- Новые optional-поля элемента ленты (напр. новое подтипное поле
ActionEvent) — additive, без breaking change; удаление/переименование/смена типа полей или сужениеpageSize— breaking (версия клиента + rollout). Новые значенияActionEventType— additive; на входном фильтре unknown →400(строгая проверка), на выходе клиент игнорирует неизвестный тип. - Logs:
history requested(actor kind,Page/PageSize,FromDate/ToDate, число типов;SearchQueryв логи не пишется); metrics:user_activity_history_requests_total{actor_kind,result}, latency p95 (< 1 с, README SLO history/списков); traces — сквозной trace черезtraceparent; dashboard Grafanapin-user-activity; runbook —docs/06-services/user-activity/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (3): UC-ACT-002. Трекинг длительности и числа просмотров объекта недвижимости, UC-ACT-003. Инвестор просматривает свою историю просмотров и действий, UC-ACT-005. Слияние гостевой и пользовательской активности при входе
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: domain/action-event.md (реестр
ActionEvent, TPH-подтипы, домAppliedFilterModel), domain/property-object-view.md (правило чтения истории по пользователю,Duration/ViewSource); карта API — README.md. - Смежные API: get-session-link.md (internal-связка устройства); чужая история лида —
internal
crm(B3, будущий путь по конвенции). - Прототип: prototype-mapping.md (
/profile/events). - Решения: ADR-0052 (вне tenant), ADR-0054 (модули
views/actions), ADR-0056 (simplicity-first, PostgreSQL-only, polling-закрытие просмотров).