UC-PRF-001. Заполнение профиля и персоны «Сам» (первый вход инвестора в ЛК)
Назначение
Первичное заполнение личного кабинета инвестором. ProfileEntity (реплика identity) и персона
Relation = Self создаются автоматически при синхронизации из identity; инвестор просматривает профиль
(getMyProfile), заполняет персональные данные персоны «Сам» — ФИО, дату рождения, гражданство (хранятся
зашифрованными как ПДн) — и настройки ЛК (Locale/Timezone). Связан с контекстом profile.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-PRF-001 |
| Контекст | profile (см. docs/06-services/profile/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Инвестор (self-service). Агент/Агентство/МенеджерПИН — только просмотр профиля и настройки (персоны запрещены — INV-PRF-4); Девелопер — запрещено всё, кроме просмотра/настроек (UC-PRF-005) |
| Permissions / scope | profile.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 — открытие страницы (загрузка) и кнопка «Сохранить» формы персональных данных |
Основной Поток
- Инвестор открывает
/profile/personal. ЛК запрашивает профиль (getMyProfile,GET /profile/me) и список персон (listPersons,GET /profile/persons). - Профиль. По актору (
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). - Персоны. Берём активные персоны владельца (
OwnerUserId = UserId,Status = Active, порядок поCreatedAt); для каждой ФИО и дата рождения расшифровываются в памяти (self-view; списки малы — ≤ 20 персон, ADR-0056). Каждый элемент —PersonListItem { id, relation, status, firstName, lastName, middleName?, birthDate?, citizenshipCountryId?, documentsCount }. - ЛК показывает персону «Сам» (создана автоматически при синхронизации из identity: ФИО из lazy-регистрации либо пустые поля). Инвестор заполняет ФИО, дату рождения, гражданство и нажимает «Сохранить».
- ЛК вызывает
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) }. Операция идемпотентна по содержимому: повтор с теми же данными даёт то же состояние. - Обновление персоны.
- Проверить вход (до шифрования): длины 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.
- Проверить вход (до шифрования): длины 1–128;
- (Опционально в том же сеансе) Инвестор меняет язык/часовой пояс:
updateMySettings(PUT /profile/me/settings) сUpdateMySettingsForm { locale (ru-RU|en-US), timezone? (IANA, ≤64) }. Проверяется толькоStatus = Active(INV-PRF-3); настройки доступны всем типам, включая DeveloperManager (матрица возможностей — ../domain/profile.md). ПубликуетсяProfileSettingsChangedEvent. - ЛК инвалидирует
getMyProfile/listPersonsи показывает обновлённые данные.
Альтернативные Потоки
A1. Профиль ещё не создан (лаг consumer-а identity)
getMyProfileвозвращаетPROFILE_NOT_FOUND(404): событиеUserUpsertedещё не обработано (README profile: «retry на 404 профиля» до догона consumer-а identity).- Фронт показывает скелетон и повторяет запрос с backoff (3 попытки по 2–5 с).
- После догона consumer-а (../consumers/identity-users-sync.md) возврат к шагу 1 основного потока.
A2. Персона Self отсутствует у старого инвестора (миграция)
listPersonsвозвращает пустой список (аккаунт создан до включения автосоздания).- Фронт вызывает
createPersonсrelation = Self; единственность персоны Self гарантируется уникальным индексомux_persons_owner_self(INV-PRS-1; гонка →PROFILE_PERSON_SELF_ALREADY_EXISTS, фронт перечитывает список). - Возврат к шагу 4 основного потока.
A3. Не-инвестор пытается редактировать персону
- Для
UserType ∈ {Agent, PartnerManager, PinEmployee}операцияupdatePersonотклоняется →PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE(403, INV-PRF-4). - Фронт не показывает блок персон этим типам (флаг
canManagePersons = falseизgetMyProfile). Сценарий завершается. Девелопер — отдельный сценарий UC-PRF-005.
A4. Конкурентная правка той же персоны (два устройства)
- Второй
updatePersonполучает конфликт версии записи →CONCURRENCY_CONFLICT(409). - Фронт перечитывает
listPersonsи предлагает повторить правку поверх свежих данных. Возврат к шагу 5.
A5. Недоступность ключей шифрования (Vault)
- Шифрование ПДн недоступно (ошибка ключей Vault) → операция отклоняется fail-closed (
INTERNAL, 500), alert «ошибки шифрования ПДн > 0» (README profile, Runtime). - Данные не сохраняются частично: запись не начинается до успешного шифрования. Повтор после восстановления ключей.
A6. Брокер/outbox отстаёт (публикация события задержана)
- Запись персоны и постановка
PersonUpsertedKafkaEventв outbox выполняются в одном согласованном изменении; при недоступности брокера пользовательская операция всё равно успешна (данные сохранены), публикация досылается ретраем outbox (at-least-once, ../events/person-upserted.md). - Downstream-эффекты (пометка «реквизиты могли устареть» в
crm) применяются с задержкой; инвестор в ЛК видит актуальные данные сразу — они читаются изprofile, а не из шины. Рассинхронизация самоустраняется после досылки события; ручных действий не требуется.
Постусловие
PersonEntity(Relation = Self) содержит шифрованные ФИО/дату рождения (KeyVersion= текущий), открытыеCitizenshipCountryId/Gender;Versionинкрементирован.ProfileEntity.Locale/Timezoneобновлены (если выполнялся шаг 7).- Опубликован
PersonUpsertedKafkaEvent(без PII). - В БД нет ни одного plaintext-значения ФИО/даты рождения персоны.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
ValidationError | 400 | Длины/формат/возраст (валидация до шифрования) | Подсветить поле |
PROFILE_COUNTRY_UNKNOWN | 422 | citizenshipCountryId не найден в справочнике стран или архивен | Обновить список стран (публичный lookup), исправить выбор |
PROFILE_NOT_FOUND | 404 | Consumer ещё не создал профиль (A1) | Retry с backoff |
PROFILE_MANAGED_EXTERNALLY | 403 | ContactsManagedBy = TalentSync (INV-PRF-2) | UC-PRF-005: скрыть формы, показать пояснение |
PROFILE_USER_BLOCKED | 403 | Status ∈ {Blocked, Archived} (INV-PRF-3) | Экран «аккаунт заблокирован» |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | UserType != Investor (INV-PRF-4) | Скрыть блок персон |
PROFILE_PERSON_ARCHIVED | 409 | Правка архивной персоны | Предложить restorePerson |
PROFILE_PERSON_SELF_ALREADY_EXISTS | 409 | Гонка автосоздания/A2 (INV-PRS-1) | Перечитать список персон |
CONCURRENCY_CONFLICT | 409 | Конфликт RowVersion (A4) | Перечитать и повторить |
401 UNAUTHORIZED | 401 | Нет/просрочен JWT | Auth-модалка (UC-IDN-001) |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
ProfileCreatedEvent | Первое UserUpserted (создание профиля + автосоздание Self) — предусловие UC | in-proc |
PersonCreatedEvent | Автосоздание Self / A2 | in-proc аудит |
PersonDataChangedEvent | Изменение ФИО/даты рождения/гражданства (имена полей, без значений) | in-proc аудит |
ProfileSettingsChangedEvent | Смена Locale/Timezone (шаг 7) | in-proc |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
PersonUpsertedKafkaEvent.v1 | pin.profile.persons.v1 | Каждое создание/изменение персоны (без PII) | notifications (будущее), внутренние проекции |
Consumed: UserUpsertedKafkaEvent.v1 | pin.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) | повторный вызов |
| AC6 | updateMySettings работает и для DeveloperManager (настройки — не персональные данные) | тест по матрице возможностей |
| AC7 | PersonUpsertedKafkaEvent не содержит ФИО/дат/номеров (только id/relation/status/counters) | контракт-тест payload |
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- 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 (регистрация).