Entity / Aggregate: UserEntity
Дом полей агрегата «Пользователь» контекста identity. Все проекции (модели ответов API,
payload событий, реплики UserId в других контекстах) ссылаются сюда.
Назначение
UserEntity — единая учётная запись платформы PIN: инвестор без родительской компании,
менеджер застройщика, агент/менеджер агентства (брокера, партнёра) или сотрудник ПИН
(Менеджер ПИН, Служба заботы). Надстройка над UserEntity из talent.microservices.identity, но без
жёсткой связи «User per Tenant» — принадлежность компании выражается nullable CompanyId.
Учётная запись поддерживает СМС-вход, 2FA, пароль и пин-код; производные флаги
isTwoFactorEnabled/hasPassword/hasPinCode и контактные поля (name, phoneNumber, email,
createdAt) отдаются в карточке текущего пользователя.
Классификация
| Поле | Значение |
|---|---|
| Контекст | identity |
| Kind | AggregateRoot (для менеджеров застройщика — гибрид с Replica по профильным полям) |
| Source of truth | identity (инвесторы, агенты, сотрудники ПИН); профильные поля менеджеров застройщика — talent.microservices.identity |
| Таблица | identity.users |
| Tenant isolation | TenantId = CompanyId (ADR-0052): инвестор — null, вне tenant; изоляция инвесторских данных — record-level по UserId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app / Talent (UserId) | Идентификатор пользователя. Он же investorId в публичном контракте. |
Type | UserType | Да | varchar(32), index ix_users_type | app / consumer синка | Тип пользователя (см. enum ниже). Согласован с CompanyId (инвариант INV-2). |
CompanyId | Guid? | Нет (обязателен для DeveloperManager/PartnerManager/PinEmployee; для Agent — nullable, ADR-0061/ADR-0070; всегда null у Investor) | FK fk_users_company_id → identity.companies(id), index ix_users_company_id | app / consumer синка | Родительская компания. null — инвестор или независимый агент (ADR-0061). Для Agent при мульти-членстве (ADR-0070) — денормализованное зеркало ТЕКУЩЕГО ВЫБРАННОГО активного агентства (зеркало RefreshSession.ActiveCompanyId), а не «единственное» и не источник истины членства: источник истины — набор активных AgentMembership (agent-membership.md); null у независимого агента (нет активных членств). Синхронизируется при POST /api/v1/me/active-agency. |
Status | UserStatus | Да | varchar(32), index ix_users_status | app | Invited / Active / Blocked / Archived. |
Name | FullName (owned) | Да (LastName может быть пустой строкой при lazy-регистрации) | см. VO ниже | app (СМС-flow/updateInvestor) / Talent sync | ФИО — VO FullName {firstName, lastName, middleName?} (maxLength 128 каждое). |
PhoneNumber | string | Да | varchar(32), нормализация E.164, unique ux_users_phone_number where deleted_at is null | app (signUpOrSignIn) / Talent sync | Основной логин СМС-входа. Смена — только через flow requestPhoneChange/confirmPhoneChange (СМС на новый номер). |
PhoneNumberConfirmedAt | DateTimeOffset? | Нет | — | app | Момент подтверждения номера (первый успешный confirmSignIn по СМС или confirmPhoneChange). |
Email | string? | Нет | varchar(256), формат email, unique ux_users_email where not null and deleted_at is null | app (requestEmailChange/confirmEmailChange) / Talent sync | Email (nullable). |
EmailConfirmedAt | DateTimeOffset? | Нет | — | app | Момент подтверждения email кодом (confirmEmailChange, code ≤ 16 симв.). |
ContactsManagedBy | ContactsManagementMode | Да | varchar(32) | app | SelfService — пользователь сам управляет ФИО/контактами (инвестор, агент, сотрудник ПИН); TalentSync — источник знаний Talent Identity, self-service Update/EmailChange/PhoneChange запрещены (менеджеры застройщика). |
PasswordHash | string? | Нет | varchar(512); Argon2id (parameters версионируются в префиксе хэша) | app (changePassword; min 8 / max 128 симв. пароля) | Хэш пароля. Производный флаг hasPassword = PasswordHash != null. Значение никогда не отдаётся наружу. |
PasswordChangedAt | DateTimeOffset? | Нет | — | app | Момент последней смены пароля (инвалидация сессий). |
PinCodeHash | string? | Нет | varchar(512); Argon2id | app (setPinCode: ^\d{4,12}$ / clearPinCode) | Хэш пин-кода. Производный флаг hasPinCode = PinCodeHash != null. |
IsTwoFactorEnabled | bool | Да | default false | app (confirmEnableTwoFactor / confirmDisableTwoFactor) | Флаг 2FA: при true вход по паролю/пин-коду дополнительно требует СМС-код. |
TwoFactorChangedAt | DateTimeOffset? | Нет | — | app | Момент последнего включения/выключения 2FA. |
FailedAccessAttempts | int | Да | >= 0, default 0 | app | Счётчик подряд неуспешных проверок пароля/пин-кода; сбрасывается успешным входом; при достижении 10 — LockoutUntil = now + 15 мин. |
LockoutUntil | DateTimeOffset? | Нет | — | app | Временная блокировка входа (не меняет Status). |
LastSignInAt | DateTimeOffset? | Нет | index ix_users_last_sign_in_at | app | Последний успешный вход (аналитика активности, admin-API). |
RegisteredVia | RegistrationSource | Да | varchar(32) | app | Источник появления учётки (см. enum ниже). Иммутабельно. |
ReferrerUrl | string? | Нет | varchar(64) | app (signUpOrSignIn/confirmSignIn referrerUrl) | Slug реферальной ссылки, зафиксированный при регистрации; передаётся событием в referral (правила привязки — там). |
ExternalTalentUserId | Guid? | Нет | unique ux_users_external_talent_user_id where not null | Talent sync | Id пользователя в Talent Identity; null — пользователь создан в PIN. |
SyncSource | UserSyncSource | Да | varchar(32) | app / consumer | Pin / TalentIdentity (какие поля владеет синк — talent-replica.md). |
ReplicaVersion | long? | Нет (обязателен при SyncSource = TalentIdentity) | >= 0 | Talent sync | Версия события Talent (out-of-order guard). |
LastSyncedAt | DateTimeOffset? | Нет | — | Talent sync | Время последнего применённого события синка. |
CreatedAt | DateTimeOffset | Да | index ix_users_created_at | app | «Дата регистрации». |
CreatedByUserId | Guid? | Нет | — | app | null при самостоятельной регистрации; заполнен при lazy-регистрации оператором/агентом и при создании через admin-API. |
UpdatedAt | DateTimeOffset | Да | — | app | Штамп изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete (архив). |
DeletedByUserId | Guid? | Нет | — | app | Кто архивировал. |
Version | long | Да | >= 1 | app | Доменная версия — идёт в payload событий. |
Enum UserType
| Значение | Компания (CompanyId) | Описание |
|---|---|---|
Investor | null (обязательно) | Самостоятельный пользователь-покупатель. Весь СМС-flow — про него. |
DeveloperManager | Type = Developer | Менеджер застройщика; профиль/контакты управляются Talent Identity (ContactsManagedBy = TalentSync). |
Agent | null (независимый) или зеркало выбранного активного членства (Agency/Broker/Partner) | Агент/риэлтор. Членство в агентстве — отдельный агрегат AgentMembership, не поле пользователя; при мульти-членстве (ADR-0070) активных членств может быть несколько, а CompanyId отражает текущее выбранное активное агентство (ADR-0061/ADR-0070). Независимый агент — с ролью agent в scope Platform, без активных членств; сохраняет UserId и все метрики/достижения. |
PartnerManager | Type = Partner | Менеджер компании-партнёра. |
PinEmployee | Type = Pin | Сотрудник ПИН: Менеджер ПИН, Служба заботы. Конкретные полномочия — ролями (role.md), не типом. |
Сотрудники ПИН, проводящие сделки как риэлторы, регистрируются пользователями типа Agent в отдельной
компании Agency, принадлежащей ПИН — тип пользователя не дублируется.
Enum UserStatus
| Значение | Описание |
|---|---|
Invited | Учётка создана компанией/синком, пользователь ещё ни разу не входил. |
Active | Рабочее состояние. |
Blocked | Заблокирован (админ/забота/синк Talent): любой вход отклоняется c IDENTITY_USER_BLOCKED. |
Archived | Архив (soft-delete): вход невозможен, исторические ссылки (сделки, фиксации) живы. |
Enum ContactsManagementMode
| Значение | Описание |
|---|---|
SelfService | ФИО/email/телефон меняет сам пользователь. |
TalentSync | Поля владеет синк Talent; self-service операции updateInvestor, request/confirmEmailChange, request/confirmPhoneChange возвращают IDENTITY_CONTACTS_MANAGED_EXTERNALLY. |
Enum RegistrationSource
| Значение | Описание |
|---|---|
SmsSignUp | Самостоятельная регистрация через signUpOrSignIn (СМС). |
LazyRegistration | Автосоздание по имени+телефону из заявки (консультация/бронь/фиксация); Имя обновляется при следующем входе, если пустое (глоссарий «Lazy-регистрация»). |
TalentSync | Создан consumer-ом синка Talent Identity. |
AdminApi | Создан через admin-API (ADR-0053) или приглашением компании. |
Enum UserSyncSource — аналогично CompanySyncSource: Pin / TalentIdentity.
Value objects
FullName (owned)
| Поле | Тип | Обязательность | Ограничения | Колонка |
|---|---|---|---|---|
FirstName | string | Да | varchar(128), min 1 | name_first_name |
LastName | string | Да (пустая строка допустима при lazy-регистрации до заполнения) | varchar(128) | name_last_name |
MiddleName | string? | Нет | varchar(128) | name_middle_name |
Поиск по ФИО — не PostgreSQL-индекс, а Elasticsearch-проекция UserSearchDocument
(../search/users.md, ADR-0059): fuzzy-поиск пользователей компании по ФИО/телефону/
email для экрана «Команда». Btree-индексы ux_users_phone_number/ux_users_email обслуживают точечный
lookup; поиск дублей инвестора по ФИО для фиксаций CRM — отдельная реплика/проекция контекста crm.
Модели JSONB
JSONB-модели не используются: расширенный профиль, документы и реквизиты — контекст profile,
не identity.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
CompanyEntity | reference | CompanyId (FK) | GetById(CompanyId) при проверке статуса компании во входе | restrict |
RoleAssignment | reference (обратная) | role_assignments.user_id → users.id | Filter по UserId при построении permissions | cascade (отзыв при Archived) |
SignInConfirmationRequest | reference (обратная) | sign_in_confirmation_requests.user_id | FindBy(Id) в confirm-шаге | cascade по TTL |
RefreshSession | reference (обратная) | refresh_sessions.user_id | Filter по UserId | cascade (revoke) |
| Talent user | replica | ExternalTalentUserId | consumer FindBy(ExternalTalentUserId) | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-1 UniquePhone | PhoneNumber уникален среди не архивных | unique index + FindBy(PhoneNumber) в signUpOrSignIn (существующий → вход, нет → регистрация) | IDENTITY_USER_PHONE_TAKEN (только в phone-change flow; в signUpOrSignIn дубль = вход) |
| INV-2 TypeCompanyConsistency | Investor ⇔ CompanyId = null (строго); DeveloperManager/PartnerManager/PinEmployee — CompanyId != null и Company.Type соответствует таблице UserType; Agent — CompanyId nullable (ADR-0061/ADR-0070): при непустом — зеркало выбранного активного членства среди возможно нескольких активных AgentMembership (тип совпадает с Agency/Broker/Partner), при null — независимый агент без активных членств. Agent не обязан иметь ровно одно активное членство: активных членств может быть несколько (мульти-агентство, ADR-0070); CompanyId при непустом обязан соответствовать одному из активных членств агента. Членство не выражается наличием CompanyId — источник истины и история в agent-membership.md | фабрики UserEntity.CreateInvestor / CreateCompanyUser; переход PromoteToAgent/DemoteToInvestor; синхронизация зеркала — join/leave membership и смена активного агентства (POST /api/v1/me/active-agency) | IDENTITY_USER_TYPE_COMPANY_MISMATCH |
| INV-3 UniqueEmail | Email уникален (где задан) | unique partial index + FindBy(Email) в requestEmailChange | IDENTITY_USER_EMAIL_TAKEN |
| INV-4 ManagedContacts | При ContactsManagedBy = TalentSync self-service смена ФИО/email/телефона запрещена | guard в методах Rename, RequestEmailChange, RequestPhoneChange | IDENTITY_CONTACTS_MANAGED_EXTERNALLY |
| INV-5 PinCodeFormat | Пин-код при установке соответствует ^\d{4,12}$ | метод SetPinCode | ValidationError |
| INV-6 PasswordPolicy | Пароль 8–128 символов; currentPassword обязателен, если PasswordHash != null | метод ChangePassword | IDENTITY_INVALID_CURRENT_PASSWORD |
| INV-7 TwoFactorConfirm | Включение/выключение 2FA — только после подтверждения СМС-кодом (ConfirmationPurpose = EnableTwoFactor/DisableTwoFactor) | flow в user-session.md | IDENTITY_CONFIRMATION_INVALID |
| INV-8 Lockout | Вход отклоняется при LockoutUntil > now | шаг валидации confirm-операций | IDENTITY_USER_LOCKED_OUT |
| INV-9 CompanyActive | Вход пользователя компании допустим только при Company.Status = Active | confirm-шаг: GetById(CompanyId) | IDENTITY_COMPANY_SUSPENDED |
| INV-10 MonotonicReplica | Событие синка применяется при event.Version > ReplicaVersion | consumer | идемпотентный пропуск |
| INV-11 LazyNameFill | firstName в confirmSignIn применяется только если текущее имя пустое | метод FillNameIfEmpty | нет ошибки — молча игнорируется |
Переходы типа Investor ↔ Agent (ADR-0061) ортогональны Status и не создают новую учётку —
один UserId на человека. PromoteToAgent (become-agent/accept приглашения) добавляет роль agent и,
при вступлении в агентство, Active membership + зеркалит CompanyId; независимый агент — роль agent
в scope Platform, CompanyId = null. DemoteToInvestor (become-investor) снимает роль agent и
закрывает активное membership. Личные метрики/достижения агента — user-centric (агрегация по
AgentUserId сквозь все компании, ADR-0061 п.5) и переживают смену/выход из агентства; исторические
сделки/лиды/фиксации остаются у компании-владельца через иммутабельные снапшоты AgencyCompanyId/
AgentUserId в crm (back-fill запрещён; reference only — role.md,
agent-membership.md).
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
UserRegisteredEvent | доменное | Создание учётки любым источником | Id, Type, RegisteredVia, ReferrerUrl |
UserSignedInEvent | доменное | Успешный confirm любого способа входа | Id, способ (Sms/Password/PinCode), SessionId |
UserContactChangedEvent | доменное | Успешный confirmEmailChange / confirmPhoneChange | Id, вид контакта, старое/новое значение (маскированные в логах) |
UserSecurityChangedEvent | доменное | смена пароля / установка/сброс пин-кода / 2FA on/off | Id, вид изменения |
UserUpsertedKafkaEvent | интеграционное (ContractVersion = 1, topic pin.identity.users.v1, key = Id) | Регистрация и изменение публичных полей (Name, Type, CompanyId, Status) | Id, Type, CompanyId, Status, Name, PhoneNumber (E.164, только для доверенных consumer-ов: crm/referral/messenger/notifications — ACL топика), RegisteredVia, ReferrerUrl, Version |
UserBlockedKafkaEvent | интеграционное (тот же topic) | Active → Blocked | Id, BlockedAt, причина |
Потребители pin.identity.users.v1: referral (реферальная привязка при регистрации), crm (лиды,
lookup инвесторов), profile (создание профиля), messenger (контакты), user-activity (связывание
anonymous-сессии), notifications.
Конкурентность и согласованность
- Зеркало
CompanyIdдля агента. Поле — денормализованная копия текущего выбранного активного агентства (зеркалоRefreshSession.ActiveCompanyId, ADR-0070), а не «единственного» членства: источник истины — набор активныхAgentMembership(agent-membership.md). Обновляется тем же изменением, что фиксирует join/leave, и при смене активного агентства (POST /api/v1/me/active-agency); downstream-контексты, читающие членство, ориентируются на события членства, а не на зеркало. Расхождение зеркала и агрегата членства внутриidentityневозможно (одно изменение), но реплики в других сервисах — eventual (окно — секунды). - Out-of-order синк Talent. События синка применяются только при
event.Version > ReplicaVersion(INV-10); устаревшее/повторное событие — идемпотентный пропуск, локальные (PIN-owned) поля синком не затираются (talent-replica.md). - Конкурентные изменения учётки. Оптимистическая блокировка по
Version: параллельные смена контакта и блокировка/архивация сериализуются — проигравший повторяет операцию поверх нового состояния. Блокировка/архивация в момент активной сессии её не ждёт: сессии отзываются (INV-S7 user-session.md), последующая авторизация отклоняется. - Idempotent-регистрация по номеру. Повторный
signUpOrSignInна существующий номер — это вход, а не создание дубля (INV-1); гонка двух регистраций одного номера страхуется уникальностью телефона (проигравший переходит в сценарий входа).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK (Id); ux_users_phone_number unique where deleted_at is null; ux_users_email unique where not null and deleted_at is null; ux_users_external_talent_user_id unique where not null; ix_users_company_id; ix_users_type; ix_users_status; ix_users_created_at; ix_users_last_sign_in_at. Поиск по ФИО — Elasticsearch-проекция UserSearchDocument (../search/users.md), не GIN-индекс PostgreSQL |
| Партиционирование / retention | none; учётки — бессрочно (soft-delete); полное удаление PII — регламент администратора платформы (ADR-0053) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую); confirm-flows дополнительно сериализуются уникальностью SignInConfirmationRequest |
| Permissions / PII | PhoneNumber, Email, FullName — PII: в логах маскируются (+7***1234, d***@mail); PasswordHash/PinCodeHash — никогда не покидают домен (нет в проекциях/событиях); self-операции — permission identity.users.view-self / identity.security.manage-self; чужие пользователи компании — identity.users.view/manage в scope организации; кросс-компания — только платформенные роли ПИН |
| Audit | Version + actor-stamping; каждое security-изменение — UserSecurityChangedEvent (лента для админ-аудита) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (18): Правила реферальной привязки (attach-rules) — дом доменных правил, Реплика Talent Identity: правила синка + Entity TalentSyncState, Entity (Replica): MessengerUserEntity, Entity (Replica): UserDisplayReplicaEntity, Entity / Aggregate: AgentMembershipEntity, Entity / Aggregate: AuthorReplica + CompanyReplica, Entity / Aggregate: BlacklistEntity, Entity / Aggregate: ClientEntity, Entity / Aggregate: CompanyEntity, Entity / Aggregate: CompanyInvitationEntity, Entity / Aggregate: OwnerMappingEntity, Entity / Aggregate: PersonEntity (+6)
- API (38): DELETE /api/admin/v1/users/{userId} — Удаление пользователя: архив vs полное удаление (admin), GET /api/investors/me — Текущий инвестор (getCurrentInvestor), GET /api/v1/companies/my/users — Список пользователей компании, GET /internal/v1/referral-links/resolve — Internal: резолв реферальной ссылки по slug, Identity Admin API — реестр методов и общие правила области, Identity Agent API — реестр методов и общие правила области, Identity API — карта областей и реестр investor-области, Identity Company-User API — реестр методов и общие правила области, POST /api/admin/v1/users/{userId}/block — Блокировка пользователя (admin), POST /api/admin/v1/users/{userId}/security/reset — Ручной сброс факторов входа (admin), POST /api/admin/v1/users/{userId}/sessions/revoke — Отзыв сессий пользователя (admin), POST /api/investors/auth/confirm-sign-in — Подтверждение входа (confirmSignIn) (+26)
- Use Cases (14): UC-BKG-001. Инвестор инициирует бронирование с витрины, UC-CRM-001. Создание лида (заявка / реферал / вручную), UC-IDN-001. Вход/регистрация по СМС (с заполнением имени и фиксацией реферальной ссылки), UC-IDN-002. Вход по паролю вместо СМС, UC-IDN-003. Включение двухфакторной аутентификации, UC-IDN-004. Смена телефона / email, UC-IDN-006. Приглашение менеджера (агента) в компанию, UC-IDN-007. Синхронизация компании и её пользователей из Talent Identity (system), UC-IDN-008. Проверка доступа сервисом (internal introspection эффективных прав), UC-IDN-009. Приглашение агента по телефону и его принятие, UC-IDN-010. Агент выходит из агентства, сохраняя метрики, UC-IDN-011. Инвестор становится агентом (+2)
- События (3): Event Contract: pin.identity.user-blocked (UserBlockedKafkaEvent), Event Contract: pin.identity.user-signed-in (UserSignedInKafkaEvent), Event Contract: pin.identity.user-upserted (UserUpsertedKafkaEvent)
- Консюмеры (4): Consumer: Chessboard Project Deleted (pin-identity-chessboard-projects), Consumer: identity-users-sync (ProfileUserUpsertedConsumer, ProfileUserBlockedConsumer), Consumer: identity.users.v1 → AuthorReplica (news-identity-users), Consumer: Talent Identity Sync (pin-identity-talent-sync)
- Фоновые задачи (3): Background Job: ExpireSessionsJob, Background Job: SessionRetentionJob, Background Job: talent-initial-import (первичная загрузка / bootstrap из Talent Identity)
- Поиск (1): UserSearchDocument
- adr (3): ADR-0061: Независимый агент, отвязка от агентства и приглашения по номеру телефона, ADR-0070: Мульти-агентство и контекст активного агентства, ADR-0072: Увольнение/исключение агента и каскад доступа
- index.md (2): Identity Service (Pin.Identity), Profile Service (Pin.Profile)
- registries (1): Реестр сущностей PIN
Связанные документы
- company.md, role.md, permission.md, user-session.md, talent-replica.md.
- Членство агента и приглашения (ADR-0061): agent-membership.md, company-invitation.md; переходы — become-agent/become-investor/leave-agency.
- Мульти-агентство и активное агентство (ADR-0070): несколько активных членств,
RefreshSession.ActiveCompanyId,POST /api/v1/me/active-agency,nextAction = SelectActiveAgency— user-session.md, agent-membership.md. - Операции входа и безопасности:
identity/api/. - ЛК/документы/верификация — контекст
profile. - ADR-0052, ADR-0053, ADR-0061, ADR-0070.