POST /api/v1/me/active-agency — Выбор активного агентства
Агент с несколькими активными членствами выбирает, в контексте какого агентства он сейчас действует
(ADR-0070). Операция устанавливает durable-указатель RefreshSession.ActiveCompanyId, синхронизирует
зеркало UserEntity.CompanyId и переиздаёт access-JWT с claim tenant_id = ActiveCompanyId (контракт
ADR-0052 TenantId = CompanyId) — смена агентства действует немедленно. Уже созданные CRM-ресурсы не
трогаются: их снапшот AgencyCompanyId/OriginAgencyCompanyId иммутабелен.
Назначение
Явный, серверный выбор активного агентства при мульти-членстве. Клиент вызывает операцию:
- после логина/refresh, когда пришёл
nextAction = SelectActiveAgency(у агента > 1 активного членства и активное агентство ещё не выбрано либо выбранное перестало быть активным — увольнение, ADR-0072); - при явном переключении между агентствами из личного кабинета.
Выбор durable — переживает refresh и повторные входы (хранится на refresh-сессии, а не только в
короткоживущем access-токене); последующий refresh продолжает выводить tenant_id из ActiveCompanyId.
Owning-контекст — identity. Дом полей сессии — domain/user-session.md
(RefreshSession.ActiveCompanyId).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | me |
| Feature group | active-agency |
| Статус | draft |
| Документ | docs/06-services/identity/api/me/set-active-agency.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (агент с ≥ 1 активным членством) |
| Auth policy / Permissions | authenticated; self-действие (UserId из claim sub) |
| Scope (RBAC) | self; выбор ограничен активными членствами самого актора |
| Record-level | собственная сессия (sid из access-JWT) и собственные активные AgentMembershipEntity (по AgentUserId) |
| Tenant isolation | результат меняет claim tenant_id актора; выбор валидируется против активных членств — задать чужой/неактивный tenant нельзя |
| Audit | update RefreshSession (ActiveCompanyId); update user (зеркало CompanyId); reissue access-JWT |
| MFA/approval | не требуется |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/me/active-agency |
| Success status | 200 |
| Idempotency header | не требуется (повтор с тем же CompanyId — идемпотентный 200, ActiveCompanyId уже равен целевому) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
SessionId | identity claims (sid) | Guid | Да | сессия существует и Active | сессия актора by construction |
CompanyId | body | Guid | Да | непустой Guid; должен быть среди активных членств актора | выбор валидируется на сервере (заголовочный tenant отвергнут, ADR-0070) |
Тело запроса: { "CompanyId": "<Guid>" }.
Модель ответа ActiveAgencyResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
ActiveCompanyId | Guid | Да | RefreshSession.ActiveCompanyId | — | Выбранное активное агентство (= CompanyId запроса). |
CompanyName | string | Да | CompanyEntity.Name активного агентства | — | Отображаемое имя выбранного агентства. |
Role | string (enum AgentMembershipRole) | Да | AgentMembershipEntity.Role в активном членстве | — | Роль агента в выбранном агентстве (напр. Agent / Lead). |
Memberships | ActiveAgencyMembershipItem[] | Да | активные AgentMembershipEntity актора | — | Полный список активных членств для рендера переключателя агентств. |
AccessToken | JwtTokenModel | Да | переизданный JWT | не логируется | Новый access-JWT с claim tenant_id = ActiveCompanyId ({token, notBefore, type, expirationDate}, user-session.md); смена агентства действует немедленно, без отдельного refresh. |
ActiveAgencyMembershipItem
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
CompanyId | Guid | Да | AgentMembershipEntity.CompanyId | Агентство членства. |
CompanyName | string | Да | CompanyEntity.Name | Имя агентства. |
Role | string (enum AgentMembershipRole) | Да | AgentMembershipEntity.Role | Роль агента в этом агентстве. |
IsActive | bool | Да | вычисляется | true для агентства, совпадающего с выбранным ActiveCompanyId. |
Алгоритм
- Контекст и доступ. Операцию выполняет сам агент (
ActorUserIdиз claimsub,SessionIdиз claimsid); без токена —401. - Проверка входа и предусловий.
CompanyIdнепустой Guid — иначеValidationError(400);- учётка
ActorUserIdсуществует и в статусеActive— иначеIDENTITY_USER_BLOCKED(400); - тип учётки —
Agent(у инвестора нет членств) — иначеIDENTITY_NOT_AN_AGENT(409); - целевое
CompanyId— среди активных членств актора (AgentMembershipEntityсоStatus = ActiveпоAgentUserId); нет такого активного членства →IDENTITY_MEMBERSHIP_NOT_ACTIVE(404); - агентство целевого членства (
CompanyEntity) в статусеActive— иначеIDENTITY_COMPANY_SUSPENDED(409).
- Что читаем. Учётка агента; его активные членства (
Status = Active, инвариантSingleActivePerCompany— не более одной активной записи на пару(AgentUserId, CompanyId), ADR-0070); компании этих членств (имена, статусы); текущая refresh-сессия поSessionId. - Бизнес-правила и переходы.
RefreshSession.ActiveCompanyId = CompanyId(durable-выбор; переживает refresh);- зеркало
UserEntity.CompanyId = CompanyId(семантика «текущее выбранное» агентство, ADR-0070 п.4; INV-S9 user-session.md); - переиздаётся access-JWT:
sub = ActorUserId,sid = SessionId,tenant_id = ActiveCompanyId, TTL как у обычного access-токена (permissions в JWT не кладутся — авторизация через check-permission); - снятие
nextAction = SelectActiveAgency: после выбора клиент допускается к CRM-ресурсам агентства.
- Что меняется. Обновляются
RefreshSession.ActiveCompanyIdи зеркалоUserEntity.CompanyId; выпускается новый access-JWT. Изменения и (при смене зеркала) публикация события — в одном согласованном изменении. - События. При фактической смене зеркала
UserEntity.CompanyIdпубликуется user-upserted вpin.identity.users.v1(CompanyId = ActiveCompanyId) — downstream видит текущее агентство агента. Отдельного события «сменил активное агентство» нет; выбор — состояние сессии. Refresh-токен не ротируется (сессия та же), поэтому событие входа не публикуется. - Результат.
ActiveAgencyResult:ActiveCompanyId,CompanyName,Role(в выбранном агентстве),Memberships[](все активные членства с флагомIsActive),AccessToken(новый claimtenant_id) →200.
Уже созданные лиды/сделки/фиксации не пересчитываются — их снапшот AgencyCompanyId/OriginAgencyCompanyId
иммутабелен (ADR-0061 п.4, ADR-0070 п.4).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | при смене зеркала — user-upserted → pin.identity.users.v1 (CompanyId = ActiveCompanyId) |
| External calls | none |
| Cache / projections / search | новый claim tenant_id определяет tenant последующих CRM create-* операций; historical-снапшоты не трогаются |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Пустой/некорректный CompanyId | «Некорректный запрос» | no retry |
IDENTITY_USER_BLOCKED | 400 | user.Status != Active | «Учётная запись заблокирована» | no retry |
IDENTITY_NOT_AN_AGENT | 409 | user.Type != Agent (нет членств) | «Вы не являетесь агентом» | no retry |
IDENTITY_MEMBERSHIP_NOT_ACTIVE | 404 | CompanyId не среди активных членств актора | «Активное членство не найдено» | no retry |
IDENTITY_COMPANY_SUSPENDED | 409 | Агентство целевого членства не Active | «Агентство заблокировано» | no retry |
CONCURRENCY_CONFLICT | 409 | Гонка версий сессии/учётки (параллельный refresh/leave) | «Данные изменены, повторите» | retry после перечитки |
Acceptance Criteria
- Given агент с ≥ 2 активными членствами и
nextAction = SelectActiveAgency, WhenPOST /me/active-agencyс валиднымCompanyIdиз активных членств, Then200;RefreshSession.ActiveCompanyId = CompanyId, зеркалоUserEntity.CompanyId = CompanyId, выпущен новый access-JWT сtenant_id = CompanyId; ответ содержитActiveCompanyId/CompanyName/RoleиMemberships[]сIsActive = trueу выбранного;nextActionснят. - Given уже выбранное активное агентство, When повтор с тем же
CompanyId, Then идемпотентный200(ActiveCompanyIdуже равен целевому), выдан свежий access-JWT. - Given
CompanyIdне входит в активные членства актора (не член / уволен / членство закрыто), WhenPOST /me/active-agency, ThenIDENTITY_MEMBERSHIP_NOT_ACTIVE(404). - Given агентство целевого членства не
Active, WhenPOST /me/active-agency, ThenIDENTITY_COMPANY_SUSPENDED(409). - Given актор — инвестор (нет членств), When
POST /me/active-agency, ThenIDENTITY_NOT_AN_AGENT(409). - Given учётка
Blocked, WhenPOST /me/active-agency, ThenIDENTITY_USER_BLOCKED(400). - Given параллельный refresh/leave меняет сессию, When выбор конфликтует по версии, Then
CONCURRENCY_CONFLICT(409); retry после перечитки. - Given выбор выполнен, Then ранее созданные CRM-ресурсы не пересчитываются (снапшоты
AgencyCompanyId/OriginAgencyCompanyIdиммутабельны); последующий refresh продолжает выводитьtenant_idизActiveCompanyId(durable); последующие CRM create-* берут агентство из этого claim.
Совместимость и наблюдаемость
- Новый эндпоинт, additive (ADR-0070). Выбор активного агентства — серверный, валидируется против активных членств; заголовочный tenant отвергнут.
- Logs:
ActorUserId,SessionId,ActiveCompanyId(токен — никогда); metrics:identity_set_active_agency_total{result}; traces: OTel.
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-CRM-014. Агент выбирает активное агентство перед созданием лида/сделки, US-реестр «Участники и владение процессами» (US-CRM-PART / US-AG-ADMIN / US-AGT-MULTI / US-INV-CO)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сессия и durable-выбор: domain/user-session.md
(
RefreshSession.ActiveCompanyId, INV-S9). - Членство и инвариант
SingleActivePerCompany: domain/agent-membership.md. - Семантика зеркала
CompanyId: domain/user.md. - Логин/refresh и
nextAction = SelectActiveAgency: investor/refresh-token.md, investor/confirm-sign-in.md. - Выход/увольнение (меняют состав активных членств): agent/leave-agency.md, agent/become-investor.md.
- ADR: ADR-0070, ADR-0052, ADR-0061.