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

GET /api/investor/crm/deals/{dealId}/timeline — Таймлайн сделки (инвестор)

Постранично отдаёт инвестору хронологию вкладки «Ход сделки» его собственной сделки (/profile/deals/[dealId], US-INV-09/10/11) — «от старых к новым», с иконкой статуса каждого шага. Только чтение: строки материализуются в момент породившей их мутации (INV-TLE-2), метод их не создаёт и не пересчитывает. Доступ — исключительно к своей сделке (Deal.PrimaryInvestorUserId = actor.UserId).

Назначение

Возвращает инвестору хронологию вкладки «Ход сделки» его карточки сделки (/profile/deals/[dealId], US-INV-09/10/11): заявка на бронирование, подтверждение/истечение брони, запросы и статусы документов, эскроу, подготовка договоров, Росреестр, завершение — «от старых к новым» с иконкой статуса шага (выполнено / в процессе / просрочено). Строки материализуются в тот же момент, что и породившая их мутация (INV-TLE-2, deal-timeline-event.md); этот метод их только читает страницами. Клик по событию BookingRequested раскрывает условия бронирования из payload (US-INV-10).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areainvestor
Feature groupdeals
Статусdraft
Документdocs/06-services/crm/api/investor/get-deal-timeline.md

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

ПроверкаЗначение
Actor typeUser (Инвестор — JWT identity)
Auth policy / Permissionspermission crm.deals.view (identity/domain/permission.md)
Scope (RBAC)платформа (инвестор вне tenant застройщика); фактический доступ — record-level ниже
Record-levelИнвестор видит только сделку, где он участник: Deal.PrimaryInvestorUserId = actor.UserId (эквивалентно активному DealMemberEntity c Role = InvestorPrimary, deal.md). Иначе — 403. Фиксации инвестору недоступны в принципе; в таймлайне их типов нет
Tenant isolationне developer-tenanted: Deal.TenantId = DeveloperCompanyId, инвестор в этот tenant не входит (ADR-0052). Изоляция — record-level по PrimaryInvestorUserId из claims
Auditnone (чтение собственной ленты). PII в payload запрещён (эскроу — маска, документы — id/тип/заголовок), поэтому read-sensitive не требуется
Доступ

Инвестор видит только сделку, где он — участник-покупатель: Deal.PrimaryInvestorUserId = actor.UserId (эквивалентно активному DealMemberEntity с Role = InvestorPrimary). Иначе — 403 CRM_DEAL_FORBIDDEN. Запрос не developer-tenanted (Deal.TenantId = DeveloperCompanyId, инвестор в этот tenant не входит, ADR-0052) — изоляция обеспечивается исключительно record-level предикатом по PrimaryInvestorUserId из claims. Фиксации инвестору недоступны в принципе — в таймлайне их типов нет.

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/investor/crm/deals/{dealId}/timeline
Success status200 (в т.ч. пустая лента — Items = [])
Idempotency headerне применяется (GET — safe/idempotent по природе)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PrimaryInvestorUserIdidentity claimsGuidДаRequired (= actor.UserId); record-level предикатscope isolation
DealIdroute {dealId}GuidДа!= Guid.Empty; существование — по загрузке сделкиrecord-level по Deal.PrimaryInvestorUserId
PagequeryintНет (default 1)>= 1
PageSizequeryintНет (default 50)1..200 (лента — десятки строк на сделку)защита от unbounded

Сортировка выдачи — фиксированная server-side по OccurredAt возрастанием (US-INV-09: «от старых к новым»), tie-break по Id (Guid v7 — монотонно по времени). Клиентские сортировки/курсоры не принимаются.

Модель ответа: постраничный список TimelineItemModel

Постраничный контейнер: элементы TimelineItemModel, общее число, номер и размер страницы. Дом полей ленты — deal-timeline-event.md; ниже — только состав строки списка (проекция, не переописание сущности).

