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

UC-PRF-001. Заполнение профиля и персоны «Сам» (первый вход инвестора в ЛК)

Назначение

Первичное заполнение личного кабинета инвестором. ProfileEntity (реплика identity) и персона Relation = Self создаются автоматически при синхронизации из identity; инвестор просматривает профиль (getMyProfile), заполняет персональные данные персоны «Сам» — ФИО, дату рождения, гражданство (хранятся зашифрованными как ПДн) — и настройки ЛК (Locale/Timezone). Связан с контекстом profile.

Metadata

ПолеЗначение
IDUC-PRF-001
Контекстprofile (см. docs/06-services/profile/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (self-service). Агент/Агентство/МенеджерПИН — только просмотр профиля и настройки (персоны запрещены — INV-PRF-4); Девелопер — запрещено всё, кроме просмотра/настроек (UC-PRF-005)
Permissions / scopeprofile.personal.view-self, profile.personal.manage-self (self-scope); персоны — profile.documents.manage-self
Tenantне tenanted: инвестор вне tenant (ADR-0052); record-level по OwnerUserId = UserId актора
Record-levelтолько собственный профиль (Id = UserId) и собственные персоны (OwnerUserId = UserId)

Предусловие

  • Пользователь зарегистрирован в identity (UC-IDN-001, СМС-вход) и аутентифицирован (JWT).
  • Consumer identity-users-sync уже обработал UserUpsertedKafkaEvent из pin.identity.users.v1: ProfileEntity создан (Id = UserId), для UserType = Investor автосоздана персона Relation = Self, Status = Active (INV-PRS-1). Иначе — A1.
  • Profile.Status = Active (INV-PRF-3), ContactsManagedBy = SelfService (INV-PRF-2).

Триггер

ПолеЗначение
ИсточникUI button
Триггерящий элементСтраница ЛК /profile/personal — открытие страницы (загрузка) и кнопка «Сохранить» формы персональных данных

Основной Поток

  1. Инвестор открывает /profile/personal. ЛК запрашивает профиль (getMyProfile, GET /profile/me) и список персон (listPersons, GET /profile/persons).
  2. Профиль. По актору (UserId из JWT) находим ProfileEntity (Id = UserId). Если профиля ещё нет (консюмер identity не догнал) → PROFILE_NOT_FOUND (A1). Иначе возвращаем MyProfileResult: поля реплики (Name, ContactPhone, ContactEmail, UserType, ContactsManagedBy) и собственные поля ЛК (Locale, Timezone, VerificationStatus, DefaultRequisiteTemplateId), плюс UI-флаги canEditContacts = (ContactsManagedBy == SelfService), canManagePersons = (UserType == Investor). Контакты владельцу отдаются открыто (self-view).
  3. Персоны. Берём активные персоны владельца (OwnerUserId = UserId, Status = Active, порядок по CreatedAt); для каждой ФИО и дата рождения расшифровываются в памяти (self-view; списки малы — ≤ 20 персон, ADR-0056). Каждый элемент — PersonListItem { id, relation, status, firstName, lastName, middleName?, birthDate?, citizenshipCountryId?, documentsCount }.
  4. ЛК показывает персону «Сам» (создана автоматически при синхронизации из identity: ФИО из lazy-регистрации либо пустые поля). Инвестор заполняет ФИО, дату рождения, гражданство и нажимает «Сохранить».
  5. ЛК вызывает updatePerson (PUT /profile/persons/{personId}) с UpdatePersonForm { firstName (1..128), lastName (1..128), middleName? (1..128), birthDate? (ISO yyyy-MM-dd), citizenshipCountryId? (short — справочник стран handbook), gender? (Male|Female) }. Операция идемпотентна по содержимому: повтор с теми же данными даёт то же состояние.
  6. Обновление персоны.
    • Проверить вход (до шифрования): длины 1–128; birthDate в прошлом и возраст ≤ 120 лет; gender из enum. Нарушение → ValidationError. Если задан citizenshipCountryId — резолв страны через internal resolve справочника handbook; страна не найдена или архивна → PROFILE_COUNTRY_UNKNOWN (422).
    • Проверить доступ по профилю: режим самообслуживания ContactsManagedBy = SelfService (INV-PRF-2, иначе PROFILE_MANAGED_EXTERNALLY); аккаунт активен Status = Active (INV-PRF-3, иначе PROFILE_USER_BLOCKED); тип пользователя — инвестор (INV-PRF-4/INV-PRS-2, иначе PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE).
    • Найти персону по personId в пределах владельца (record-level по OwnerUserId); чужая или несуществующая → 404 без утечки существования; архивная (Status != Active) → PROFILE_PERSON_ARCHIVED.
    • Применить изменения (маппинг поле-в-поле): ФИО (Name.FirstNameCipher/LastNameCipher/ MiddleNameCipher) и дата рождения (BirthDateCipher) шифруются актуальным ключом как ПДн; middleName = null → cipher = null; CitizenshipCountryId и Gender хранятся открыто. Все cipher-поля перешифровываются одним текущим KeyVersion (INV-PRS-8); Version инкрементируется; UpdatedAt/UpdatedByUserId — актор.
    • Конкурентная правка той же персоны разрешается оптимистической блокировкой по версии записи; конфликт → CONCURRENCY_CONFLICT (A4).
    • Публикуются доменное PersonDataChangedEvent (in-proc аудит; только имена изменённых полей, без значений) и интеграционное PersonUpsertedKafkaEvent (ContractVersion = 1, топик pin.profile.persons.v1, key = Id; payload без PII: Id, OwnerUserId, Relation, Status, DocumentsCount, Version) — контракт ../events/person-upserted.md. Персона и факт её изменения фиксируются в одном согласованном изменении.
    • Владельцу возвращается PersonResult с расшифрованными значениями (self-view); в логах — только PersonId и имена изменённых полей, без значений PII.
  7. (Опционально в том же сеансе) Инвестор меняет язык/часовой пояс: updateMySettings (PUT /profile/me/settings) с UpdateMySettingsForm { locale (ru-RU|en-US), timezone? (IANA, ≤64) }. Проверяется только Status = Active (INV-PRF-3); настройки доступны всем типам, включая DeveloperManager (матрица возможностей — ../domain/profile.md). Публикуется ProfileSettingsChangedEvent.
  8. ЛК инвалидирует getMyProfile/listPersons и показывает обновлённые данные.

Альтернативные Потоки

A1. Профиль ещё не создан (лаг consumer-а identity)

  1. getMyProfile возвращает PROFILE_NOT_FOUND (404): событие UserUpserted ещё не обработано (README profile: «retry на 404 профиля» до догона consumer-а identity).
  2. Фронт показывает скелетон и повторяет запрос с backoff (3 попытки по 2–5 с).
  3. После догона consumer-а (../consumers/identity-users-sync.md) возврат к шагу 1 основного потока.

A2. Персона Self отсутствует у старого инвестора (миграция)

  1. listPersons возвращает пустой список (аккаунт создан до включения автосоздания).
  2. Фронт вызывает createPerson с relation = Self; единственность персоны Self гарантируется уникальным индексом ux_persons_owner_self (INV-PRS-1; гонка → PROFILE_PERSON_SELF_ALREADY_EXISTS, фронт перечитывает список).
  3. Возврат к шагу 4 основного потока.

A3. Не-инвестор пытается редактировать персону

  1. Для UserType ∈ {Agent, PartnerManager, PinEmployee} операция updatePerson отклоняется → PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE (403, INV-PRF-4).
  2. Фронт не показывает блок персон этим типам (флаг canManagePersons = false из getMyProfile). Сценарий завершается. Девелопер — отдельный сценарий UC-PRF-005.

A4. Конкурентная правка той же персоны (два устройства)

  1. Второй updatePerson получает конфликт версии записи → CONCURRENCY_CONFLICT (409).
  2. Фронт перечитывает listPersons и предлагает повторить правку поверх свежих данных. Возврат к шагу 5.

A5. Недоступность ключей шифрования (Vault)

  1. Шифрование ПДн недоступно (ошибка ключей Vault) → операция отклоняется fail-closed (INTERNAL, 500), alert «ошибки шифрования ПДн > 0» (README profile, Runtime).
  2. Данные не сохраняются частично: запись не начинается до успешного шифрования. Повтор после восстановления ключей.

A6. Брокер/outbox отстаёт (публикация события задержана)

  1. Запись персоны и постановка PersonUpsertedKafkaEvent в outbox выполняются в одном согласованном изменении; при недоступности брокера пользовательская операция всё равно успешна (данные сохранены), публикация досылается ретраем outbox (at-least-once, ../events/person-upserted.md).
  2. Downstream-эффекты (пометка «реквизиты могли устареть» в crm) применяются с задержкой; инвестор в ЛК видит актуальные данные сразу — они читаются из profile, а не из шины. Рассинхронизация самоустраняется после досылки события; ручных действий не требуется.

Постусловие

  • PersonEntity (Relation = Self) содержит шифрованные ФИО/дату рождения (KeyVersion = текущий), открытые CitizenshipCountryId/Gender; Version инкрементирован.
  • ProfileEntity.Locale/Timezone обновлены (если выполнялся шаг 7).
  • Опубликован PersonUpsertedKafkaEvent (без PII).
  • В БД нет ни одного plaintext-значения ФИО/даты рождения персоны.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
ValidationError400Длины/формат/возраст (валидация до шифрования)Подсветить поле
PROFILE_COUNTRY_UNKNOWN422citizenshipCountryId не найден в справочнике стран или архивенОбновить список стран (публичный lookup), исправить выбор
PROFILE_NOT_FOUND404Consumer ещё не создал профиль (A1)Retry с backoff
PROFILE_MANAGED_EXTERNALLY403ContactsManagedBy = TalentSync (INV-PRF-2)UC-PRF-005: скрыть формы, показать пояснение
PROFILE_USER_BLOCKED403Status ∈ {Blocked, Archived} (INV-PRF-3)Экран «аккаунт заблокирован»
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE403UserType != Investor (INV-PRF-4)Скрыть блок персон
PROFILE_PERSON_ARCHIVED409Правка архивной персоныПредложить restorePerson
PROFILE_PERSON_SELF_ALREADY_EXISTS409Гонка автосоздания/A2 (INV-PRS-1)Перечитать список персон
CONCURRENCY_CONFLICT409Конфликт RowVersion (A4)Перечитать и повторить
401 UNAUTHORIZED401Нет/просрочен JWTAuth-модалка (UC-IDN-001)

Доменные События

СобытиеКогда поднимаетсяSubscribers
ProfileCreatedEventПервое UserUpserted (создание профиля + автосоздание Self) — предусловие UCin-proc
PersonCreatedEventАвтосоздание Self / A2in-proc аудит
PersonDataChangedEventИзменение ФИО/даты рождения/гражданства (имена полей, без значений)in-proc аудит
ProfileSettingsChangedEventСмена Locale/Timezone (шаг 7)in-proc

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
PersonUpsertedKafkaEvent.v1pin.profile.persons.v1Каждое создание/изменение персоны (без PII)notifications (будущее), внутренние проекции
Consumed: UserUpsertedKafkaEvent.v1pin.identity.users.v1Предусловие: создание профиля/репликиprofile (consumer identity-users-sync)

Acceptance Criteria

#КритерийПроверка
AC1После первого UserUpserted инвестора существуют профиль и активная персона Selfинтеграционный тест consumer-а
AC2ФИО/дата рождения в таблице profile.persons — только шифротекст (bytea), plaintext отсутствуеттест: прямой SQL-просмотр колонок
AC3Владелец получает расшифрованные значения; в логах значений нет (только PersonId + имена полей)тест + аудит логов
AC4Чужая персона недоступна на чтение/запись (record-level, 404 без утечки)тест с двумя пользователями
AC5Повторный updatePerson с теми же данными идемпотентен (состояние не меняется сверх Version/UpdatedAt)повторный вызов
AC6updateMySettings работает и для DeveloperManager (настройки — не персональные данные)тест по матрице возможностей
AC7PersonUpsertedKafkaEvent не содержит ФИО/дат/номеров (только id/relation/status/counters)контракт-тест payload

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

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

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

  • API методы (будущие): ../api/public/get-my-profile.md, ../api/public/list-persons.md, ../api/public/update-person.md, ../api/public/update-my-settings.md.
  • Consumer: ../consumers/identity-users-sync.md (автосоздание Self).
  • Domain entity: ../domain/profile.md (INV-PRF-1..4, матрица возможностей), ../domain/person.md (INV-PRS-1..3, INV-PRS-8, EncryptedPersonName).
  • Событие: ../events/person-upserted.md.
  • Frontend: страница /profile/personal, стор персон (prototype-mapping.md).
  • Связанные UC: UC-PRF-002 (персона супруга + документы), UC-PRF-005 (запрет для менеджера застройщика), UC-PRF-006 (смена email/phone — identity), UC-IDN-001 (регистрация).