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

User API — пользователи и профили

Слой: libs/apis/providers/user-api · Приложение: apps/user-api

User API — микросервис домена пользователей и профилей. Он отвечает за:

  • Управление двумя типами акторов системы — Customer (заказчик) и Employee (исполнитель/сотрудник). Обе сущности являются «надстройками» над базовой сущностью User (учётные данные, e-mail, телефон, пароль).
  • Управление профилями: общий Profile (имя, фамилия, часовой пояс, локация, аватар, языки) плюс специализированные CustomerProfile и EmployeeProfile (тип занятости, опыт, навыки, специализации).
  • Роли пользователя через связку UserOnRole → Role (CUSTOMER / EMPLOYEE).
  • Команды исполнителей (Team): профиль команды, её состав (TeamMember — роли и полномочия) и приглашения по e-mail (TeamInvitation).
  • Аватары — загрузка/удаление файлов в S3 с выдачей подписанных URL.
  • Справочники только на чтение/поиск: языки (Language), локации (Location), часовые пояса (Timezone), навыки (Skill), специализации (Specialization).

Сервис не занимается аутентификацией (выдачей токенов) — это домен auth-api. Он лишь потребляет его guard’ы и JWT-конфиги для защиты эндпоинтов.

Файл main.ts (bootstrap):

  • Тип приложения: NestExpressApplication, статика раздаётся из assets.
  • Глобальный префикс: api → все пути начинаются с /api.
  • Версионирование: VersioningType.URI → фактический путь /api/v1/... (у всех контроллеров version: '1').
  • ValidationPipe (глобальный): transform: true, whitelist: true, transformOptions.strategy = 'excludeAll' — в DTO проходят только поля с @Expose().
  • Фильтр ошибок: PrismaExceptionFilter (маппинг ошибок Prisma в HTTP).
  • CORS с credentials, cookie-parser.
  • Swagger: DocumentBuilder().setTitle('User API'), два bearer-схемы — CUSTOMER_ACCESS_TOKEN_STRATEGY_NAME и EMPLOYEE_ACCESS_TOKEN_STRATEGY_NAME; UI доступен по /api.
  • Порт: из AppConfigService.port (env APP_PORT).

Файл app/app.module.ts подключает:

  • AppConfigModule — конфигурация приложения.
  • ClsModule.forRoot(...) с плагином ClsPluginTransactional и TransactionalAdapterPrisma — сквозные транзакции (@Transactional()) поверх Prisma через CLS-контекст; генерация X-Request-Id.
  • Пять фичевых модулей: CustomerModule, EmployeeModule, ProfileModule, TeamModule, TeamInvitationModule.

Модуль UserModule в AppModule напрямую не импортируется — фича user подключается через UserCoreModule внутри core-модулей customer/employee.

ФичаПутьЧто делает
customerlibs/apis/providers/user-api/features/customerCRUD заказчиков (для сотрудников), «мой» эндпоинт заказчика, чтение профиля заказчика
employeelibs/apis/providers/user-api/features/employeeПрофиль сотрудника «me»: навыки, специализации, тип работы, опыт
profilelibs/apis/providers/user-api/features/profileОбщий профиль пользователя «me» (имя, локация, аватар, языки) + публичные справочники
userlibs/apis/providers/user-api/features/userБазовый сервис User (чтение/обновление/удаление) и связка ролей UserOnRole — переиспользуется customer/employee
teamlibs/apis/providers/user-api/features/teamКоманды и их состав: профиль команды, роли и полномочия участников, TeamMembershipGuard и декоратор TeamRoles (README)
team-invitationlibs/apis/providers/user-api/features/team-invitationПриглашения в команду по e-mail: выпуск, отзыв, предпросмотр, приём и отклонение по токену (README)
data-accesslibs/apis/providers/user-api/data-accessВсе DTO домена + константы ролей (CUSTOMER_ROLE, EMPLOYEE_ROLE) и команд (TEAM_INVITATION_TTL_DAYS, TEAM_SLUG_MAX_ATTEMPTS, лимиты полей)

Базовая сущность — User (учётные данные). Над ней «надстраиваются» роль-специфичные Customer и Employee, и параллельно 1:1 присоединяется общий Profile. Профильные детали разнесены в CustomerProfile / EmployeeProfile.

