Перейти к основному содержимому

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

В invite-company-user.md «invite = сразу UserEntity в статусе Invited» без самоподтверждения — этот путь сохраняется для внутренних сотрудников (менеджеров/операторов), которых заводит руководитель. CompanyInvitation — про добор агента «со стороны», у которого уже может быть учётка и который должен явно согласиться вступить.

Классификация

ПолеЗначение
Контекстidentity
KindAggregateRoot (собственная консистентность и события)
Source of truthidentity
Таблицаidentity.company_invitations
Tenant isolationTenantId = CompanyId (приглашающая компания владеет приглашением); адресат-получатель обращается к приглашению record-level по совпадению телефона, не по tenant (см. INV-CI2, API accept/decline)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор приглашения.
CompanyIdGuidДаFK fk_company_invitations_company_id → identity.companies(id), index ix_company_invitations_company_idapp (claims tenant_id)Приглашающая компания (Agency/Broker/Partner). TenantId.
PhoneNumberstringДаvarchar(32), нормализация E.164, index ix_company_invitations_phoneapp (тело invite)Телефон приглашаемого агента. Может ещё не соответствовать ни одной учётке. PII.
TargetRoleIdGuidДаFK fk_company_invitations_role → identity.role_definitions(id)appРоль, назначаемая при accept (ADR-0061 TargetRole): по умолчанию системная agent; допускается внутренняя роль агентства (INV-CI7).
StatusCompanyInvitationStatusДаvarchar(16), index ix_company_invitations_statusappPending / Accepted / Declined / Expired / Revoked.
ExpiresAtDateTimeOffsetДа> CreatedAtappСрок жизни приглашения (дефолт — 14 суток); после — Expired (INV-CI4).
InvitedByUserIdGuidДаFK → identity.users(id)app (actor)Кто пригласил (руководитель/менеджер).
AcceptedByUserIdGuid?НетFK → identity.users(id)appКто принял (учётка, чей телефон совпал); null до accept.
AcceptedAtDateTimeOffset?НетappМомент accept.
DeclinedAtDateTimeOffset?НетappМомент decline.
RevokedAtDateTimeOffset?НетappМомент revoke приглашающей стороной.
CreatedAtDateTimeOffsetДаindex ix_company_invitations_created_atappКогда создано.
CreatedByUserIdGuid?Нет= InvitedByUserIdappActor-stamping.
UpdatedAtDateTimeOffsetДаappШтамп изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший (accept/decline/revoke).
VersionlongДа>= 1appДоменная версия.

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
CompanyEntityreferenceCompanyId (FK)GetById(CompanyId) — проверка Active при созданииrestrict
RoleDefinition (TargetRoleId)referenceTargetRoleId (FK)GetById(TargetRoleId) — Active, назначима в Organizationrestrict
UserEntity (приглашённый)reference (по телефону)PhoneNumberusers.phone_numberFindBy(PhoneNumber) в accept (INV-CI2)— (мягкая, без FK: адресата может ещё не быть)
AgentMembershipEntityreference (порождает)InvitationId в membershipсоздаётся в accept (та же транзакция)

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-CI1 PendingTransitionsOnlyПереход возможен только из Pending в {Accepted, Declined, Revoked, Expired}; терминальные — иммутабельныguard IEntityWithStatus в методах Accept/Decline/Revoke/ExpireIDENTITY_INVITATION_INVALID_STATUS
INV-CI2 PhoneMatchOnAcceptAccept допустим только если телефон принимающего пользователя == PhoneNumber приглашенияaccept-handler: acceptingUser.PhoneNumber == invitation.PhoneNumberIDENTITY_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 NotExpiredAccept/Decline допустимы только при now < ExpiresAt; иначе приглашение уже Expiredшаг валидации + job expire-invitationsIDENTITY_INVITATION_EXPIRED
INV-CI5 RevokeByInviterSideRevoke — только актором приглашающей компании с identity.users.manage в её scopeauthorization behavior403 FORBIDDEN
INV-CI6 CompanyActiveСоздать приглашение можно только при Company.Status = Active, Type ∈ {Agency, Broker, Partner}GetById(CompanyId) в inviteIDENTITY_COMPANY_SUSPENDED / IDENTITY_USER_TYPE_COMPANY_MISMATCH
INV-CI7 TargetRoleAssignableTargetRoleId — 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).

Edge — телефон адресата сменился после создания

accept матчит по текущему телефону учётки; если он больше не равен PhoneNumber приглашения → IDENTITY_INVITATION_PHONE_MISMATCH; приглашение остаётся Pending до Revoke/Expired.

  • Истечение. По ExpiresAt фоновый job expire-invitations переводит Pending → Expired. accept/ decline в интервале «ExpiresAt прошёл, но job ещё не отработал» отклоняются IDENTITY_INVITATION_EXPIRED ленивой проверкой (INV-CI4) — независимо от job.
  • Свежесть. Лента найма (crm/notifications) строится по CompanyInvitationChangedKafkaEvent (key = Id) — eventual, окно доставки; членство доступно downstream после AgentMembershipChangedKafkaEvent.

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
CompanyInvitationCreatedEventIDomainEventInvite (создание Pending)Id, CompanyId, PhoneNumber, TargetRoleId
CompanyInvitationResolvedEventIDomainEventAccept/Decline/Revoke/ExpireId, Status, AcceptedByUserId?
CompanyInvitationChangedKafkaEventIKafkaEvent (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
Партиционирование / retentionnone; терминальные приглашения хранятся бессрочно (аудит найма)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую); гонка двух accept/revoke — проигравший получает CONCURRENCY_CONFLICT; гонка Pending-дублей разрешается ux_company_invitations_active
Permissions / PIIPhoneNumber — PII (в логах +7***1234); invite/revoke — identity.users.manage в scope компании; accept/decline — self-действие адресата (record-level по телефону, не tenant)
AuditVersion + actor-stamping (InvitedByUserId/UpdatedByUserId); каждое изменение — Kafka-событие (лента найма для crm/notifications)

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы