POST /api/agent/crm/leads/{leadId}/extend — Продлить лид
Агент вручную продлевает срок жизни лида на +1 месяц (лид живёт ~месяц и продлевается). Операция закрывает
сценарий из уведомления «продлите лид», которое notifications рассылает по событию
lead-expired, и реализует инвариант INV-LEAD-3 (lead.md):
продлевать можно и Active, и Expired — в последнем случае это реактивация связи «инвестор ↔ агент» с
повторной проверкой INV-CRM-001. Дом полей и переходов — lead.md.
Назначение
Продлить срок жизни лида агента-владельца: ExpiresAt = max(now, ExpiresAt) + 1 месяц, ExtensionsCount += 1,
при необходимости реактивировать истёкший лид (Expired → Active).
INV-LEAD-3: продлить можно и Active, и Expired лид — во втором случае это реактивация связи «агент ↔
инвестор» с повторной проверкой INV-CRM-001 (если у пары уже есть другой активный лид — 409 CRM_LEAD_EXTEND_CONFLICT). Операция не идемпотентна: каждый вызов сдвигает ExpiresAt на +1 месяц и
увеличивает ExtensionsCount, независимо от количества предыдущих вызовов.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | agent |
| Feature group | lead |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/extend-lead.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Агент-владелец; Руководитель агентства — в рамках своего TenantId) |
| Auth policy / Permissions | crm.leads.manage |
| Scope (RBAC) | организация (агентство): агент — свои лиды; руководитель — весь TenantId |
| Record-level | LeadEntity.ExecutorAgentUserId == actor (агент-исполнитель) либо TenantId == actor.AgencyCompanyId (руководитель) |
| Tenant isolation | TenantId = AgencyCompanyId в каждом запросе (ADR-0052) |
| Audit | update (UpdatedByUserId = actor, Version) |
Permission crm.leads.manage действует в пределах своего TenantId (агентство). Внутри tenant record-level
сужает круг: агент продлевает только лиды, где он — исполнитель (ExecutorAgentUserId = actor); руководитель
агентства — любой лид своего TenantId.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/agent/crm/leads/{leadId}/extend |
| Success status | 200 (лид продлён; тело — обновлённая карточка) |
| Idempotency header | не применяется — продление не идемпотентно (каждый вызов = +1 месяц, ExtensionsCount += 1) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required; = AgencyCompanyId | Tenant isolation |
LeadId | route | Guid | Да | != Guid.Empty; существующий лид в TenantId | — |
ActorUserId | identity claims | Guid | Да | JWT агента; используется для record-level и LeadExtendedPayload.ExtendedByUserId | — |
Тело запроса пустое (действие детерминировано: +1 месяц). Причина/срок не параметризуются — правило
фиксированное (INV-LEAD-3).
Модель ответа LeadCardModel
Проекция агрегата — дом полей lead.md; ниже перечислена только дельта, значимая для
продления. LeadCardModel — та же карточка лида, что возвращают list/get агента (полный состав — в доме).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | LeadEntity.Id | — | Идентификатор лида. |
Status | string (enum LeadStatus) | Да | LeadEntity.Status | — | После операции — всегда Active (реактивация Expired). |
ExpiresAt | DateTimeOffset | Да | LeadEntity.ExpiresAt | — | Новый срок = max(now, prev) + 1 месяц. |
ExtensionsCount | int | Да | LeadEntity.ExtensionsCount | — | Инкрементирован на 1. |
LastExtendedAt | DateTimeOffset | Да | LeadEntity.LastExtendedAt | — | = now (момент продления). |
Version | long | Да | LeadEntity.Version | — | Оптимистическая версия после апдейта. |
Алгоритм
- Контекст и доступ. Операцию выполняет агент-владелец / руководитель агентства (permission
crm.leads.manage) в рамкахTenantId. - Проверка входа и предусловий.
- формат:
LeadId != Guid.Empty— иначе ошибка валидации; - лид существует в
TenantId— иначеCRM_LEAD_NOT_FOUND; - допустимость состояния:
Status ∈ {Active, Expired};Archived→CRM_LEAD_NOT_EXTENDABLE(архивный лид не продлевается — только пересоздаётся); - record-level: лид того же tenant и агент — владелец (или руководитель того же
TenantId) — иначеCRM_LEAD_FORBIDDEN.
- формат:
- Что читаем. Лид (строка блокируется на время операции от гонки с worker-ом
lead-expirationи параллельными продлениями). ЕслиStatus == Expired(реактивация) — проверить INV-CRM-001: нет ли другогоActive-лида на ту же пару (агент, инвестор), кроме текущего. - Бизнес-правила и переходы (INV-LEAD-3).
ExpiresAt = max(now, ExpiresAt) + 1 месяц;ExtensionsCount += 1;LastExtendedAt = now. ЕслиStatus == Expiredи найден другойActive-лид пары (INV-CRM-001) →CRM_LEAD_EXTEND_CONFLICT; иначе переходExpired → Active,ExpiredAt = null(реактивация). В ленту лида добавить событиеLeadEventType.LeadExtended(payloadLeadExtendedPayload { NewExpiresAt = ExpiresAt, ExtendedByUserId = actor }, INV-LEAD-2) и обновитьLastEventAt. - Что меняется. Лид (сроки, счётчик, статус) + новое событие ленты — в одном согласованном изменении.
- События. Интеграционных Kafka-событий нет: продление — внутренняя операция агента (топика
pin.crm.lead-extendedне существует, см. README). РеактивацияExpired → Activeне порождает lead-created (это не новый лид); истечение публикует только worker — lead-expired. - Результат. Обновлённая карточка лида (
Status = Active, новыйExpiresAt,ExtensionsCount,LastExtendedAt) → HTTP200. При гонке INV-CRM-001 (реактивация) —CRM_LEAD_EXTEND_CONFLICT.
Диаграмма
Предусловия и постусловия
Предусловия. Актор — владелец лида или руководитель агентства с crm.leads.manage, TenantId = AgencyCompanyId. Лид существует в tenant, Status ∈ {Active, Expired} (не Archived). При реактивации
(Expired) у пары (агент, инвестор) нет другого Active-лида (INV-CRM-001).
Постусловие (успех). ExpiresAt = max(now, prev) + 1 месяц, ExtensionsCount += 1, LastExtendedAt = now.
Если лид был Expired → стал Active, ExpiredAt = null (реактивация). В ленте лида добавлено событие
LeadExtended, обновлён LastEventAt. Интеграционных событий нет (операция внутренняя для агентства).
Постусловие (отказ). Лид не изменён (сроки/счётчик/статус), событие ленты не добавлено.
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | none (продление kafka-событий не публикует) |
| Внешние вызовы | none |
| Проекции / поиск | LastEventAt обновлён → пересортировка списка лидов агента (индекс по последней активности) |
| Уведомления | none (агент сам инициировал; уведомление «продлите» было по lead-expired) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | LeadId = Guid.Empty | «Некорректный идентификатор лида» | no retry |
CRM_LEAD_NOT_FOUND | 404 | лид не найден в TenantId | «Лид не найден» | no retry |
CRM_LEAD_FORBIDDEN | 403 | не владелец / чужой tenant | «Нет доступа к лиду» | no retry |
CRM_LEAD_NOT_EXTENDABLE | 409 | Status = Archived | «Архивный лид нельзя продлить» | no retry |
CRM_LEAD_EXTEND_CONFLICT | 409 | реактивация Expired нарушает INV-CRM-001 (у пары уже есть Active-лид; либо гонка) | «У клиента уже есть активный лид с этим агентом» | no retry |
Совместимость и наблюдаемость
- Route/тело стабильны; добавление optional-полей в
LeadCardModel— additive (без breaking); удаление/ переименование/смена типа — breaking (migration + rollout). EnumLeadStatus— строкой; unknown-значение на входе не встречается (поле выходное). - Logs:
lead-extend requested/succeeded/conflict(LeadId,TenantId,ExtensionsCount; PII не пишется); metrics:crm_lead_extend_total{result},crm_lead_extensions_count(гистограмма); traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Acceptance Criteria
- Happy path (Active). Given
Active-лид сExpiresAtв будущем; Whenextend; Then200,ExpiresAt= прежний + 1 месяц,ExtensionsCount += 1, событие лентыLeadExtended. - Просроченный срок у Active. Given
Active-лид сExpiresAtв прошлом (worker ещё не подхватил); Whenextend; Then новыйExpiresAt = now + 1 месяц(а неprev + 1 месяц). - Реактивация. Given
Expired-лид, у пары нет другогоActive-лида; Whenextend; Then200,Status = Active,ExpiredAt = null. - Конфликт реактивации (INV-CRM-001). Given
Expired-лид и у пары уже есть другойActive-лид; Whenextend; Then409 CRM_LEAD_EXTEND_CONFLICT. - Архивный лид. Given
Status = Archived; Whenextend; Then409 CRM_LEAD_NOT_EXTENDABLE. - Неидемпотентность. Given два
extendподряд; Then срок сдвинут на +2 месяца,ExtensionsCount += 2. - Доступ. Given чужой лид не-руководителю; When
extend; Then403 CRM_LEAD_FORBIDDEN.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): GET /api/agent/crm/deals — Сделки агента (список/поиск)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность и инварианты: lead.md (INV-LEAD-3, INV-CRM-001,
LeadExtendedPayload, переходExpired → Active). - События агрегата: lead-expired.md (источник сценария «продлите лид»), lead-created.md (почему реактивация — не новый лид).
- Соседний flow создания лида: create-booking-request.md.
- ADR: 0052 tenancy, 0056 simplicity-first.