Модель TimelineItemModel

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealTimelineEventEntity.IdИдентификатор события таймлайна.
Typestring (enum DealTimelineEventType)Да.TypeТип события (дом — deal-timeline-event.md).
Statusstring (enum TimelineItemStatus)Да.StatusИконка шага US-INV-09.3: Done / InProgress / Overdue.
TitlestringДа.TitleГотовый заголовок («Создана заявка на бронирование»).
OccurredAtDateTimeOffsetДа.OccurredAtМомент факта; ключ сортировки (asc).
DueAtDateTimeOffset?Нет.DueAtДедлайн для InProgress-шагов; null для фактов-событий.
CompletedAtDateTimeOffset?Нет.CompletedAtМомент закрытия шага (→ Done).
ActorRolestring? (enum ParticipantRole)Нет.ActorRoleРоль совершившего действие; null — система/worker. ActorUserId наружу не проецируется.
PayloadTimelineItemPayload (полиморфный по Type, ContractVersion = 1)Да.Payload (typed jsonb)PII-правила нижеТипизированные данные события; подтип — по Type.

TimelineItemPayloadстрого типизированный полиморфный контракт (по дискриминатору Type), не сырой object/json. Дом каждого подтипа и всех его полей — deal-timeline-event.md (раздел «Enum DealTimelineEventType и typed payload»); здесь — только соответствие Type → подтип и источник материализации:

TypePayload-подтип (дом — deal-timeline-event.md)Кто материализует строку (источник)
DealCreatedTimelineDealCreatedPayloadсоздание сделки; событие ../../events/deal-created.md
StageChangedTimelineStageChangedPayloadпереход этапа сделки; ../../events/deal-stage-changed.md
BookingRequestedTimelineBookingRequestedPayload (условия брони: ProgramName,IsPaid,PriceAmount?,ReserveDurationMinutes,BookingDurationHours,RefundPolicy?)создание брони booking.md; ../../events/booking-created.md. Раскрытие условий по клику — US-INV-10
BookingConfirmedTimelineBookingConfirmedPayloadподтверждение брони (→ Booked); ../../events/booking-confirmed.md
BookingExpiredTimelineBookingRefPayloadworker booking-expiration; ../../events/booking-expired.md
DocumentRequested / DocumentUploaded / DocumentAcceptedTimelineDocumentPayloadтолько DocumentId/DocumentType/Title (без PII)запрос/загрузка/приёмка документа; ../../events/document-requested.md, ../../events/document-status-changed.md
DocumentRejectedTimelineDocumentRejectedPayload (id/тип + RejectReason)отклонение документа на проверке
EscrowProvidedTimelineEscrowPayloadтолько маска AccountNumberMasked (****NNNN)инвестор указал эскроу (US-INV-19)
ContractsPrepared / RosreestrRegistered / DealCompleted / TermsAcknowledgedTimelineContractsPreparedPayload / TimelineEmptyPayloadUS-INV-18/20; ../../events/deal-completed.md

PII в payload запрещён (дом — deal-timeline-event.md, «Индексы, хранение, безопасность»): номер эскроу-счёта отдаётся только маской ****NNNN; документы — только id/тип/заголовок (бинарь и метаданные файла — deal-document.md); ФИО/телефоны в ленту не пишутся. Типы ленты лида (ManualNote и т.п.) инвестору не адресованы; ManualNote создаётся только Менеджером ПИН/Службой заботы.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор с permission crm.deals.view; PrimaryInvestorUserId — из токена; DealId из маршрута; Page/PageSize из query.
  2. Проверка входа и предусловий.
    • синтаксис/диапазоны: DealId != Guid.Empty, Page >= 1, PageSize ∈ 1..200 — иначе VALIDATION_FAILED;
    • существование сделки: если сделки нет → CRM_DEAL_NOT_FOUND (404). Доступ режется record-level ниже;
    • допустимость состояния не проверяется — таймлайн читается для сделки в любом DealStatus (ApplicationCompleted/Cancelled): история доступна и по завершённой/отменённой сделке;
    • связи/scope (record-level): требуется deal.PrimaryInvestorUserId == actor.UserId, иначе CRM_DEAL_FORBIDDEN (403) — инвестор не участник этой сделки.
  3. Что читаем. Страницу ленты сделки: строки crm.deal_timeline_events по DealId, порядок по OccurredAt возрастанием, срез (Page, PageSize); отдельно — общее число строк для метаданных страницы. Индекс ix_deal_timeline_deal_occurred (deal_id, occurred_at) покрывает и предикат, и порядок.
  4. Бизнес-правила и маппинг. Переходов состояний нет. Маппинг поле-в-поле — по таблице TimelineItemModel: Id/Type/Status/Title/OccurredAt/DueAt/CompletedAt/ActorRole — из строки ленты; Payload — типизированный подтип TimelineItemPayload по дискриминатору Type (ContractVersion = 1). Status уже иммутабельно вычислен при записи или worker-ом (Done/InProgress; Overdue ставит только worker timeline-overdue — INV-TLE-4); ничего не пересчитывается. Внутренние TenantId/ActorUserId/CreatedAt/ UpdatedAt наружу не проецируются.
  5. Результат. Постраничный список TimelineItemModel → HTTP 200. Пустая лента → 200 c Items = [], TotalCount = 0 (не 404). Freshness — строго консистентно внутри crm: строки пишутся в один момент с породившей мутацией (INV-TLE-2), появляются атомарно; единственный лаг — переход InProgress → Overdue до цикла worker-а timeline-overdue (≤ 5 мин, ADR-0056).

