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

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 areapublic (self-scope: гость по X-Anonymous-Id, пользователь по JWT sub)
Feature groupviews
Статусdraft
Документdocs/06-services/user-activity/api/public/get-my-history.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (JWT sub) или Anonymous (заголовок X-Anonymous-Id)
Auth policy / Permissionsnone (публичный self-scope); rate limit по актору/IP. Чужая история по id инвестора — не здесь (internal crm / permission user-activity.history.view-by-user)
Scope (RBAC)не применяется — контекст вне tenant (ADR-0052 п.2)
Record-levelself-scope, обязателен: пользователь — UserId == actor.UserId ИЛИ SessionId ∈ сессии actor-а (правило чтения, шаг 4); гость — AnonymousId == actor.AnonymousId. Предикат подставляется handler-ом из actor-контекста, не из query/body
Tenant isolationвне tenant (ADR-0052 п.2); tenant-интерсепторы не подключаются, TenantId в запросе не участвует
Auditread (не sensitive) — своя история; PII в ответе нет (Device.IpAddress/UserAgent исключены); SearchQuery — Internal, но принадлежит самому актору

Actor-контекст даёт UserId (JWT sub), AnonymousId и SessionId текущего запроса; для выборки истории используются UserId/AnonymousId, SessionId (текущая сессия) на состав ленты не влияет. Актор обязателен: нет ни JWT, ни X-Anonymous-Id401 (fail closed, БД не запрашивается).

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/user-activity/me/history
Success status200 (всегда для валидных параметров; пустая история — Items = [], TotalCount = 0, не 404)
Idempotency headerне применяется (GET безопасен и идемпотентен по природе)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
Pagingquery (page, pageSize)PagingQueryДаpage >= 1; pageSize ∈ 1..100 (default 20)
FromDatequeryDateTimeOffset?Нетесли задан с ToDateFromDate <= ToDate; трактуется как UTC-граница фильтра по OccurredAt (read-side, не сохраняемый timestamp — конфликта с server-clock-only нет)
ToDatequeryDateTimeOffset?Нетверхняя UTC-граница фильтра OccurredAt
ActionTypesquery (повторяемый types)List<ActionEventType>?Нетзначения enum ViewPropertyObject/ApplyFilter/Search/AddToFavorites/RemoveFromFavorites/AddToSelection; unknown-значение → ValidationError (строго, чтобы не отдать всё молча). Пусто/нет → все типы
UserId / AnonymousIdidentity (actor-контекст) / header X-Anonymous-IdGuidДа (один из)из 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Описание
IdGuidДаActionEvent.IdИдентификатор записи ленты.
ActionTypeActionEventType (string)ДаActionEvent.ActionTypeТип: ViewPropertyObject / ApplyFilter / Search / AddToFavorites / RemoveFromFavorites / AddToSelection (дискриминатор ленты).
OccurredAtDateTimeOffsetДаActionEvent.OccurredAt (серверное время)Момент действия.
PropertyObjectIdGuid?НетActionEvent.PropertyObjectIdОбъект недвижимости для ViewPropertyObject/AddToFavorites/RemoveFromFavorites/AddToSelection. Display-данные фронт добирает батчем в catalog.
ViewSourceViewSource (string?)НетPropertyObjectView.ViewSource (join по PropertyObjectViewId)Только ViewPropertyObject: откуда открыта карточка.
DurationSecondsint?НетPropertyObjectView.Duration (join)Только ViewPropertyObject: длительность просмотра. null — просмотр ещё Active (не закрыт worker-ом) либо запись PropertyObjectView вычищена retention.
FilterSnapshotAppliedFilterModel?НетApplyFilterActionEvent.Filter (jsonb, дом — action-event.md)Только ApplyFilter: typed-снимок применённого фильтра.
ResultsCountint?НетActionEvent.ResultsCountApplyFilter/Search: размер выдачи.
SearchQuerystring?НетSearchActionEvent.SearchQuerynone (своя история актора)Только Search: текст запроса.
FavoriteIdGuid?НетActionEvent.FavoriteIdAddToFavorites/RemoveFromFavorites: запись избранного.
SelectionIdGuid?НетActionEvent.SelectionIdAddToSelection: подборка-приёмник.

