POST /api/v1/companies/my/users/invitations — Приглашение пользователя в компанию
Создание учётки сотрудника своей компании в статусе Invited с опциональным немедленным назначением
ролей. Агенты добавляются только приглашением, если AllowAgentSelfRegistration = false
(workspace-настройка, company.md).
Назначение
Руководитель агентства/менеджер застройщика добавляет сотрудника: указывает телефон, ФИО, роли.
Учётка создаётся сразу (без отдельной pending-invitation-сущности — приглашение = UserEntity в статусе
Invited, stateDiagram user.md); активация — первым успешным входом по СМС.
Owning-контекст — identity. Feature group — companies.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | companies |
| Статус | draft |
| Документ | docs/06-services/identity/api/company-user/invite-company-user.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (пользователь компании) |
| Auth policy / Permissions | identity.users.manage (дефолт: agency-owner, developer-manager — только своя компания) |
| Scope (RBAC) | организация: Active RoleAssignment со scope Platform или Organization(CompanyId); роли назначаются дополнительно по INV-R8 (см. шаг 2) |
| Record-level | не применяется |
| Tenant isolation | TenantId = CompanyId из claims: пригласить можно только в свою компанию |
| Audit | create: CreatedByUserId = actor, событие UserUpsertedKafkaEvent; назначения ролей — GrantedByUserId |
| MFA/approval | не требуется (операция в границах организации; платформенные роли этим методом не выдаются — INV-R3) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/companies/my/users/invitations |
| Success status | 201 |
| Idempotency header | Idempotency-Key (Guid) — повтор с тем же ключом возвращает первый результат |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims | Guid | Да | Required | — |
CompanyId | identity claims (tenant_id) | Guid | Да | Required | Tenant isolation |
IdempotencyKey | header Idempotency-Key | Guid | Да | Required | — |
PhoneNumber | body | string | Да | E.164 после нормализации, <= 32; уникальность INV-1 user.md | PII — в логах маскируется |
Email | body | string? | Нет | формат email, <= 256; уникальность INV-3 | PII — маскируется |
FirstName | body | string | Да | 1..128 | PII |
LastName | body | string | Да | 1..128 | PII |
MiddleName | body | string? | Нет | <= 128 | PII |
RoleIds | body | IReadOnlyList<Guid> | Нет (default — WorkspaceSettings.DefaultRoleId, если задан) | <= 10 элементов; каждая роль — Active, этой компании или системная назначимая в Organization | — |
Модель ответа InviteCompanyUserCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
UserId | Guid | Да | созданный UserEntity.Id | — | Id учётки. |
Status | string (enum UserStatus) | Да | UserEntity.Status | — | Всегда Invited. |
AssignedRoleIds | IReadOnlyList<Guid> | Да | созданные RoleAssignment.RoleId | — | Фактически назначенные роли. |
CreatedAt | DateTimeOffset | Да | UserEntity.CreatedAt | — | Момент создания. |
Предусловия и постусловия
Предусловия. Актор аутентифицирован и имеет identity.users.manage в scope Organization(CompanyId);
для назначения ролей — дополнительно identity.role-assignments.manage (INV-R8); компания актора Active;
телефон не занят активной учёткой (INV-1), email (если задан) свободен (INV-3).
Постусловия при успехе. Создана учётка сотрудника Status = Invited, Version = 1, с типом по типу
компании (INV-2); для каждой роли — активное назначение в scope Organization(CompanyId); опубликованы
UserUpsertedKafkaEvent и (при непустых назначениях) UserAccessChangedKafkaEvent. Учётка активируется
первым успешным входом по СМС (вне этой операции). Отметка идемпотентности сохранена.
Постусловия при отказе. Учётка и назначения не созданы, события не публикуются.
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь компании; tenant — его компания (
CompanyId); требуется правоidentity.users.manageв scopeOrganization(CompanyId); для назначения ролей — такжеidentity.role-assignments.manageв этом scope (INV-R8), иначе —FORBIDDEN. Повтор с тем жеIdempotency-Keyвозвращает сохранённый результат. - Проверка входа и предусловий.
- Синтаксис по таблице входных данных (телефон E.164, email, ФИО, лимит ролей); иначе —
ValidationError. - Компания актора существует и активна;
Status != Active→IDENTITY_COMPANY_SUSPENDED(INV-9 user.md). - Телефон свободен (INV-1 user.md): активная учётка с таким телефоном уже есть →
IDENTITY_USER_PHONE_TAKEN(в отличие от входа по СМС, здесь дубль — ошибка). - Email, если задан, свободен (INV-3) → иначе
IDENTITY_USER_EMAIL_TAKEN. - Роли: каждая —
Active(IDENTITY_ROLE_ARCHIVED); внутренняя роль — только этой компании (INV-R2,IDENTITY_ASSIGNMENT_COMPANY_MISMATCH); системная — только организационная (Code ∈ {agent, agency-owner, developer-manager}), совместимая сcompany.Type; платформенные роли (pin-manager/care-operator/investor) запрещены (INV-R3 →IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN).
- Синтаксис по таблице входных данных (телефон E.164, email, ФИО, лимит ролей); иначе —
- Что читаем. Компанию актора и запрошенные роли. При пустом списке ролей — роль по умолчанию
WorkspaceSettings.DefaultRoleIdкомпании (если задана). - Бизнес-правила и маппинг. Создаётся учётка (INV-2 user.md — согласованность
UserType↔Company.Type):Type— по типу компании (Agency|Broker → Agent,Developer → DeveloperManager,Partner → PartnerManager,Pin → PinEmployee),CompanyId— из claims,Status = Invited, ФИО, телефон в E.164, email в нижнем регистре (не подтверждён), контакты управляются самостоятельно, регистрация через admin API, источникPin,Version = 1. Поднимается доменный фактUserRegisteredEvent. Для каждой роли создаётся назначениеRoleAssignmentв scopeOrganization(CompanyId)(инварианты INV-R2/R3/R4/R6; дубликаты в списке схлопываются) с фактомRoleAssignmentGrantedEvent. - Что меняется. Появляются учётка и её назначения ролей.
- Какие факты/события фиксируются.
UserUpsertedKafkaEvent(топикpin.identity.users.v1, ключUserId) — snapshotId/Type/CompanyId/Status/Name/PhoneNumber/RegisteredVia/Version;UserAccessChangedKafkaEvent(топикpin.identity.access.v1, ключUserId) — список активных назначений{RoleCode, ScopeType, ScopeIds}(при непустых назначениях).
- Согласованность. Учётка, назначения, отметка идемпотентности и события фиксируются в одном
согласованном изменении. Гонка одновременного приглашения того же телефона разрешается уникальным
индексом
ux_users_phone_number→IDENTITY_USER_PHONE_TAKEN. - Результат. Идентификатор учётки, статус
Invited, фактически назначенные роли, момент создания (201). Телефон в ответ не возвращается (клиент его знает), в логах — маска.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | UserUpsertedKafkaEvent → pin.identity.users.v1; UserAccessChangedKafkaEvent → pin.identity.access.v1 |
| Уведомления | приглашающая СМС «Вас пригласили в <Название>» — через контекст notifications (event-driven; identity сам СМС не шлёт) |
Интеграционная последовательность и согласованность
Всё асинхронно после фиксации учётки и назначений (в одном согласованном изменении):
UserUpsertedKafkaEvent→ топикpin.identity.users.v1, ключUserId— snapshot профиля (Id/Type/CompanyId/Status/Name/PhoneNumber/RegisteredVia/Version). Потребители: поисковая проекцияUserSearchDocument(сотрудник появляется в поиске «Команда»),crm/прочие реплики профиля.- При непустых назначениях —
UserAccessChangedKafkaEvent→ топикpin.identity.access.v1, ключUserId— список активных назначений; потребители авторизации (crm,catalog,chessboard, gateway) наполняют доступ нового сотрудника. - Контекст
notificationsпо событию отправляет приглашающую СМС.
Здесь учётка создаётся сразу (Invited) без самоподтверждения адресата — путь для внутренних
сотрудников (сравнить с invite-agent-by-phone.md).
Окно eventual-consistency (появление в поиске/доступах у потребителей) — секунды; до входа по СМС учётка
остаётся Invited.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
ValidationError | 400 | Формат телефона/email/ФИО/лимит ролей | Список ошибок | no retry |
IDENTITY_USER_PHONE_TAKEN | 409 | Телефон занят активной учёткой (INV-1) | «Пользователь с таким телефоном уже существует» | no retry |
IDENTITY_USER_EMAIL_TAKEN | 409 | Email занят (INV-3) | «Email уже используется» | no retry |
IDENTITY_ROLE_NOT_FOUND | 400 | Роль из RoleIds не существует | «Роль не найдена» | no retry |
IDENTITY_ROLE_ARCHIVED | 409 | Роль в статусе Archived | «Роль архивирована» | no retry |
IDENTITY_ASSIGNMENT_COMPANY_MISMATCH | 409 | Роль другой компании (INV-R2) | «Роль принадлежит другой компании» | no retry |
IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN | 403 | Попытка выдать платформенную роль (INV-R3) | «Роль недоступна для назначения» | no retry |
IDENTITY_COMPANY_SUSPENDED | 409 | Компания не Active | «Компания заблокирована» | no retry |
FORBIDDEN | 403 | Нет identity.users.manage / identity.role-assignments.manage | «Недостаточно прав» | no retry |
Совместимость и наблюдаемость
- Новые optional поля формы — additive. Контракт событий — по domain/user.md и domain/role.md, версия контракта событий — 1.
- Logs:
CompanyId,ActorUserId,UserIdрезультата, маскированный телефон; metrics:identity_user_invite_total{result}; traces: OTel; dashboardidentity-overview; runbook: merge учёток при конфликте телефона — README identity, R6.
Acceptance Criteria
- Given компания
Active, актор сidentity.users.manageиidentity.role-assignments.manage, WhenPOSTс телефоном/ФИО иRoleIds, Then201; учёткаInvitedтипа по типу компании, назначения созданы; опубликованыUserUpsertedKafkaEventиUserAccessChangedKafkaEvent. - Given
RoleIdsне задан и вWorkspaceSettings.DefaultRoleIdесть роль, WhenPOST, Then учётка получает роль по умолчанию;AssignedRoleIdsсодержит её. - Given актор имеет
identity.users.manage, но неidentity.role-assignments.manage, аRoleIdsнепуст, WhenPOST, ThenFORBIDDEN(403) — INV-R8. - Given активная учётка с таким телефоном уже есть, When
POST, ThenIDENTITY_USER_PHONE_TAKEN(409) — INV-1. - Given email задан и уже используется, When
POST, ThenIDENTITY_USER_EMAIL_TAKEN(409) — INV-3. - Given в
RoleIds— платформенная роль (pin-manager/care-operator/investor), WhenPOST, ThenIDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN(403) — INV-R3. - Given в
RoleIds— внутренняя роль чужой компании, WhenPOST, ThenIDENTITY_ASSIGNMENT_COMPANY_MISMATCH(409) — INV-R2. - Given компания актора
Suspended, WhenPOST, ThenIDENTITY_COMPANY_SUSPENDED(409) — INV-9. - Given первый вызов создал учётку по ключу
K, When повтор с тем жеIdempotency-Key = K, Then возвращается тот жеUserId, вторая учётка не создаётся. - Given два параллельных приглашения того же телефона, When оба прошли предпроверку, Then ровно
одно создаёт учётку, второе →
IDENTITY_USER_PHONE_TAKEN(409) на индексе. - Given
RoleIds = []иDefaultRoleIdне задан, WhenPOST, Then учётка создаётсяInvitedбез назначений;UserAccessChangedKafkaEventне публикуется (эффективные права пусты).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: CompanyInvitationEntity
- API (4): GET /api/v1/companies/my/users — Список пользователей компании, Identity Company-User API — реестр методов и общие правила области, POST /api/v1/companies/my/agent-invitations — Приглашение агента по номеру телефона, POST /api/v1/companies/my/users/{userId}/block — Блокировка пользователя компании
- Use Cases (1): UC-IDN-006. Приглашение менеджера (агента) в компанию
- adr (1): ADR-0061: Независимый агент, отвязка от агентства и приглашения по номеру телефона
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: domain/user.md (фабрика, INV-1/2/3), domain/role.md (INV-R2/R3/R6/R8).
- Назначение ролей позже: grant-role-assignment.md; блокировка: block-company-user.md.