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 area | investor |
| Feature group | deals |
| Статус | draft |
| Документ | docs/06-services/crm/api/investor/get-deal-timeline.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Инвестор — JWT identity) |
| Auth policy / Permissions | permission 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 |
| Audit | none (чтение собственной ленты). 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-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/investor/crm/deals/{dealId}/timeline |
| Success status | 200 (в т.ч. пустая лента — Items = []) |
| Idempotency header | не применяется (GET — safe/idempotent по природе) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
PrimaryInvestorUserId | identity claims | Guid | Да | Required (= actor.UserId); record-level предикат | scope isolation |
DealId | route {dealId} | Guid | Да | != Guid.Empty; существование — по загрузке сделки | record-level по Deal.PrimaryInvestorUserId |
Page | query | int | Нет (default 1) | >= 1 | — |
PageSize | query | int | Нет (default 50) | 1..200 (лента — десятки строк на сделку) | защита от unbounded |
Сортировка выдачи — фиксированная server-side по OccurredAt возрастанием (US-INV-09: «от старых к новым»),
tie-break по Id (Guid v7 — монотонно по времени). Клиентские сортировки/курсоры не принимаются.
Модель ответа: постраничный список TimelineItemModel
Постраничный контейнер: элементы TimelineItemModel, общее число, номер и размер страницы. Дом полей ленты —
deal-timeline-event.md; ниже — только состав строки списка (проекция,
не переописание сущности).
Модель TimelineItemModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | DealTimelineEventEntity.Id | — | Идентификатор события таймлайна. |
Type | string (enum DealTimelineEventType) | Да | .Type | — | Тип события (дом — deal-timeline-event.md). |
Status | string (enum TimelineItemStatus) | Да | .Status | — | Иконка шага US-INV-09.3: Done / InProgress / Overdue. |
Title | string | Да | .Title | — | Готовый заголовок («Создана заявка на бронирование»). |
OccurredAt | DateTimeOffset | Да | .OccurredAt | — | Момент факта; ключ сортировки (asc). |
DueAt | DateTimeOffset? | Нет | .DueAt | — | Дедлайн для InProgress-шагов; null для фактов-событий. |
CompletedAt | DateTimeOffset? | Нет | .CompletedAt | — | Момент закрытия шага (→ Done). |
ActorRole | string? (enum ParticipantRole) | Нет | .ActorRole | — | Роль совершившего действие; null — система/worker. ActorUserId наружу не проецируется. |
Payload | TimelineItemPayload (полиморфный по Type, ContractVersion = 1) | Да | .Payload (typed jsonb) | PII-правила ниже | Типизированные данные события; подтип — по Type. |
TimelineItemPayload — строго типизированный полиморфный контракт (по дискриминатору Type), не сырой
object/json. Дом каждого подтипа и всех его полей — deal-timeline-event.md
(раздел «Enum DealTimelineEventType и typed payload»); здесь — только соответствие Type → подтип и
источник материализации:
Type | Payload-подтип (дом — deal-timeline-event.md) | Кто материализует строку (источник) |
|---|---|---|
DealCreated | TimelineDealCreatedPayload | создание сделки; событие ../../events/deal-created.md |
StageChanged | TimelineStageChangedPayload | переход этапа сделки; ../../events/deal-stage-changed.md |
BookingRequested | TimelineBookingRequestedPayload (условия брони: ProgramName,IsPaid,PriceAmount?,ReserveDurationMinutes,BookingDurationHours,RefundPolicy?) | создание брони booking.md; ../../events/booking-created.md. Раскрытие условий по клику — US-INV-10 |
BookingConfirmed | TimelineBookingConfirmedPayload | подтверждение брони (→ Booked); ../../events/booking-confirmed.md |
BookingExpired | TimelineBookingRefPayload | worker booking-expiration; ../../events/booking-expired.md |
DocumentRequested / DocumentUploaded / DocumentAccepted | TimelineDocumentPayload — только DocumentId/DocumentType/Title (без PII) | запрос/загрузка/приёмка документа; ../../events/document-requested.md, ../../events/document-status-changed.md |
DocumentRejected | TimelineDocumentRejectedPayload (id/тип + RejectReason) | отклонение документа на проверке |
EscrowProvided | TimelineEscrowPayload — только маска AccountNumberMasked (****NNNN) | инвестор указал эскроу (US-INV-19) |
ContractsPrepared / RosreestrRegistered / DealCompleted / TermsAcknowledged | TimelineContractsPreparedPayload / TimelineEmptyPayload | US-INV-18/20; ../../events/deal-completed.md |
PII в payload запрещён (дом — deal-timeline-event.md, «Индексы, хранение, безопасность»): номер
эскроу-счёта отдаётся только маской ****NNNN; документы — только id/тип/заголовок (бинарь и метаданные
файла — deal-document.md); ФИО/телефоны в ленту не пишутся. Типы ленты лида
(ManualNote и т.п.) инвестору не адресованы; ManualNote создаётся только Менеджером ПИН/Службой заботы.
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор с permission
crm.deals.view;PrimaryInvestorUserId— из токена;DealIdиз маршрута;Page/PageSizeиз query. - Проверка входа и предусловий.
- синтаксис/диапазоны:
DealId != Guid.Empty,Page >= 1,PageSize ∈ 1..200— иначеVALIDATION_FAILED; - существование сделки: если сделки нет →
CRM_DEAL_NOT_FOUND(404). Доступ режется record-level ниже; - допустимость состояния не проверяется — таймлайн читается для сделки в любом
DealStatus(Application…Completed/Cancelled): история доступна и по завершённой/отменённой сделке; - связи/scope (record-level): требуется
deal.PrimaryInvestorUserId == actor.UserId, иначеCRM_DEAL_FORBIDDEN(403) — инвестор не участник этой сделки.
- синтаксис/диапазоны:
- Что читаем. Страницу ленты сделки: строки
crm.deal_timeline_eventsпоDealId, порядок поOccurredAtвозрастанием, срез(Page, PageSize); отдельно — общее число строк для метаданных страницы. Индексix_deal_timeline_deal_occurred(deal_id,occurred_at) покрывает и предикат, и порядок. - Бизнес-правила и маппинг. Переходов состояний нет. Маппинг поле-в-поле — по таблице
TimelineItemModel:Id/Type/Status/Title/OccurredAt/DueAt/CompletedAt/ActorRole— из строки ленты;Payload— типизированный подтипTimelineItemPayloadпо дискриминаторуType(ContractVersion = 1).Statusуже иммутабельно вычислен при записи или worker-ом (Done/InProgress;Overdueставит только workertimeline-overdue— INV-TLE-4); ничего не пересчитывается. ВнутренниеTenantId/ActorUserId/CreatedAt/UpdatedAtнаружу не проецируются. - Результат. Постраничный список
TimelineItemModel→ HTTP200. Пустая лента →200cItems = [],TotalCount = 0(не404). Freshness — строго консистентно внутриcrm: строки пишутся в один момент с породившей мутацией (INV-TLE-2), появляются атомарно; единственный лаг — переходInProgress → Overdueдо цикла worker-аtimeline-overdue(≤ 5 мин, ADR-0056).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (read-only query) |
| External calls | none (display-данные объекта недвижимости/документов ЛК добирает отдельными запросами по id из payload) |
| Cache / projections / search | none (лента читается query-time; source of truth — crm.deal_timeline_events) |
| Notifications | none (уведомление US-INV-11 «новое событие в таймлайне» доставляет notifications по факту добавления строки, а не этот метод) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | DealId = Guid.Empty, Page/PageSize вне границ | «Проверьте параметры запроса» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
FORBIDDEN | 403 | нет permission crm.deals.view | «Доступ запрещён» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделка не существует | «Сделка не найдена» | no retry |
CRM_DEAL_FORBIDDEN | 403 | инвестор не участник сделки (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 запрос, ThenCRM_DEAL_FORBIDDEN(403); сделки нет →CRM_DEAL_NOT_FOUND(404); нетcrm.deals.view→FORBIDDEN(403). - Given
PageSize ∉ [1..200],Page < 1илиDealId = Guid.Empty, When запрос, ThenVALIDATION_FAILED(400). - Given любая выдача ленты, When строки проецируются, Then номер эскроу-счёта в payload — только маска
****NNNN, документы — толькоDocumentId/DocumentType/Title, ФИО/телефоны в ленту не попадают; типы ленты лида (ManualNoteи т.п.) инвестору не адресованы. - Given новый неизвестный
Type/Status/подтипPayload, When клиент рендерит ленту, Then он игнорирует незнакомый элемент без падения; строки появляются атомарно с породившей мутацией (INV-TLE-2), единственный лаг — переходInProgress → Overdueдо цикла workertimeline-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; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (3): GET /api/investor/crm/deals — Мои сделки (инвестор), GET /api/investor/crm/deals/{dealId} — Карточка сделки (инвестор), POST /api/pin-manager/crm/deals/{dealId}/timeline-note — Ручная запись в таймлайн
- registries (1): Реестр API-методов PIN
Связанные документы
- Дом ленты (все поля/типы/индекс
ix_deal_timeline_deal_occurred,TimelineItemStatus, payload-подтипы, workertimeline-overdue): domain/deal-timeline-event.md; родитель — domain/deal.md; условия брони — domain/booking.md; документы — domain/deal-document.md. - Источники строк ленты (уже опубликованные контракты): ../../events/deal-created.md, ../../events/deal-stage-changed.md, ../../events/booking-created.md, ../../events/booking-confirmed.md, ../../events/booking-expired.md, ../../events/document-requested.md, ../../events/document-status-changed.md, ../../events/deal-completed.md.
- Карта области investor и событий: ../../README.md.
- Решения: ADR-0052 (record-level инвестора),
ADR-0056 (worker
timeline-overdue).