Приватные данные не отдаются: Favorite.Note, Device.*, id сессий во внутреннем виде наружу не выходят.

Алгоритм

  1. Контекст и доступ. Актор — User (JWT sub) или Anonymous (X-Anonymous-Id), self-scope, вне tenant (ADR-0052 п.2). Нет ни JWT, ни X-Anonymous-Idunauthenticated (401), хранилище не запрашивается.
  2. Проверка входа. PageSize ∈ 1..100, Page >= 1; FromDate <= ToDate, если оба заданы; каждое значение ActionTypes — валидное значение ActionEventType (unknown → VALIDATION_FAILED, 400). Входных id нет — актор берётся из контекста; реестр immutable, статуса нет. Обязателен record-level self-scope (шаг 4): для гостя проверяется наличие AnonymousId.
  3. Что читаем.
    • для User: идентификаторы его сессий по UserId — чтобы поднять до-входные гостевые записи связанных сессий (правило чтения); для Anonymous шаг не нужен;
    • страница ленты ActionEvent под record-level предикатом (шаг 4) с фильтрами по дате/типу, отсортированная по OccurredAt (новые сверху), с пагинацией;
    • обогащение просмотров: для элементов ViewPropertyObjectDuration/ViewSource связанного PropertyObjectView (батч по странице; отсутствующий после retention → поля null).
  4. Правила чтения (record-level). Переходов состояний нет. Self-scope предикат:
    • User: запись принадлежит пользователю напрямую (UserId) или через его сессию (SessionId);
    • Anonymous: AnonymousId == actor.AnonymousId; плюс необязательные границы OccurredAt (FromDate/ToDate) и ActionTypes. Предикат берётся из actor-контекста, не из query/body. Маппинг поле-в-поле ActionEvent → UserHistoryItemModel (таблица ответа); для ViewPropertyObjectDurationSeconds/ViewSource из связанного PropertyObjectView.
  5. Результат. Страница (Items, Page, PageSize, TotalCount, TotalPages) → 200. Freshness: DurationSeconds eventual (worker activity-closer закрывает просмотр с лагом до порога + цикл, ADR-0056 п.3); consumer-действия (AddToFavorites*/AddToSelection) появляются в ленте после доставки события favorites/selections. Пустая история — Items = [], TotalCount = 0 (не 404).

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (read-only)
External callsnone (только чтение user_activity.action_events + user_activity.property_object_views; display-данные объекта недвижимости фронт добирает в catalog)
Cache / projections / searchnone (btree-выборка по ix_action_events_user/ix_action_events_session; кеш не вводится — лента растёт при каждом действии)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400pageSize вне 1..100, FromDate > ToDate, неизвестный ActionTypes«Проверьте параметры запроса истории»no retry
unauthenticated401нет ни JWT, ни X-Anonymous-Id (актор не разрешён)«Требуется идентификация»no retry
— (не ошибка)200нет записей под фильтромтело Items = [], TotalCount = 0

404 не возвращается: пустая история — валидный результат (Items = []), а не отсутствие ресурса.

Acceptance-критерии

  • Given гость с X-Anonymous-Id и записями, When GET /me/history, Then 200, страница своей ленты (новые сверху), только записи с AnonymousId актора.
  • Given пользователь, который ранее был гостем на связанной сессии, When GET после входа, Then лента включает до-входные гостевые записи связанных сессий (единая история «до и после входа»).
  • Given ActionTypes = [Search], When GET, Then в ленте только записи типа Search.
  • Given элемент ViewPropertyObject по ещё не закрытому просмотру, When GET, Then DurationSeconds = null (заполнит activity-closer); после retention PropertyObjectView — тоже null.
  • Given FromDate > ToDate, When GET, Then VALIDATION_FAILED (400).
  • Given pageSize вне 1..100, When GET, Then VALIDATION_FAILED (400).
  • Given неизвестное значение в ActionTypes, When GET, Then VALIDATION_FAILED (400) (строгая проверка входа — чтобы не отдать всё молча).
  • Given нет ни JWT, ни X-Anonymous-Id, When GET, Then unauthenticated (401), хранилище не запрашивается (fail closed).
  • Given нет записей под фильтром, When GET, Then 200, 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 Grafana pin-user-activity; runbook — docs/06-services/user-activity/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущности: 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-закрытие просмотров).