erDiagram
User ||--o| Customer : "1:1"
User ||--o| Employee : "1:1"
User ||--o| Profile : "1:1 (userId)"
User ||--o{ UserOnRole : "роли"
UserOnRole }o--|| Role : "CUSTOMER / EMPLOYEE"
Customer ||--o| CustomerProfile : "1:1"
Employee ||--o| EmployeeProfile : "1:1"
CustomerProfile }o--|| Profile : "profileId"
EmployeeProfile }o--|| Profile : "profileId"
Profile }o--o| Timezone : "timezoneId"
Profile }o--o| Location : "locationId"
Profile ||--o| Avatar : "1:1"
Profile ||--o{ ProfileLanguageLink : ""
ProfileLanguageLink }o--|| Language : ""
EmployeeProfile ||--o{ EmployeeProfileSkill : ""
EmployeeProfileSkill }o--|| Skill : ""
EmployeeProfile ||--o{ EmployeeProfileSpecialization : ""
EmployeeProfileSpecialization }o--|| Specialization : ""
Location }o--o| Location : "parent (self-ref)"
Location }o--o| Location : "country"

Ключевые сущности:

  • Useremail, phone, passwordHash/passwordSalt, isEmailVerified, isBlocked. Один пользователь → одна роль-надстройка (Customer или Employee) + один общий Profile.
  • Customer / Employee — тонкие сущности со ссылкой userId (в DTO наследуются от BaseDto, без собственных доменных полей, кроме связи с User).
  • ProfileuserId, firstName, lastName, timezoneId, locationId; связи: avatar (1:1), languages (M:N через ProfileLanguageLink), employeeProfile/customerProfile.
  • CustomerProfile — связка customerId + profileId (без доменных полей).
  • EmployeeProfile — связка employeeId + profileId + workType (FULL_TIME | PART_TIME | CONTRACT | FREELANCE, по умолчанию FULL_TIME), experienceMonths; связи M:N skills (EmployeeProfileSkill → Skill) и specializations (EmployeeProfileSpecialization → Specialization).
  • РолиUserOnRole (userId + roleId) → Role (type: RoleType). Фиксированные UUID ролей заданы в role.constants.ts (CUSTOMER_ROLE, EMPLOYEE_ROLE) и подставляются через connectOrCreate при создании пользователя.
  • СправочникиLanguage (code/name), Skill (name/description), Specialization (name/description), Timezone (name), Location (type REGION|SUBREGION|COUNTRY|CITY, самоссылка parent + country, координаты lat/lng), Avatar (url/format).

Транзакционное создание (CustomerService.saveOne / EmployeeService.saveOne, обёрнуто @Transactional()):

  1. Хешируется пароль (generatePasswordHashBy).
  2. Создаётся User вместе с UserOnRoleRole (connectOrCreate на фикс-UUID роли).
  3. Создаётся Customer/Employee (user.create).
  4. Создаётся общий Profile (connect к user).
  5. Создаётся CustomerProfile/EmployeeProfile (для employee — с workType = FULL_TIME).

Дубликат e-mail/телефона (P2002) → BadRequestException. Удаление актора удаляет базовый User (userService.deleteOneById), каскадно снося надстройки.

Модели Team, TeamMember, TeamInvitation (поля, индексы, правила удаления) описаны в database-schema — здесь только поведение сервиса.

Права участника — это две независимые оси, и одна из другой не выводится:

  • role (ADMIN | EDITOR | VIEWER) — ось доступа: что участник видит и меняет в самой команде. Её проверяет TeamMembershipGuard по декоратору @TeamRoles(...).
  • canSign / canSubmit — ось полномочий: право подписывать оферты и сдавать работы от имени команды. Полномочия выдаются явно и не следуют из роли: EDITOR может подписывать, ADMIN — не обязан.

Создатель команды получает ADMIN с обоими полномочиями, приглашённый — роль из приглашения (по умолчанию EDITOR) и оба полномочия в false.

Цепочка для всего, что лежит под /teams/:teamId/..., — EmployeeGuard, затем TeamMembershipGuard:

  1. EmployeeGuard проверяет employee-токен и его наличие в БД токенов (401 иначе);
  2. TeamMembershipGuard ищет активное членство пары (teamId из пути, employeeId из токена), при @TeamRoles(...) на методе или классе сверяет роль и кладёт членство в request.teamMember.

Отказ guard’а — всегда 403, а не 404: 404 на чужой команде выдавал бы факт её существования. Участник со status = REMOVED членством не считается и получает тот же 403.

Команда не остаётся без активного администратора. Смена роли администратора на не-ADMIN, его удаление и его же выход из команды проходят через одну проверку и дают 409, если активный администратор в команде остался один. Строка команды при этом берётся под FOR UPDATE до подсчёта — иначе два параллельных удаления двух последних администраторов оба увидели бы счётчик 2.

Выход (DELETE /teams/:teamId/members/me) и удаление участника администратором ставят status = REMOVED и removedAt; строка остаётся — на неё ссылаются подписи и сдачи. Вернувшийся по новому приглашению сотрудник не заводит вторую строку (@@unique([teamId, employeeId])), а реактивирует прежнюю: роль берётся из приглашения, полномочия сбрасываются в false.

Приглашение выпускает администратор команды на e-mail. Токен (randomBytes(32), base64url) живёт TEAM_INVITATION_TTL_DAYS = 7 дней и хранится открытым. Принять или отклонить приглашение может только пользователь, чей user.email совпадает с адресом приглашения без учёта регистра, — утёкшая ссылка не пускает в команду кого угодно.

Фоновых таймеров в сервисе нет, поэтому истечение ленивое: обращение к PENDING-приглашению с истёкшим expiresAt само переводит строку в EXPIRED и отвечает 410. То же и в списке GET /teams/:teamId/invitations: перед выборкой просроченные PENDING-приглашения команды помечаются EXPIRED пачкой (updateMany), иначе администратор видел бы просроченное приглашение живым, а фильтр ?status=PENDING его находил бы.

Адресность проверяется только на accept и decline. Предпросмотр GET /team-invitations/:token открыт держателю токена: сам токен и есть секрет, а увидеть, в какую команду зовут, нужно до решения принимать приглашение. Поэтому любой аутентифицированный сотрудник, у которого есть ссылка, увидит имя команды, слаг, роль и e-mail приглашённого — но не войдёт в команду: 403 по несовпавшему e-mail стоит на приёме и отклонении.

СитуацияОтвет
токен пустой или длиннее 255 символов400
неизвестный токен404
PENDING, срок вышелстрока → EXPIRED, 410
приглашение уже ACCEPTED / DECLINED / REVOKED / EXPIRED409
e-mail принимающего не совпал с адресом приглашения — только accept / decline403
приглашение на e-mail активного участника команды409
второе живое PENDING-приглашение на тот же e-mail в ту же команду409
приглашённый уже активный участник команды409
invitationId не принадлежит teamId из пути404
отзыв приглашения не в статусе PENDING409
  • Письмо с приглашением не отправляется. Токен возвращается в ответе на создание приглашения и в списке приглашений команды (TeamInvitationResponseDto.token) — рассылка приходит с единицей O2, вместе с ней поле из ответа уходит.
  • Задача TEAM_ONBOARDING администратору не заводится: очередь задач появляется в O1.
  • Статус команды меняется прямой записью, без журнала StateTransition и outbox. Реализован единственный переход ONBOARDING → ACTIVE — верификация администратором платформы в admin-api. Значения SUSPENDED и ARCHIVED в TeamStatus есть, но недостижимы до машины состояний единицы T2.

Все пути с учётом префикса и версии: /api/v1/<path>. Ниже — «сырые» пути из декораторов.

Guard: EmployeeRoleGuard(Role.EMPLOYEE) — доступ только по employee-токену с ролью EMPLOYEE (управление заказчиками силами сотрудников).

МетодПутьGuardОписаниеDTO
POST/customersEmployeeRole(EMPLOYEE)Создать заказчикаCustomerCreateDtoCustomerResponseDto
PATCH/customers/:customerIdEmployeeRole(EMPLOYEE)Обновить заказчикаCustomerUpdateDtoCustomerResponseDto
GET/customersEmployeeRole(EMPLOYEE)Список заказчиков[CustomerResponseDto]
GET/customers/:customerIdEmployeeRole(EMPLOYEE)Заказчик по idCustomerResponseDto
DELETE/customers/:customerIdEmployeeRole(EMPLOYEE)Удалить заказчикаCustomerResponseDto

Guard: CustomerGuard — доступ по customer-токену.

МетодПутьGuardОписаниеDTO
PATCH/customers/meCustomerGuardИзменить мою личную информациюCustomerMeUpdateDtoCustomerMeResponseDto
DELETE/customers/meCustomerGuardУдалить меняCustomerResponseDto
GET/customers/profile/meCustomerGuardПолучить мой профиль заказчикаCustomerProfileMeResponseDto

Guard: EmployeeGuard — доступ по employee-токену.

МетодПутьGuardОписаниеDTO
GET/employees/me/profilesEmployeeGuardПолучить мой профиль сотрудникаEmployeeProfileMeResponseDto
PATCH/employees/me/profilesEmployeeGuardОбновить профиль (опыт, тип работы)UpdateEmployeeProfileDtoEmployeeProfileMeResponseDto
PUT/employees/me/profiles/skillsEmployeeGuardЗаменить навыкиUpdateEmployeeProfileSkillsDtoEmployeeProfileMeResponseDto
DELETE/employees/me/profiles/skillsEmployeeGuardУдалить указанные навыкиDeleteEmployeeProfileSkillsDtoEmployeeProfileMeResponseDto
PUT/employees/me/profiles/specializationsEmployeeGuardЗаменить специализацииUpdateEmployeeProfileSpecializationsDtoEmployeeProfileMeResponseDto
DELETE/employees/me/profiles/specializationsEmployeeGuardУдалить указанные специализацииDeleteEmployeeProfileSpecializationsDtoEmployeeProfileMeResponseDto

EmployeeMeController (@Controller('employee')) — пустой заглушечный контроллер без маршрутов.

Guard: UniversalAccessTokenGuard — принимает любой валидный токен (customer / employee / admin), проверяя его существование в БД токенов. Общий профиль доступен обоим типам пользователей.

МетодПутьGuardОписаниеDTO
GET/users/me/profilesUniversalПолучить мой профиль (+подписанный URL аватара)ProfileMeResponseDto
PATCH/users/me/profilesUniversalИзменить профиль (имя, фамилия, timezone, location)UpdateProfileDtoProfileMeResponseDto
PUT/users/me/profiles/avatarUniversalЗагрузить/заменить аватар (multipart/form-data, поле avatar, до 200 МБ)file → { message }
DELETE/users/me/profiles/avatarUniversalУдалить аватар{ message }
PUT/users/me/profiles/languagesUniversalЗаменить языки профиляUpdateProfileLanguagesDtoProfileMeResponseDto
DELETE/users/me/profiles/languagesUniversalУдалить указанные языкиDeleteProfileLanguagesDtoProfileMeResponseDto

Без guard’ов. Только чтение и поиск (?search=).

МетодПутьGuardОписаниеDTO
GET/profiles/languagesСписок языков[LanguageResponseDto]
GET/profiles/languages/search?search=Поиск языков[LanguageResponseDto]
GET/profiles/skillsСписок навыков[SkillResponseDto]
GET/profiles/skills/search?search=Поиск навыков[SkillResponseDto]
GET/profiles/specializationsСписок специализаций[SpecializationResponseDto]
GET/profiles/specializations/search?search=Поиск специализаций[SpecializationResponseDto]
GET/profiles/timezonesСписок часовых поясов[TimezoneResponseDto]
GET/profiles/timezones/search?search=Поиск часовых поясов[TimezoneResponseDto]
GET/profiles/locationsСписок локаций (с parent, country)[LocationResponseDto]
GET/profiles/locations/search?search=Поиск локаций[LocationResponseDto]
МетодПутьGuardОписаниеDTO
POST/teamsEmployeeGuardСоздать команду (создатель — ADMIN с обоими полномочиями), 201TeamCreateDtoTeamResponseDto
GET/teams/:teamIdEmployeeGuard + TeamMembershipПрофиль командыTeamResponseDto
PATCH/teams/:teamIdEmployeeGuard + TeamMembership(ADMIN)Изменить профиль командыTeamUpdateDtoTeamResponseDto
GET/teams/:teamId/membersEmployeeGuard + TeamMembershipСостав командыQuery: TeamMemberQueryDto[TeamMemberResponseDto]
DELETE/teams/:teamId/members/meEmployeeGuard + TeamMembershipВыйти из команды, 204
PATCH/teams/:teamId/members/:memberIdEmployeeGuard + TeamMembership(ADMIN)Изменить роль и полномочия участникаTeamMemberUpdateDtoTeamMemberResponseDto
DELETE/teams/:teamId/members/:memberIdEmployeeGuard + TeamMembership(ADMIN)Удалить участника (REMOVED), 204
GET/employees/me/teamsEmployeeGuardМои активные членства вместе с командами[TeamMembershipResponseDto]

