UC-SEL-005. Агент отправляет подборку клиенту в лиде/сделке
Назначение
Описывает сценарий, в котором агент — владелец подборки отправляет свою подборку клиенту в
карточке лида CRM как канал привлечения и работы (ТехЗадание.md:277–283; prototype-mapping: «агент
отправляет подборки клиенту; CRM хранит ссылку на подборку»). Со стороны selections сценарий
выполняет одну серверную операцию — POST /selections/{id}/share (агент отправил подборку
клиенту): она фиксирует доменный факт SelectionSharedEvent и публикует pin.selections.selection-shared.v1
(факт для ленты лида crm и истории действий user-activity), но состав подборки не меняет.
Владение связью «подборка ↔ лид» и её материализацию в таймлайн (LeadEvent SelectionAttached) держит
crm — потребитель события; резолв подборки для карточки — синхронный internal API. Связан с
контекстом selections; интеграция — crm, referral, user-activity, catalog.
| Поле | Значение |
|---|
| ID | UC-SEL-005 |
| Контекст | selections (см. docs/06-services/selections/); интеграция — crm, referral, user-activity, catalog |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Агент — владелец подборки (UserEntity.Type = Agent); отправка подборки клиенту = канал привлечения/работы в лиде/сделке |
| Permissions / scope | self-scope на selections: OwnerUserId = actor.UserId (INV-SEL-002), отдельных permissions нет; прикрепление подборки к лиду/сделке — операция и permission crm (не selections) |
| Tenant | подборка вне tenant (ADR-0052 п.2 — личные данные владельца); лид/сделка агента — в tenant агентства (TenantId = CompanyId) на стороне crm |
| Record-level | GET /selections фильтруется по OwnerUserId = actor.UserId (чужую подборку выбрать нельзя); internal-резолв для CRM — по SelectionId (доверенный m2m crm, не привязан к record-level владельца) |
Предусловие
- У агента есть активная (не удалённая) подборка — создана из избранного или вручную
(UC-SEL-001).
- В CRM существует лид/сделка клиента (карточка
/crm/clients/[id]).
selections доступен (PostgreSQL up); referral / user-activity / catalog — опциональные
зависимости (graceful degradation — A2, A4; display-данные объектов недвижимости добирает фронт из catalog).
Триггер
| Поле | Значение |
|---|
| Источник | UI button |
| Триггерящий элемент | Раздел «Подборки» карточки клиента CRM /crm/clients/[id]/collections — «отправить подборку»; фронт вызывает selections POST /selections/{id}/share (событие selection-shared → лента лида crm) |
Основной Поток
- Агент в карточке клиента/сделки открывает
/crm/clients/[id]/collections и выбирает свою подборку;
фронт CRM берёт список GET /api/v1/selections (self-scope: только подборки владельца, не удалённые,
по CreatedAt desc — INV-SEL-002, чужие не видны). В каждом MySelectionListItemModel есть Id и
PublicCode (../api/public/list-my-selections.md).
- Агент нажимает «отправить подборку» выбранному клиенту — фронт CRM вызывает
selections
POST /api/v1/selections/{id}/share (JWT агента, тело { investorUserId } — id клиента карточки).
Операция share (owner-only INV-SEL-002, не удалена INV-SEL-009; состав не меняется) публикует
pin.selections.selection-shared.v1 (Action = Shared, AgentUserId = OwnerUserId,
InvestorUserId, SelectionTitle, PropertyObjectsCount, OccurredAt) — атомарно с фиксацией
операции share (внутренний доменный факт SelectionSharedEvent). Событие потребляют crm
(дописывает LeadEvent SelectionAttached в ленту активного лида пары agent↔investor —
crm/consumers/selection-shared.md) и user-activity
(ActionEvent «отправил подборку»). Персистентную запись «подборка на лиде» держит именно лента лида
crm (LeadSelectionPayload.SelectionId), не selections.
- Агент делится публичной ссылкой
/collection/{publicCode} с клиентом: SharePopover комбинирует
публичную ссылку подборки с реф-параметром referral — итог /collection/{publicCode}?ref={referralLinkId}
(UC-SEL-002). Ссылка строится на фронте, без запроса в selections.
- Клиент открывает подборку по коду — UC-SEL-003:
GET /api/v1/selections/{publicCode} без JWT (любой актор); в публичном ответе OwnerUserId
не раскрывается (masking — ../domain/selection.md «Permissions / PII»).
- Резолв подборки для карточки CRM. CRM резолвит подборку через
GET /api/v1/internal/selections/{id} (m2m crm, policy InternalCrmOnly; дом контракта — будущий
../api/internal/get-selection.md) — показывает Title/состав. Read-only на стороне selections:
- Доступ. Вызыватель — доверенный m2m-клиент
crm (policy InternalCrmOnly); id — непустой
Guid, иначе ValidationError.
- Поиск. Подборка ищется по
id с элементами (граница агрегата, ≤ 200, INV-SEL-004). Предикат
DeletedAt == null не накладывается: CRM хранит SelectionId и обязан различать «удалена» и
«не существует вовсе». Не найдена → SELECTIONS_NOT_FOUND (404).
- Проекция
InternalSelectionResult (дельта дома полей
../domain/selection.md): SelectionId, Title, Description,
PublicCode, Items в порядке SortOrder (PropertyObjectId, SortOrder, Comment),
IsDeleted = (DeletedAt != null). Для soft-deleted подборки IsDeleted = true — INV-SEL-009
(публичный 404) на internal-чтение crm не распространяется (soft delete сохраняет разрешимость
ссылок для CRM/snapshot-ов). При IsDeleted = true CRM показывает «подборка недоступна/удалена»,
иначе — Title и состав.
- Просмотры/действия клиента по ссылке трекает
user-activity (гостевая сессия X-Anonymous-Id,
клиентский ActionEvent, ADR-0054) → таймлайн лида. Карточка лида CRM дополнительно обогащается
ActionEvent «агент создал подборку» из pin.selections.selection-created.v1 (consumer user-activity
— ../events/selection-created.md), опубликованного ранее при
создании подборки (UC-SEL-001) — не в этом
сценарии.
Промежуточное состояние (eventual consistency). POST /selections/{id}/share подтверждается
синхронно (selections фиксирует факт и ставит событие в outbox); запись «подборка на лиде»
(LeadEvent SelectionAttached) появляется в таймлайне crm только после потребления
selection-shared.v1 (окно — SLA консюмера crm < 5 мин,
../events/selection-shared.md). До этого агент видит успех отправки,
но лента лида ещё без записи — это ожидаемое промежуточное состояние. Повторная отправка той же
подборки тому же клиенту — осознанно новый факт: каждая операция share даёт своё событие
(уникальный MessageId, свой OccurredAt) и свою запись в ленте лида — консюмер дубли одного
события схлопывает по MessageId, но разные отправки во времени не объединяет.
Альтернативные Потоки
A1. Агент удалил подборку после прикрепления
- internal-резолв (без предиката
DeletedAt == null) возвращает запись с IsDeleted = true → CRM
показывает «подборка удалена» ([../api/public/delete-selection.md] — soft delete; INV-SEL-009 на internal
не распространяется). Публичная ссылка /collection/{publicCode} при этом отдаёт 404
(UC-SEL-003 A1, INV-SEL-009 — публичная часть). Завершается.
A2. referral недоступен
- SharePopover деградирует до ссылки без
?ref= — шаринг подборки не блокируется реферальной
системой (UC-SEL-002 A1). Возврат к основному потоку, шаг 4.
A3. Клиент регистрируется/оставляет заявку в окне привязки
- Реферальная связь «кто привёл» и лид формируются по правилам
referral + crm (Имя пустое /
регистрация в окне / владелец-агент → лид в CRM у агента-владельца; glossary «Реферальная связь»).
- Это зона
referral/crm (их UC); selections в формировании связи не участвует. Завершается.
A4. Клиент уже авторизован
- Cookie
referralUrl ставится, но реферальная связь не возникает (правила привязки referral
отсеивают существующих пользователей вне окна — UC-SEL-002 A3); подборка открывается как обычно
(UC-SEL-003 A4 — любой актор). Завершается.
Постусловие
selections выполнил операцию share: состав подборки не изменился, но опубликовано
pin.selections.selection-shared.v1 (Action = Shared) — факт для ленты лида crm
(SelectionAttached) и истории действий user-activity.
- Публичная ссылка рабочая и не протухает (
PublicCode неизменяем, INV-SEL-005).
- Владение связью «подборка ↔ лид» и её материализация в таймлайн — у
crm (потребитель события);
selections события не потребляет. Факт создания подборки дал selection-created.v1 ранее
(UC-SEL-001).
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
UNAUTHORIZED | 401 | GET /selections / POST /selections/{id}/share без/с просроченным JWT | refresh identity → повтор (агент) |
SELECTIONS_NOT_FOUND | 404 | POST /selections/{id}/share: подборка чужая (INV-SEL-002, чужое существование не раскрывается) или удалена (INV-SEL-009) | «Подборку нельзя отправить» |
SELECTIONS_NOT_FOUND | 404 | internal-резолв: SelectionId не существует вовсе (не «удалена») | CRM: «подборка недоступна» |
SELECTIONS_NOT_FOUND | 404 | публичное открытие удалённой/несуществующей (UC-SEL-003 A1) | «подборка не найдена» |
FORBIDDEN | 403 | internal-вызыватель не m2m crm (policy InternalCrmOnly) | — (дефект интеграции) |
5xx от referral | — | Реф-ссылку получить не удалось (A2) | Деградация: ссылка без ?ref= |
Soft-deleted подборка через internal — не ошибка: 200 + IsDeleted = true (A1).
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
SelectionSharedEvent | Операция share (POST /selections/{id}/share, шаг 2) — агент отправил подборку клиенту | Публикуется как интеграционное SelectionSharedKafkaEvent |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
SelectionSharedKafkaEvent | pin.selections.selection-shared.v1 | Шаг 2: агент отправил подборку клиенту (Action = Shared) — атомарно с фиксацией операции share | crm (лента лида SelectionAttached — crm/consumers/selection-shared.md), user-activity (ActionEvent «отправил подборку») |
Контракт события — ../events/selection-shared.md. selection-created.v1
для этой подборки был опубликован ранее при её создании
(UC-SEL-001) — отдельное событие, не в этом сценарии.
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | GET /selections self-scoped: агент видит только свои подборки, чужие не возвращаются | тест изоляции record-level (обязателен) |
| AC2 | GET /internal/selections/{id} резолвит подборку, включая soft-deleted (IsDeleted = true, не 404) | контракт-тест internal-области |
| AC3 | Несуществующий SelectionId → internal 404 (отличим от soft-deleted IsDeleted = true) | негативный тест |
| AC4 | Клиент открывает подборку по publicCode без данных владельца (OwnerUserId в ответе отсутствует) | контракт-тест публичной проекции (UC-SEL-003 AC2) |
| AC5 | Операция POST /selections/{id}/share публикует selection-shared.v1 (Action = Shared, AgentUserId/InvestorUserId/SelectionTitle/PropertyObjectsCount); состав подборки при этом не меняется | тест публикации события + неизменности состава |
| AC6 | PublicCode остаётся рабочим после прикрепления и правок подборки (INV-SEL-005) | интеграционный тест |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Domain: ../domain/selection.md (
PublicCode INV-SEL-005, masking
OwnerUserId, soft delete INV-SEL-009); ../domain/selection-snapshot.md.
- API методы (будущие пути по конвенции):
../api/public/list-my-selections.md
(список агента), ../api/public/get-selection-by-code.md (открытие клиентом),
../api/internal/get-selection.md (резолв для CRM), ../api/public/delete-selection.md (soft delete).
- Смежные UC: UC-SEL-002 (шаринг ссылки),
UC-SEL-003 (открытие клиентом),
UC-SEL-001 (создание подборки).
- События: ../events/selection-shared.md (контракт
selection-shared,
публикуется операцией share), ../events/README.md (карта топиков); контракт
создания — ../events/selection-created.md.
- Границы: ../README.md; будущие пути (по конвенции):
docs/06-services/crm/README.md (владеет связью «подборка ↔ лид»),
docs/06-services/referral/README.md (реф-ссылка SharePopover),
docs/06-services/user-activity/README.md (ActionEvent, ADR-0054),
docs/06-services/catalog/README.md (display-данные объектов недвижимости).
- Решения: ADR-0052 (вне tenant), ADR-0054 (ActionEvent user-activity), ADR-0056 (PostgreSQL-only).