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-конфиги для защиты эндпоинтов.
Приложение apps/user-api
Заголовок раздела «Приложение apps/user-api»Файл 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(envAPP_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.
Состав (фичи)
Заголовок раздела «Состав (фичи)»| Фича | Путь | Что делает |
|---|---|---|
| customer | libs/apis/providers/user-api/features/customer | CRUD заказчиков (для сотрудников), «мой» эндпоинт заказчика, чтение профиля заказчика |
| employee | libs/apis/providers/user-api/features/employee | Профиль сотрудника «me»: навыки, специализации, тип работы, опыт |
| profile | libs/apis/providers/user-api/features/profile | Общий профиль пользователя «me» (имя, локация, аватар, языки) + публичные справочники |
| user | libs/apis/providers/user-api/features/user | Базовый сервис User (чтение/обновление/удаление) и связка ролей UserOnRole — переиспользуется customer/employee |
| team | libs/apis/providers/user-api/features/team | Команды и их состав: профиль команды, роли и полномочия участников, TeamMembershipGuard и декоратор TeamRoles (README) |
| team-invitation | libs/apis/providers/user-api/features/team-invitation | Приглашения в команду по e-mail: выпуск, отзыв, предпросмотр, приём и отклонение по токену (README) |
| data-access | libs/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"Ключевые сущности:
- User —
email,phone,passwordHash/passwordSalt,isEmailVerified,isBlocked. Один пользователь → одна роль-надстройка (Customer или Employee) + один общий Profile. - Customer / Employee — тонкие сущности со ссылкой
userId(в DTO наследуются отBaseDto, без собственных доменных полей, кроме связи сUser). - Profile —
userId,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:Nskills(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(typeREGION|SUBREGION|COUNTRY|CITY, самоссылкаparent+country, координатыlat/lng),Avatar(url/format).
Транзакционное создание (CustomerService.saveOne / EmployeeService.saveOne, обёрнуто @Transactional()):
- Хешируется пароль (
generatePasswordHashBy). - Создаётся
Userвместе сUserOnRole→Role(connectOrCreateна фикс-UUID роли). - Создаётся
Customer/Employee(user.create). - Создаётся общий
Profile(connectк user). - Создаётся
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:
EmployeeGuardпроверяет employee-токен и его наличие в БД токенов (401иначе);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 / EXPIRED | 409 |
e-mail принимающего не совпал с адресом приглашения — только accept / decline | 403 |
| приглашение на e-mail активного участника команды | 409 |
второе живое PENDING-приглашение на тот же e-mail в ту же команду | 409 |
| приглашённый уже активный участник команды | 409 |
invitationId не принадлежит teamId из пути | 404 |
отзыв приглашения не в статусе PENDING | 409 |
Границы реализованного
Заголовок раздела «Границы реализованного»- Письмо с приглашением не отправляется. Токен возвращается в ответе на создание приглашения и
в списке приглашений команды (
TeamInvitationResponseDto.token) — рассылка приходит с единицейO2, вместе с ней поле из ответа уходит. - Задача
TEAM_ONBOARDINGадминистратору не заводится: очередь задач появляется вO1. - Статус команды меняется прямой записью, без журнала
StateTransitionи outbox. Реализован единственный переходONBOARDING → ACTIVE— верификация администратором платформы в admin-api. ЗначенияSUSPENDEDиARCHIVEDвTeamStatusесть, но недостижимы до машины состояний единицыT2.
HTTP API
Заголовок раздела «HTTP API»Все пути с учётом префикса и версии: /api/v1/<path>. Ниже — «сырые» пути из декораторов.
Заказчики — административные (customers)
Заголовок раздела «Заказчики — административные (customers)»Guard: EmployeeRoleGuard(Role.EMPLOYEE) — доступ только по employee-токену с ролью EMPLOYEE (управление заказчиками силами сотрудников).
| Метод | Путь | Guard | Описание | DTO |
|---|---|---|---|---|
| POST | /customers | EmployeeRole(EMPLOYEE) | Создать заказчика | CustomerCreateDto → CustomerResponseDto |
| PATCH | /customers/:customerId | EmployeeRole(EMPLOYEE) | Обновить заказчика | CustomerUpdateDto → CustomerResponseDto |
| GET | /customers | EmployeeRole(EMPLOYEE) | Список заказчиков | [CustomerResponseDto] |
| GET | /customers/:customerId | EmployeeRole(EMPLOYEE) | Заказчик по id | CustomerResponseDto |
| DELETE | /customers/:customerId | EmployeeRole(EMPLOYEE) | Удалить заказчика | CustomerResponseDto |
Заказчик — «мой» (customers/me, customers/profile)
Заголовок раздела «Заказчик — «мой» (customers/me, customers/profile)»Guard: CustomerGuard — доступ по customer-токену.
| Метод | Путь | Guard | Описание | DTO |
|---|---|---|---|---|
| PATCH | /customers/me | CustomerGuard | Изменить мою личную информацию | CustomerMeUpdateDto → CustomerMeResponseDto |
| DELETE | /customers/me | CustomerGuard | Удалить меня | CustomerResponseDto |
| GET | /customers/profile/me | CustomerGuard | Получить мой профиль заказчика | CustomerProfileMeResponseDto |
Сотрудник — профиль «мой» (employees/me/profiles)
Заголовок раздела «Сотрудник — профиль «мой» (employees/me/profiles)»Guard: EmployeeGuard — доступ по employee-токену.
| Метод | Путь | Guard | Описание | DTO |
|---|---|---|---|---|
| GET | /employees/me/profiles | EmployeeGuard | Получить мой профиль сотрудника | EmployeeProfileMeResponseDto |
| PATCH | /employees/me/profiles | EmployeeGuard | Обновить профиль (опыт, тип работы) | UpdateEmployeeProfileDto → EmployeeProfileMeResponseDto |
| PUT | /employees/me/profiles/skills | EmployeeGuard | Заменить навыки | UpdateEmployeeProfileSkillsDto → EmployeeProfileMeResponseDto |
| DELETE | /employees/me/profiles/skills | EmployeeGuard | Удалить указанные навыки | DeleteEmployeeProfileSkillsDto → EmployeeProfileMeResponseDto |
| PUT | /employees/me/profiles/specializations | EmployeeGuard | Заменить специализации | UpdateEmployeeProfileSpecializationsDto → EmployeeProfileMeResponseDto |
| DELETE | /employees/me/profiles/specializations | EmployeeGuard | Удалить указанные специализации | DeleteEmployeeProfileSpecializationsDto → EmployeeProfileMeResponseDto |
EmployeeMeController(@Controller('employee')) — пустой заглушечный контроллер без маршрутов.
Профиль пользователя — «мой» (users/me/profiles)
Заголовок раздела «Профиль пользователя — «мой» (users/me/profiles)»Guard: UniversalAccessTokenGuard — принимает любой валидный токен (customer / employee / admin), проверяя его существование в БД токенов. Общий профиль доступен обоим типам пользователей.
| Метод | Путь | Guard | Описание | DTO |
|---|---|---|---|---|
| GET | /users/me/profiles | Universal | Получить мой профиль (+подписанный URL аватара) | ProfileMeResponseDto |
| PATCH | /users/me/profiles | Universal | Изменить профиль (имя, фамилия, timezone, location) | UpdateProfileDto → ProfileMeResponseDto |
| PUT | /users/me/profiles/avatar | Universal | Загрузить/заменить аватар (multipart/form-data, поле avatar, до 200 МБ) | file → { message } |
| DELETE | /users/me/profiles/avatar | Universal | Удалить аватар | { message } |
| PUT | /users/me/profiles/languages | Universal | Заменить языки профиля | UpdateProfileLanguagesDto → ProfileMeResponseDto |
| DELETE | /users/me/profiles/languages | Universal | Удалить указанные языки | DeleteProfileLanguagesDto → ProfileMeResponseDto |
Справочники (profiles) — публичные
Заголовок раздела «Справочники (profiles) — публичные»Без 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] |
Команды (teams, employees/me/teams)
Заголовок раздела «Команды (teams, employees/me/teams)»| Метод | Путь | Guard | Описание | DTO |
|---|---|---|---|---|
| POST | /teams | EmployeeGuard | Создать команду (создатель — ADMIN с обоими полномочиями), 201 | TeamCreateDto → TeamResponseDto |
| GET | /teams/:teamId | EmployeeGuard + TeamMembership | Профиль команды | TeamResponseDto |
| PATCH | /teams/:teamId | EmployeeGuard + TeamMembership(ADMIN) | Изменить профиль команды | TeamUpdateDto → TeamResponseDto |
| GET | /teams/:teamId/members | EmployeeGuard + TeamMembership | Состав команды | Query: TeamMemberQueryDto → [TeamMemberResponseDto] |
| DELETE | /teams/:teamId/members/me | EmployeeGuard + TeamMembership | Выйти из команды, 204 | — |
| PATCH | /teams/:teamId/members/:memberId | EmployeeGuard + TeamMembership(ADMIN) | Изменить роль и полномочия участника | TeamMemberUpdateDto → TeamMemberResponseDto |
| DELETE | /teams/:teamId/members/:memberId | EmployeeGuard + TeamMembership(ADMIN) | Удалить участника (REMOVED), 204 | — |
| GET | /employees/me/teams | EmployeeGuard | Мои активные членства вместе с командами | [TeamMembershipResponseDto] |
TeamMembership(ADMIN) в таблице — TeamMembershipGuard с @TeamRoles(TeamMemberRole.ADMIN) на
маршруте; без роли guard требует лишь активного членства.
Тела и фильтры:
TeamCreateDto:name(строка 1..255,@Trim, обязательна),bio?(≤ 5000),externalExperience?(массивTeamExternalCaseDto, до 20 элементов:title1..255,description?≤ 2000,url?,roleInProject?≤ 255,year?1970..2100). Слаг генерируется изnameи в ответе только читается.TeamUpdateDto—PartialType(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/invitations | EmployeeGuard + TeamMembership(ADMIN) | Приглашения команды, новые сверху | Query: TeamInvitationQueryDto → [TeamInvitationResponseDto] |
| POST | /teams/:teamId/invitations | EmployeeGuard + TeamMembership(ADMIN) | Пригласить по e-mail, 201 | TeamInvitationCreateDto → TeamInvitationResponseDto |
| DELETE | /teams/:teamId/invitations/:invitationId | EmployeeGuard + TeamMembership(ADMIN) | Отозвать приглашение (REVOKED), 204 | — |
| GET | /team-invitations/:token | EmployeeGuard | Предпросмотр приглашения по токену — адресность не проверяется | TeamInvitationPreviewDto |
| POST | /team-invitations/:token/accept | EmployeeGuard | Принять приглашение, 200 | TeamMemberResponseDto |
| POST | /team-invitations/:token/decline | EmployeeGuard | Отклонить приглашение, 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 — предпросмотр по
токену открыт держателю ссылки. Коды ответов см. в таблице раздела
«Жизненный цикл приглашения».
Паттерны
Заголовок раздела «Паттерны»core-module vs module
Заголовок раздела «core-module vs module»Каждая фича разбита на два 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), чтобы импортировать ядро профиля без его контроллеров.
Repository-слой
Заголовок раздела «Repository-слой»Все репозитории инжектят 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).
mapper.service
Заголовок раздела «mapper.service»MapperService (из apis/shared) сериализует сущность в plain-объект (JSON.parse(JSON.stringify(...))) и прогоняет через plainToInstance с strategy: 'excludeAll' и excludeExtraneousValues: true. Наружу отдаются только поля DTO, помеченные @Expose(). Методы: toResponse, toArrayResponse, toPaginateResponse. Все контроллеры возвращают результат через маппер — паролей и лишних полей в ответе нет (UserResponseDto исключает passwordHash/passwordSalt).
Аватары через S3
Заголовок раздела «Аватары через S3»Загрузка идёт через 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-иерархия
Заголовок раздела «DTO-иерархия»Все DTO наследуют BaseDto (id, createdAt, updatedAt). Create-DTO собираются через OmitType(..., excludeBaseField), Update — через PartialType. Вложенные сущности размечены @Type() + @ValidateNested(). Отдельные «команды» вынесены в самостоятельные DTO (UpdateProfileDto, UpdateProfileLanguagesDto, UpdateEmployeeProfileSkillsDto и т.п.).
Зависимости
Заголовок раздела «Зависимости»- configs —
apis/configs/shared/app(порт/окружение),apis/configs/auth-api/jwt-customer,apis/configs/auth-api/jwt-employee,apis/configs/auth-api/jwt-admin(секреты для верификации токенов в guard’ах). - utils —
apis/utils/prisma-client(PrismaClientModule/PrismaClientService),apis/utils/s3-client(S3ClientModule/S3ClientServiceдля аватаров). - shared —
apis/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.ts | Bootstrap: префикс 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.ts | Prisma-доступ к 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.ts | GET /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.ts | TEAM_INVITATION_TTL_DAYS, TEAM_SLUG_MAX_ATTEMPTS, лимиты полей команды |
Точки расширения
Заголовок раздела «Точки расширения»Добавить новую сущность-профиль (по образцу EmployeeProfile):
- Prisma-модель + связь 1:1 с
Profile(полеprofileId) и с актором. - Репозиторий на
txHost.tx.<model>в*-core.module, сервис с логикой, DTO (*ProfileResponseDtoотBaseDto). - В
<actor>Service.saveOne(внутри@Transactional()) добавить создание записи профиля после общегоProfile. - Контроллер
<actor>-me-profile.controller.tsс нужным guard’ом, зарегистрировать в*.module.ts.
Добавить новый справочник (по образцу Skill/Language):
- Prisma-модель + DTO
XxxResponseDto(отBaseDto). - Методы
findManyXxx/поиск вProfileRepository,findAllXxx/searchXxxвProfileService. - Эндпоинты
GET /profiles/xxxиGET /profiles/xxx/searchвProfileController. - Для 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. Ось полномочий с ролью не связывается: значение выдаётся явно и при реактивации участника сбрасывается.