TeamMembership(ADMIN) в таблице — TeamMembershipGuard с @TeamRoles(TeamMemberRole.ADMIN) на маршруте; без роли guard требует лишь активного членства.

Тела и фильтры:

  • TeamCreateDto: name (строка 1..255, @Trim, обязательна), bio? (≤ 5000), externalExperience? (массив TeamExternalCaseDto, до 20 элементов: title 1..255, description? ≤ 2000, url?, roleInProject? ≤ 255, year? 1970..2100). Слаг генерируется из name и в ответе только читается.
  • TeamUpdateDtoPartialType(TeamCreateDto); смена name слаг не пересчитывает.
  • TeamMemberUpdateDto: role?, canSign?, canSubmit? — хотя бы одно поле обязано быть задано (@IsAtLeastOneFieldDefined), иначе 400.
  • TeamMemberQueryDto: status? (ACTIVE | REMOVED); без фильтра отдаются только ACTIVE.

Создание команды TeamMembershipGuard не защищает — команды, членство в которой можно было бы проверить, ещё нет; /employees/me/teams тоже, там команды нет в пути.

Приглашения в команду (teams/:teamId/invitations, team-invitations)

Заголовок раздела «Приглашения в команду (teams/:teamId/invitations, team-invitations)»
МетодПутьGuardОписаниеDTO
GET/teams/:teamId/invitationsEmployeeGuard + TeamMembership(ADMIN)Приглашения команды, новые сверхуQuery: TeamInvitationQueryDto[TeamInvitationResponseDto]
POST/teams/:teamId/invitationsEmployeeGuard + TeamMembership(ADMIN)Пригласить по e-mail, 201TeamInvitationCreateDtoTeamInvitationResponseDto
DELETE/teams/:teamId/invitations/:invitationIdEmployeeGuard + TeamMembership(ADMIN)Отозвать приглашение (REVOKED), 204
GET/team-invitations/:tokenEmployeeGuardПредпросмотр приглашения по токену — адресность не проверяетсяTeamInvitationPreviewDto
POST/team-invitations/:token/acceptEmployeeGuardПринять приглашение, 200TeamMemberResponseDto
POST/team-invitations/:token/declineEmployeeGuardОтклонить приглашение, 204
  • TeamInvitationCreateDto: email (валидный, ≤ 255, @Trim), role? (ADMIN | EDITOR | VIEWER, по умолчанию EDITOR).
  • TeamInvitationQueryDto: status? (InvitationStatus); без фильтра отдаются приглашения всех статусов.
  • TeamInvitationPreviewDto отдаёт teamId, teamName, teamSlug, email, role, expiresAt — токена в нём нет.
  • Роль ADMIN объявлена на классе TeamInvitationController: ни один его маршрут не открывается мимо неё.

Контур приёма (/team-invitations/...) идёт без TeamMembershipGuard: членства у приглашённого ещё нет. Адресность приглашения проверяет сервис, и только на accept / decline — предпросмотр по токену открыт держателю ссылки. Коды ответов см. в таблице раздела «Жизненный цикл приглашения».

Каждая фича разбита на два Nest-модуля:

  • *-core.module.ts — «ядро»: только providers (сервисы + репозитории) и exports, без контроллеров и guard’ов. Предназначен для переиспользования другими фичами. Пример: CustomerCoreModule экспортирует CustomerService, CustomerRepository, CustomerProfileRepository; UserCoreModule экспортирует UserService, UserOnRoleService; ProfileCoreModule экспортирует ProfileService, ProfileRepository.
  • *.module.ts — «фасад»: импортирует core-модуль, объявляет controllers, подключает MapperService, guard’ы (CustomerGuard/EmployeeGuard), JwtModule и JWT-конфиги нужного типа токена (JwtCustomerConfigModule / JwtEmployeeConfigModule), TokenModule.

Core-модули переиспользуются между доменами: CustomerCoreModule и EmployeeCoreModule импортируют UserCoreModule (для UserService) и ProfileCoreModule (для ProfileRepository, чтобы создавать общий профиль в той же транзакции). ProfileCoreModule подключается через вторичный entrypoint @crewsforge-back/apis/providers/user-api/features/profile/core (маппинг в tsconfig.base.json), чтобы импортировать ядро профиля без его контроллеров.