Диаграмма

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

ТипДетали
Integration eventsnone (read-only query)
External callsnone (display-данные объекта недвижимости/документов ЛК добирает отдельными запросами по id из payload)
Cache / projections / searchnone (лента читается query-time; source of truth — crm.deal_timeline_events)
Notificationsnone (уведомление US-INV-11 «новое событие в таймлайне» доставляет notifications по факту добавления строки, а не этот метод)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400DealId = Guid.Empty, Page/PageSize вне границ«Проверьте параметры запроса»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
FORBIDDEN403нет permission crm.deals.view«Доступ запрещён»no retry
CRM_DEAL_NOT_FOUND404сделка не существует«Сделка не найдена»no retry
CRM_DEAL_FORBIDDEN403инвестор не участник сделки (Deal.PrimaryInvestorUserId != actor)«Доступ к сделке запрещён»no retry

Acceptance Criteria

  • Given своя сделка с лентой, When инвестор запрашивает страницу, Then строки TimelineItemModel отсортированы по OccurredAt возрастанием (tie-break по Id), каждая с типизированным Payload (ContractVersion = 1), ответ 200 постраничным списком.
  • Given у сделки ещё нет строк, When запрос, Then 200 с Items = [], TotalCount = 0 (не 404).
  • Given сделка Completed/Cancelled, When запрос, Then лента читается — история доступна и по завершённой/отменённой сделке.
  • Given Deal.PrimaryInvestorUserId != actor, When запрос, Then CRM_DEAL_FORBIDDEN (403); сделки нет → CRM_DEAL_NOT_FOUND (404); нет crm.deals.viewFORBIDDEN (403).
  • Given PageSize ∉ [1..200], Page < 1 или DealId = Guid.Empty, When запрос, Then VALIDATION_FAILED (400).
  • Given любая выдача ленты, When строки проецируются, Then номер эскроу-счёта в payload — только маска ****NNNN, документы — только DocumentId/DocumentType/Title, ФИО/телефоны в ленту не попадают; типы ленты лида (ManualNote и т.п.) инвестору не адресованы.
  • Given новый неизвестный Type/Status/подтип Payload, When клиент рендерит ленту, Then он игнорирует незнакомый элемент без падения; строки появляются атомарно с породившей мутацией (INV-TLE-2), единственный лаг — переход InProgress → Overdue до цикла worker timeline-overdue (≤ 5 мин).

Совместимость и наблюдаемость

  • Новые типы DealTimelineEventType, значения TimelineItemStatus и additive-поля payload (ContractVersion = 1, дом — deal-timeline-event.md) расширяют ленту без breaking change; клиент обязан игнорировать неизвестный Type/Status/подтип Payload (forward-compatible). Удаление/переименование поля модели или подтипа — breaking (migration + rollout). Enum — строкой.
  • Logs: deal-timeline-listed (dealId, primaryInvestorUserId, count — без PII); metrics: crm_investor_operations_total{operation="get-deal-timeline"}, latency p95 < 1 c (SLO списков, README); traces — OTel, correlation через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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