Entity / Aggregate: CompanyInvitationEntity
Дом полей агрегата «Приглашение агента в компанию по номеру телефона» контекста identity (ADR-0061).
Все проекции (payload события pin.identity.company-invitation-changed.v1, модели ответов API
invite/accept/decline/revoke) ссылаются сюда и перечисляют только дельту.
Назначение
CompanyInvitationEntity отделяет приглашение от создания учётки. Руководитель агентства/брокера
приглашает нового агента по номеру телефона (адресат может уже быть инвестором или агентом другой
компании); приглашённый сам подтверждает (accept) или отклоняет (decline), и только accept
создаёт/меняет членство (agent-membership.md) и назначает роль.
В invite-company-user.md «invite = сразу
UserEntity в статусе Invited» без самоподтверждения — этот путь сохраняется для внутренних
сотрудников (менеджеров/операторов), которых заводит руководитель. CompanyInvitation — про добор агента
«со стороны», у которого уже может быть учётка и который должен явно согласиться вступить.
Классификация
| Поле | Значение |
|---|---|
| Контекст | identity |
| Kind | AggregateRoot (собственная консистентность и события) |
| Source of truth | identity |
| Таблица | identity.company_invitations |
| Tenant isolation | TenantId = CompanyId (приглашающая компания владеет приглашением); адресат-получатель обращается к приглашению record-level по совпадению телефона, не по tenant (см. INV-CI2, API accept/decline) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор приглашения. |
CompanyId | Guid | Да | FK fk_company_invitations_company_id → identity.companies(id), index ix_company_invitations_company_id | app (claims tenant_id) | Приглашающая компания (Agency/Broker/Partner). TenantId. |
PhoneNumber | string | Да | varchar(32), нормализация E.164, index ix_company_invitations_phone | app (тело invite) | Телефон приглашаемого агента. Может ещё не соответствовать ни одной учётке. PII. |
TargetRoleId | Guid | Да | FK fk_company_invitations_role → identity.role_definitions(id) | app | Роль, назначаемая при accept (ADR-0061 TargetRole): по умолчанию системная agent; допускается внутренняя роль агентства (INV-CI7). |
Status | CompanyInvitationStatus | Да | varchar(16), index ix_company_invitations_status | app | Pending / Accepted / Declined / Expired / Revoked. |
ExpiresAt | DateTimeOffset | Да | > CreatedAt | app | Срок жизни приглашения (дефолт — 14 суток); после — Expired (INV-CI4). |
InvitedByUserId | Guid | Да | FK → identity.users(id) | app (actor) | Кто пригласил (руководитель/менеджер). |
AcceptedByUserId | Guid? | Нет | FK → identity.users(id) | app | Кто принял (учётка, чей телефон совпал); null до accept. |
AcceptedAt | DateTimeOffset? | Нет | — | app | Момент accept. |
DeclinedAt | DateTimeOffset? | Нет | — | app | Момент decline. |
RevokedAt | DateTimeOffset? | Нет | — | app | Момент revoke приглашающей стороной. |
CreatedAt | DateTimeOffset | Да | index ix_company_invitations_created_at | app | Когда создано. |
CreatedByUserId | Guid? | Нет | = InvitedByUserId | app | Actor-stamping. |
UpdatedAt | DateTimeOffset | Да | — | app | Штамп изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший (accept/decline/revoke). |
Version | long | Да | >= 1 | app | Доменная версия. |
Enum CompanyInvitationStatus
| Значение | Описание |
|---|---|
Pending | Создано, ждёт решения адресата; единственное «активное» состояние. |
Accepted | Принято адресатом → создано/обновлено AgentMembership + выдана роль. Терминальное. |
Declined | Отклонено адресатом. Терминальное. |
Expired | Истёк ExpiresAt (переводит background-job expire-invitations). Терминальное. |
Revoked | Отозвано приглашающей стороной до решения адресата. Терминальное. |
Value objects / EF owned types
Owned-VO нет.
Модели JSONB (IDocument, versioned)
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
CompanyEntity | reference | CompanyId (FK) | GetById(CompanyId) — проверка Active при создании | restrict |
RoleDefinition (TargetRoleId) | reference | TargetRoleId (FK) | GetById(TargetRoleId) — Active, назначима в Organization | restrict |
UserEntity (приглашённый) | reference (по телефону) | PhoneNumber → users.phone_number | FindBy(PhoneNumber) в accept (INV-CI2) | — (мягкая, без FK: адресата может ещё не быть) |
AgentMembershipEntity | reference (порождает) | InvitationId в membership | создаётся в accept (та же транзакция) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-CI1 PendingTransitionsOnly | Переход возможен только из Pending в {Accepted, Declined, Revoked, Expired}; терминальные — иммутабельны | guard IEntityWithStatus в методах Accept/Decline/Revoke/Expire | IDENTITY_INVITATION_INVALID_STATUS |
| INV-CI2 PhoneMatchOnAccept | Accept допустим только если телефон принимающего пользователя == PhoneNumber приглашения | accept-handler: acceptingUser.PhoneNumber == invitation.PhoneNumber | IDENTITY_INVITATION_PHONE_MISMATCH |
| INV-CI3 SingleActivePending | Не более одного Pending на (CompanyId, PhoneNumber, TargetRoleId) | unique partial index ux_company_invitations_active where status = 'Pending' + Any(spec) до вставки | IDENTITY_INVITATION_DUPLICATE |
| INV-CI4 NotExpired | Accept/Decline допустимы только при now < ExpiresAt; иначе приглашение уже Expired | шаг валидации + job expire-invitations | IDENTITY_INVITATION_EXPIRED |
| INV-CI5 RevokeByInviterSide | Revoke — только актором приглашающей компании с identity.users.manage в её scope | authorization behavior | 403 FORBIDDEN |
| INV-CI6 CompanyActive | Создать приглашение можно только при Company.Status = Active, Type ∈ {Agency, Broker, Partner} | GetById(CompanyId) в invite | IDENTITY_COMPANY_SUSPENDED / IDENTITY_USER_TYPE_COMPANY_MISMATCH |
| INV-CI7 TargetRoleAssignable | TargetRoleId — Active, либо системная agent, либо внутренняя роль этой компании (INV-R2 role.md); платформенные роли ПИН запрещены (INV-R3) | invite/accept валидация | IDENTITY_ASSIGNMENT_COMPANY_MISMATCH / IDENTITY_ASSIGNMENT_PLATFORM_SCOPE_FORBIDDEN |
Разрешение приглашения: эффекты, конкурентность, свежесть
- Accept (
Pending → Accepted). Предусловие:now < ExpiresAt(INV-CI4) и телефон принимающего ==PhoneNumber(INV-CI2). Эффект в одном согласованном изменении: если принимающий — инвестор, он повышается до агента (рольagent, agent-membership.md); открывается/переоткрываетсяAgentMembership(Active, сInvitationId); создаётсяRoleAssignment(TargetRoleId, Organization(CompanyId)); приглашение →Accepted(AcceptedByUserId/AcceptedAt). Постусловие: у пользователя активное членство в компании и назначенная роль; ушлиCompanyInvitationResolvedEvent+CompanyInvitationChangedKafkaEvent+ событие членства +UserAccessChangedKafkaEvent. - Decline / Revoke.
Decline(адресат) иRevoke(приглашающая сторона,identity.users.manage, INV-CI5) переводятPending → Declined/Revoked; членство и роль не создаются.
Гонка accept vs revoke и двойной accept: guard IEntityWithStatus из Pending пускает первого,
проигравший получает IDENTITY_INVITATION_INVALID_STATUS (или CONCURRENCY_CONFLICT при гонке версий).
Два Pending на один (CompanyId, PhoneNumber, TargetRoleId) исключены
ux_company_invitations_active (INV-CI3).
accept матчит по текущему телефону учётки; если он больше не равен PhoneNumber приглашения →
IDENTITY_INVITATION_PHONE_MISMATCH; приглашение остаётся Pending до Revoke/Expired.
- Истечение. По
ExpiresAtфоновый jobexpire-invitationsпереводитPending → Expired.accept/declineв интервале «ExpiresAtпрошёл, но job ещё не отработал» отклоняютсяIDENTITY_INVITATION_EXPIREDленивой проверкой (INV-CI4) — независимо от job. - Свежесть. Лента найма (
crm/notifications) строится поCompanyInvitationChangedKafkaEvent(key =Id) — eventual, окно доставки; членство доступно downstream послеAgentMembershipChangedKafkaEvent.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
CompanyInvitationCreatedEvent | IDomainEvent | Invite (создание Pending) | Id, CompanyId, PhoneNumber, TargetRoleId |
CompanyInvitationResolvedEvent | IDomainEvent | Accept/Decline/Revoke/Expire | Id, Status, AcceptedByUserId? |
CompanyInvitationChangedKafkaEvent | IKafkaEvent (ContractVersion = 1, topic pin.identity.company-invitation-changed.v1, key = Id) | Любой переход статуса приглашения (создание и разрешение) | Id, CompanyId, PhoneNumber, TargetRoleId, Status, InvitedByUserId, AcceptedByUserId?, ExpiresAt, Version — контракт company-invitation-changed.md |
При Accept в одном согласованном изменении открывается/переоткрывается AgentMembership и
создаётся RoleAssignment — оба поднимают свои события; интеграционное событие публикуется атомарно
со сменой статуса приглашения.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK (Id); ux_company_invitations_active unique partial (company_id, phone_number, target_role_id) where status='Pending'; ix_company_invitations_company_id; ix_company_invitations_phone; ix_company_invitations_status; ix_company_invitations_created_at |
| Партиционирование / retention | none; терминальные приглашения хранятся бессрочно (аудит найма) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую); гонка двух accept/revoke — проигравший получает CONCURRENCY_CONFLICT; гонка Pending-дублей разрешается ux_company_invitations_active |
| Permissions / PII | PhoneNumber — PII (в логах +7***1234); invite/revoke — identity.users.manage в scope компании; accept/decline — self-действие адресата (record-level по телефону, не tenant) |
| Audit | Version + actor-stamping (InvitedByUserId/UpdatedByUserId); каждое изменение — Kafka-событие (лента найма для crm/notifications) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: AgentMembershipEntity, Entity / Aggregate: UserEntity
- API (5): Identity Company-User API — реестр методов и общие правила области, POST /api/v1/companies/my/agent-invitations — Приглашение агента по номеру телефона, POST /api/v1/companies/my/agent-invitations/{invitationId}/revoke — Отзыв приглашения агента, POST /api/v1/me/agent-invitations/{invitationId}/accept — Принятие приглашения в агентство, POST /api/v1/me/agent-invitations/{invitationId}/decline — Отклонение приглашения в агентство
- Use Cases (2): UC-IDN-009. Приглашение агента по телефону и его принятие, US-реестр «Участники и владение процессами» (US-CRM-PART / US-AG-ADMIN / US-AGT-MULTI / US-INV-CO)
- События (1): Event Contract: pin.identity.company-invitation-changed (CompanyInvitationChangedKafkaEvent)
- index.md (1): Identity Service (Pin.Identity)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- agent-membership.md (accept открывает членство), user.md (INV-2, роль
agent), role.md (INV-R2/R3, назначениеagent), company.md (AllowAgentSelfRegistration). - API: invite-agent-by-phone.md, revoke-invitation.md, accept-invitation.md, decline-invitation.md.
- Событие: company-invitation-changed.md.
- Отличие от легаси: invite-company-user.md.
- Решение: ADR-0061; шаблон: entity-template.md.