Все репозитории инжектят TransactionHost<TransactionalAdapterPrisma> и работают через this.txHost.tx.<model>.*. Благодаря этому любой вызов автоматически участвует в текущей CLS-транзакции, открытой декоратором @Transactional() на сервисе. Репозитории — тонкие обёртки над Prisma (create/update/findUnique/findFirst/findMany), плюс специализированные методы для M:N-связей (updateLanguages, updateSkills, updateSpecializations — по паттерну deleteMany + createMany). CustomerRepository также содержит пагинацию (findManyAndPaginate через getPaginate/toPaginateResponse).

MapperService (из apis/shared) сериализует сущность в plain-объект (JSON.parse(JSON.stringify(...))) и прогоняет через plainToInstance с strategy: 'excludeAll' и excludeExtraneousValues: true. Наружу отдаются только поля DTO, помеченные @Expose(). Методы: toResponse, toArrayResponse, toPaginateResponse. Все контроллеры возвращают результат через маппер — паролей и лишних полей в ответе нет (UserResponseDto исключает passwordHash/passwordSalt).

Загрузка идёт через FileInterceptor('avatar') (Multer, лимит 200 МБ). ProfileService.uploadAvatar кладёт файл в S3 (S3ClientService.uploadUserAvatar(userId, filename, buffer, mime)), сохраняет S3-ключ в Avatar.url (создаёт или обновляет запись). При чтении профиля (findOneByUserIdOrFail) url подменяется на подписанный URL (getUserAvatarUrl). Удаление (deleteAvatar) сносит объект в S3 и запись Avatar. DTO ответа — AvatarResponseDto (url, format).

Все DTO наследуют BaseDto (id, createdAt, updatedAt). Create-DTO собираются через OmitType(..., excludeBaseField), Update — через PartialType. Вложенные сущности размечены @Type() + @ValidateNested(). Отдельные «команды» вынесены в самостоятельные DTO (UpdateProfileDto, UpdateProfileLanguagesDto, UpdateEmployeeProfileSkillsDto и т.п.).

  • configsapis/configs/shared/app (порт/окружение), apis/configs/auth-api/jwt-customer, apis/configs/auth-api/jwt-employee, apis/configs/auth-api/jwt-admin (секреты для верификации токенов в guard’ах).
  • utilsapis/utils/prisma-client (PrismaClientModule/PrismaClientService), apis/utils/s3-client (S3ClientModule/S3ClientService для аватаров).
  • sharedapis/shared: MapperService, guard’ы (CustomerGuard, EmployeeGuard, EmployeeRoleGuard), GetTokenPayload (декоратор), Role (enum), хелперы (generatePasswordHashBy, Trim, пагинация), PrismaExceptionFilter, имена стратегий токенов.
  • auth-api — потребляется как источник guard’ов и токенов: apis/providers/auth-api/features/token (TokenModule, TokenService, TokenPayload), apis/providers/auth-api/user-auth (UniversalAccessTokenGuard). Сам User API не выдаёт токены — только проверяет их.
  • транзакцииnestjs-cls + @nestjs-cls/transactional + TransactionalAdapterPrisma.
ПутьРоль
apps/user-api/src/main.tsBootstrap: префикс api, URI-версии, Swagger, ValidationPipe, CORS, Prisma-фильтр
apps/user-api/src/app/app.module.tsКорневой модуль: Cls+transactional, подключение Customer/Employee/Profile/Team/TeamInvitation
.../features/customer/src/lib/customer.controller.tsАдмин-CRUD заказчиков (employee-guard)
.../features/customer/src/lib/customer-me.controller.ts«Мой» заказчик (customer-guard)
.../features/customer/src/lib/customer-profile.controller.tsЧтение профиля заказчика
.../features/customer/src/lib/customer.service.tsТранзакционное создание User+Customer+Profile+CustomerProfile
.../features/customer/src/lib/customer-core.module.tsЯдро: сервисы/репозитории, импорт User/Profile core
.../features/employee/src/lib/employee-me-profile.controller.tsПрофиль сотрудника «me»: навыки/специализации/опыт
.../features/employee/src/lib/employee-profile.service.tsЛогика навыков/специализаций + transform связей
.../features/employee/src/lib/employee.service.tsТранзакционное создание User+Employee+Profile+EmployeeProfile
.../features/profile/src/lib/profile.controller.tsПубличные справочники (list/search)
.../features/profile/src/lib/profile-me.controller.tsОбщий профиль «me»: аватар, языки, timezone/location
.../features/profile/src/lib/profile.service.tsПрофиль + S3-аватары + справочники
.../features/profile/src/lib/profile.repository.tsPrisma-доступ к profile/avatar/языкам/справочникам
.../features/user/src/lib/user.service.tsБазовый CRUD User (email, пароль, isBlocked, delete)
.../features/user/src/lib/user-on-role.service.tsУправление связкой UserOnRole (роли)
.../features/team/src/lib/team.controller.tsСоздание команды и её профиль
.../features/team/src/lib/team-member.controller.tsСостав команды: список, выход, изменение и удаление участника
.../features/team/src/lib/employee-me-team.controller.tsGET /employees/me/teams — мои активные членства
.../features/team/src/lib/guards/team-membership.guard.tsАктивное членство в команде из пути + роль из @TeamRoles
.../features/team/src/lib/decorators/team-roles.decorator.ts@TeamRoles(...) — требуемая роль внутри команды
.../features/team/src/lib/team.service.tsКоманда: свободный слаг, профиль, пагинация, ONBOARDING → ACTIVE
.../features/team/src/lib/team-member.service.tsСостав: роли, полномочия, REMOVED, реактивация, инвариант последнего админа
.../features/team-invitation/src/lib/team-invitation.controller.tsПриглашения внутри команды (только ADMIN)
.../features/team-invitation/src/lib/team-invitation-acceptance.controller.tsПредпросмотр, приём и отклонение по токену
.../features/team-invitation/src/lib/team-invitation.service.tsВыпуск, отзыв, ленивое истечение, адресность приглашения
.../features/team-invitation/src/lib/pipes/parse-invitation-token.pipe.tsПроверка формы токена до похода в базу
.../data-access/src/lib/dtos/*Все DTO домена
.../data-access/src/lib/constants/role.constants.tsФикс-UUID ролей CUSTOMER_ROLE/EMPLOYEE_ROLE
.../data-access/src/lib/constants/team.constants.tsTEAM_INVITATION_TTL_DAYS, TEAM_SLUG_MAX_ATTEMPTS, лимиты полей команды

Добавить новую сущность-профиль (по образцу EmployeeProfile):

  1. Prisma-модель + связь 1:1 с Profile (поле profileId) и с актором.
  2. Репозиторий на txHost.tx.<model> в *-core.module, сервис с логикой, DTO (*ProfileResponseDto от BaseDto).
  3. В <actor>Service.saveOne (внутри @Transactional()) добавить создание записи профиля после общего Profile.
  4. Контроллер <actor>-me-profile.controller.ts с нужным guard’ом, зарегистрировать в *.module.ts.

Добавить новый справочник (по образцу Skill/Language):

  1. Prisma-модель + DTO XxxResponseDto (от BaseDto).
  2. Методы findManyXxx/поиск в ProfileRepository, findAllXxx/searchXxx в ProfileService.
  3. Эндпоинты GET /profiles/xxx и GET /profiles/xxx/search в ProfileController.
  4. Для M:N-привязки к профилю — link-модель (ProfileXxxLink) + методы updateXxx/deleteXxx по паттерну deleteMany+createMany.

Добавить новый эндпоинт: метод в сервисе (@Transactional() при множественных записях) → метод в контроллере с @ApiOperation/@ApiOkResponse, @UseGuards(...), @ApiBearerAuth(<strategy>), ответ через MapperService.toResponse(..., <Dto>). Для «me»-ручек — @GetTokenPayload() payload: TokenPayload и поиск по payload.userId.

Добавить новую роль: расширить RoleType (Prisma) и константы в role.constants.ts; при необходимости — свой guard/JWT-конфиг в auth-api.

Добавить маршрут в контуре команды: положить путь под /teams/:teamId/... (иначе TeamMembershipGuard не найдёт teamId и отдаст 403), навесить @UseGuards(EmployeeGuard, TeamMembershipGuard) и, если операция администраторская, @TeamRoles(TeamMemberRole.ADMIN). Найденное членство уже лежит в request.teamMember — второй раз его искать не нужно. Логику писать в TeamService/TeamMemberService: их же переиспользуют team-invitation и admin-api.

Добавить полномочие участнику (по образцу canSign/canSubmit): поле в TeamMember (миграция) → поле в TeamMemberResponseDto и TeamMemberUpdateDto → ветка в TeamMemberService.updateOneByIdOrFail. Ось полномочий с ролью не связывается: значение выдаётся явно и при реактивации участника сбрасывается.