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

T1 — Команды: состав, приглашения, полномочия

Цель: команда становится субъектом платформы — регистрируется, собирает состав по приглашениям, разводит доступ (role) и полномочия (canSign/canSubmit), проходит верификацию администратором.

Источники: шаг 2 §3 (домен Team) · шаг 4 §7 (строка «Команда: состав, роли, полномочия»), §8 (скоупинг маршрутов) · шаг 7 §2 (TEAM_ONBOARDING) · шаг 9, дополнение 2026-08-30 (эпик F6, машина статуса команды) · решение №08 (team — либы в user-api).

API для фронта:

МетодПутьКто вызываетНазначениеВозвращает
POST/api/v1/teamsemployeeсоздать команду; создатель — ADMIN с обоими полномочиямикарточка команды
GET/api/v1/teams/:teamIdучастникпрофиль командыкарточка
PATCH/api/v1/teams/:teamIdADMIN командыправка имени, описания, внешнего опытакарточка
GET/api/v1/teams/:teamId/membersучастниксостав с ролями и полномочиямисписок членств
PATCH/api/v1/teams/:teamId/members/:memberIdADMIN командысменить роль и/или canSign/canSubmitчленство
DELETE/api/v1/teams/:teamId/members/:memberIdADMIN командыубрать участника (→ REMOVED)204
DELETE/api/v1/teams/:teamId/members/meучастниквыйти из команды204
GET/api/v1/teams/:teamId/invitationsADMIN командыприглашения команды, фильтр по статусусписок
POST/api/v1/teams/:teamId/invitationsADMIN командыпригласить по email с назначенной рольюприглашение + токен
DELETE/api/v1/teams/:teamId/invitations/:invitationIdADMIN командыотозвать (→ REVOKED)204
GET/api/v1/team-invitations/:tokenemployeeчто за приглашение: команда, роль, сроккраткая карточка
POST/api/v1/team-invitations/:token/acceptemployeeпринятьсозданное членство
POST/api/v1/team-invitations/:token/declineemployeeотклонить204
GET/api/v1/employees/me/teamsemployeeмои членствасписок
GET/api/v1/administrator/teamsадминистраторсписок команд, фильтр по статусу, пагинация{ data, meta }
GET/api/v1/administrator/teams/:teamIdадминистраторкарточка команды с составомкарточка
POST/api/v1/administrator/teams/:teamId/verifyадминистраторONBOARDING → ACTIVE, штамп verifiedAtкарточка

Критерии приёмки (из карточки):

  1. HTTP-e2e на реальной БД: создать команду → пригласить → принять приглашение → выдать canSign → снять роль ADMIN с себя при единственном админе (отказ) → убрать участника → строка членства осталась со статусом REMOVED.
  2. Оси независимы: участник с role: VIEWER и canSign: true существует и проходит валидацию.
  3. Приглашение по истёкшему или уже использованному токену отвергается.
  4. Чужой teamId в пути — 403, а не 404 с утечкой существования.
  5. Swagger содержит все маршруты; примеры запроса и ответа приложены к PR.

Создать

  • libs/apis/providers/user-api/features/team/** — либа user-api-feature-team: team.repository.ts, team-member.repository.ts, team.service.ts, team-member.service.ts, team-core.module.ts, team.module.ts, team.controller.ts, team-member.controller.ts, employee-me-team.controller.ts, guards/team-membership.guard.ts, decorators/team-roles.decorator.ts, team.constants.ts
  • libs/apis/providers/user-api/features/team-invitation/** — либа user-api-feature-team-invitation: team-invitation.repository.ts, team-invitation.service.ts, team-invitation-core.module.ts, team-invitation.module.ts, team-invitation.controller.ts, team-invitation-acceptance.controller.ts
  • libs/apis/providers/admin-api/features/admin-team/** — либа admin-api-feature-admin-team: admin-team.service.ts, admin-team.module.ts, admin-team.controller.ts, dto/admin-team-query.dto.ts, dto/admin-team-response.dto.ts
  • libs/apis/providers/user-api/data-access/src/lib/dtos/team*.dto.ts — общие DTO домена
  • apps/user-api-e2e/jest-integration.config.ts, src/support/integration-db.ts, src/support/integration-env.ts, src/support/integration-global-setup.ts
  • apps/user-api-e2e/src/user-api/team-lifecycle.integration.spec.ts — сквозной сценарий приёмки
  • apps/user-api-e2e/src/user-api/team-access.integration.spec.ts — 403 на чужой команде, оси, гонка
  • apps/admin-api-e2e/src/admin-api/administrator-team.integration.spec.ts — админская поверхность
  • README.md каждой из трёх новых либ

Изменить

  • tsconfig.base.json — три новых алиаса @crewsforge-back/apis/providers/...
  • apps/user-api/src/app/app.module.tsTeamModule, TeamInvitationModule
  • apps/admin-api/src/app/app.module.tsAdminTeamModule
  • libs/apis/providers/user-api/data-access/src/lib/dtos/index.ts, constants/index.ts — реэкспорт новых DTO и констант
  • apps/user-api-e2e/project.json — таргет integration
  • apps/user-api-e2e/tsconfig.spec.jsontarget: es2021 (баррель api-sharedapi-core-money)
  • docs/03-services/user/user-api.md — фичи team, team-invitation, их API и домен
  • docs/03-services/admin/admin-api.md — поверхность администратора по командам
  • docs/06-operations/local-dev.md — новый интеграционный прогон user-api-e2e:integration
  • docs/delivery/ROADMAP.md, docs/delivery/SESSION-LOG.md — закрытие единицы
  • T1. DTO домена в user-api/data-access: TeamResponseDto, TeamCreateDto, TeamUpdateDto, TeamMemberResponseDto, TeamMemberUpdateDto, TeamInvitationCreateDto, TeamInvitationResponseDto, TeamInvitationPreviewDto, TeamInvitationQueryDto, константа TEAM_INVITATION_TTL_DAYS — тесты: валидация (пустое имя, длина, неверный enum, email), @Expose() на каждом поле (без него поле молча пропадает при excludeAll).
  • T2. Либа user-api-feature-team, слой данных и домена: TeamRepository, TeamMemberRepository (включая блокировку строки команды FOR UPDATE), TeamService (создание с транзакцией, слаг с ретраем на P2002, профиль, верификация, список с пагинацией), TeamMemberService (состав, смена роли и полномочий, удаление, выход, assertNotLastAdmin, реактивация REMOVED), TeamCoreModule — тесты: инвариант последнего админа во всех трёх точках входа, независимость осей, реактивация членства, ретрай слага.
  • T3. Либа user-api-feature-team-invitation, слой данных и домена: TeamInvitationRepository, TeamInvitationService (создание с криптотокеном и сроком, список, отзыв, предпросмотр, приём, отклонение, ленивый EXPIRED), TeamInvitationCoreModule — тесты: истёкший, отозванный, уже принятый токен, чужой email, приглашение активного участника.
  • T4. Обвяз интеграционных прогонов user-api-e2e: jest-integration.config.ts, три support-файла, таргет integration в project.json, target: es2021 в tsconfig.spec.json — тест: прогон стартует и падает с внятным сообщением при погашенной БД.
  • T5. Guard членства: TeamMembershipGuard + @TeamRoles(...) + Reflector, 403 на чужой команде и на не-ADMIN операции — тесты: не член → 403, VIEWER на админской операции → 403, REMOVED не считается членством.
  • T6. Контроллеры user-api-feature-team: teams, teams/:teamId/members, employees/me/teams, TeamModule — тесты: цепочка guard’ов на каждом маршруте, Swagger-декораторы, маппинг через MapperService.
  • T7. Контроллеры user-api-feature-team-invitation: teams/:teamId/invitations, team-invitations/:token, TeamInvitationModule — тесты: коды отказов 404/403/409/410.
  • T8. Либа admin-api-feature-admin-team: сервис поверх TeamCoreModule, контроллер administrator/teams под AdminAccessTokenGuard + AdministratorGuard, query-DTO с фильтром по статусу и пагинацией — тесты: 403 оператору и арбитру, повторная верификация → 409.
  • T9. Монтирование: app.module.ts user-api и admin-api, алиасы в tsconfig.base.json, реэкспорты в data-access/index.ts.
  • T10. Сквозной HTTP-e2e user-api по живой БД: сценарий приёмки карточки целиком плюс негативные — чужой teamId 403, истёкший и повторно использованный токен, параллельное снятие двух последних админов (гонка).
  • T11. HTTP-e2e admin-api: список с фильтром, карточка, верификация, кросс-ролевые 403.
  • T12. Документация по documentation-rules.md: README трёх либ, страницы сервисов, docs/06-operations/local-dev.md (новый интеграционный таргет).

Проверено субагентом crewsforge-decision-reviewer в два круга. Первый вернул три возражения по существу (скоуп вложенных ресурсов :memberId/:invitationId, контракт externalExperience, видимость строк REMOVED в списках) — все приняты и закрыты; второй вернул CONFIRMED по 52 решениям.

  • Домен команд: три библиотеки — user-api-feature-team (libs/apis/providers/user-api/features/team/), user-api-feature-team-invitation (.../features/team-invitation/), admin-api-feature-admin-team (libs/apis/providers/admin-api/features/admin-team/) — решение №08 («team — либы в user-api») и шаг 2 §3 («либы team-*»); третья либа нужна, потому что админская поверхность живёт в admin-api по шагу 4 §8.
  • Переиспользование домена админкой: admin-api-feature-admin-team импортирует TeamCoreModule из user-api-feature-team и зовёт его сервисы; собственных записей в БД не делает — единая точка правил состава (шаг 2 §3), граница разрешена тегами (scope:admin-api → scope:user-api, eslint.config.mjs).
  • Разделение либ внутри user-api: team держит Team + TeamMember, team-invitationTeamInvitation; team-invitation зависит от TeamCoreModule (приём приглашения создаёт членство), обратной зависимости нет — coding-rules §3 («зависимости направлены сверху вниз»).
  • team-core.module.ts без контроллеров экспортирует TeamService/TeamMemberService; team.module.ts импортирует core и добавляет контроллеры — coding-rules §3.
  • DTO, общие для двух user-api-либ (TeamResponseDto, TeamMemberResponseDto), — в user-api/data-access/src/lib/dtos/; DTO, живущие в одной либе (админские, query-DTO), — в dto/ этой либы — coding-rules §2 и §1.4.
  • Теги новых либ: scope:user-api|admin-api + type:feature, имена по схеме <service>-feature-<feature> — coding-rules §1.2 (легаси-имена admin-project/admin-user не копируем, §13).
  • Пути: /api/v1/teams/... (команда), /api/v1/team-invitations/:token/... (приём), /api/v1/employees/me/teams (мои членства), /api/v1/administrator/teams/... (администратор) — шаг 4 §8 задаёт префиксы teams/:teamId и administrator/; приём вынесен из-под /teams/:teamId, чтобы :teamId и :token не конфликтовали в одном сегменте.
  • Контроль членства: один TeamMembershipGuard в user-api-feature-team + декоратор @TeamRoles(...), читаемый Reflector; отсутствие декоратора = достаточно активного членства любой роли — каноническая форма Nest: требование к роли объявляется метаданными на маршруте и видно в коде контроллера, а guard остаётся одним классом с одной точкой запроса членства. Mixin-фабрика (EmployeeRoleGuard) закрывает другую ось — платформенную роль из токена, без обращения к БД, — и здесь не переиспользуется.
  • Guard живёт в либе домена, а не в libs/apis/shared: coding-rules §2 отправляет в shared только то, что нужно больше чем одному сервису, а второй потребитель членства появится не раньше P5. Соседний EmployeeGuard остался в shared и обошёл цикл прямым запросом через PrismaClientService — приём, которого здесь не требуется: правила членства уже живут в сервисе домена, и guard зовёт его напрямую.
  • Не член команды на /teams/:teamId/...ForbiddenException (403) — прямое требование приёмки карточки («403, а не 404 с утечкой существования»).
  • Вложенные ресурсы (:memberId, :invitationId) выбираются только по паре (teamId из пути, id ресурса) — иначе ADMIN команды A правит роль или удаляет участника команды B, и та же утечка проходит боком, мимо проверки :teamId. Репозиторные методы принимают обе части ключа, отдельного findUnique({ id }) в сервисах состава и приглашений нет.
  • Несовпадение пары → NotFoundException (404), а не 403: путь уже прошёл TeamMembershipGuard, то есть своя команда доказана, и 404 здесь означает «в этой команде такого участника нет» — существования чужой команды он не выдаёт. Требование карточки «403, а не 404» относится к :teamId и им закрыто.
  • assertNotLastAdmin считает активных администраторов по teamId из пути, а не по teamId найденной строки — строка и так найдена по паре, но зависимость от данных здесь лишняя.
  • Цепочка guard’ов team-поверхности: @UseGuards(EmployeeGuard, TeamMembershipGuard); EmployeeGuard кладёт в запрос payload с employeeId (employee-auth.service.ts:64), по нему guard ищет членство.
  • admin-api-feature-admin-team не импортирует @crewsforge-back/apis/providers/auth-api/features/token: либа без тегов, и такой импорт даёт ровно ту ошибку границ, что сейчас краснеет у admin-project. AdminTokenPayload берётся из api-shared, как в admin-api-feature-workspace.
  • Админская поверхность: @UseGuards(AdminAccessTokenGuard, AdministratorGuard) — как в admin-api-feature-workspace (F2), матрица шага 4 §7 («Команда: состав, роли, полномочия» — Administrator R/W, Operator и Arbiter ∅).
  • Оси canSign/canSubmit в T1 не охраняют ни одного маршрута — подписывать и сдавать пока нечего; guard полномочий не заводится (принцип роадмапа «механизм без потребителя — не тот механизм»), появится с P5 и M3. В T1 они данные, независимость осей доказывается тестом.
  • externalExperience (Json? в схеме, вопрос ФТ 10.3.9) кладётся типизированным массивом кейсов: вложенный TeamExternalCaseDto (title обязателен, description?, url?, roleInProject?, year?), не более 20 элементов, через @Type() + @ValidateNested({ each: true }) — coding-rules §1.4; свободный @IsObject() не даёт фронту контракта, а whitelist внутрь Json-поля не заглядывает, и туда прошло бы что угодно. Колонка остаётся Json, поэтому уточнение формы позже — правка DTO, а не миграция.
  • Создатель команды получает role: ADMIN, canSign: true, canSubmit: true в той же транзакции, что и команда — иначе команда из одного человека не может ни подписать договор, ни сдать этап; оси при этом остаются независимыми для всех прочих участников (2.2 ФТ, шаг 2 §3).
  • Инвариант последнего администратора: единый приватный assertNotLastAdmin в TeamMemberService, вызывается из смены роли, удаления участника и выхода — шаг 2 §3 («service-level, единая точка изменения состава»).
  • Защита от гонки на этом инварианте: в транзакции @Transactional() сначала берётся блокировка строки команды (SELECT id FROM teams WHERE id = $1 FOR UPDATE через $queryRaw в репозитории), затем считаются активные админы — без блокировки два параллельных удаления двух последних админов оба видят счётчик 2 и оба проходят. Проверяется параллельным запросом в интеграционном тесте (мокать транзакции запрещено, фаза 6 скилла).
  • Выход и удаление участника: status: REMOVED + removedAt, строка не удаляется — шаг 2 §3 (на членство ссылается история подписей и сдач).
  • GET /teams/:teamId/members по умолчанию отдаёт только ACTIVE; необязательный query-параметр status (ACTIVE/REMOVED) открывает ушедших — так критерий приёмки «строка осталась со статусом REMOVED» доказывается по HTTP, а не запросом e2e напрямую в базу, и экран состава получает вкладку бывших участников без второго маршрута.
  • GET /employees/me/teams отдаёт только активные членства: список отвечает на вопрос «куда я могу зайти», а с REMOVED TeamMembershipGuard всё равно не пустит. Ушедший команду в своём списке не видит.
  • Повторный приём приглашения при существующей строке REMOVED: строка реактивируется (status: ACTIVE, role из приглашения, removedAt: null), canSign/canSubmit сбрасываются в false@@unique([teamId, employeeId]) не даёт завести вторую строку, а полномочия по 2.2 ФТ выдаются явно и не воскресают сами.
  • Слаг: createSlug(name) из shared/helpers; при P2002 повтор с шестизначным hex-суффиксом, не более трёх попыток, затем ConflictException — уникальность слага держит БД (Team.slug @unique), а не предварительная проверка, которая гонку не закрывает.
  • Мультичленство разрешено (шаг 2 §3); запрет «фаундер = участник команды того же проекта» — guard создания и приёма оффера в P2, здесь не реализуется.
  • Токен: 32 случайных байта node:crypto.randomBytes в base64url, хранится открытым в TeamInvitation.token — схема шага 2 §3 не содержит поля под хеш, а введение хеша здесь разошлось бы со схемой (правка схемы в T1 не оправдана: секрет живёт до 7 дней и одноразовый).
  • Срок жизни: константа TEAM_INVITATION_TTL_DAYS = 7 в user-api/data-access/constants, не переменная окружения — параметра нет в журнале решений («Параметры»), а новая env потребовала бы полного обвяза по coding-rules §7 без потребителя, который её меняет.
  • Истечение ленивое: обращение к PENDING-приглашению с expiresAt < now переводит его в EXPIRED и отвечает GoneException (410) — фоновых таймеров нет до O1 (шаг 7 §3), а без ленивого перевода статус в БД врал бы.
  • Коды отказов: несуществующий токен — 404, истёкший — 410, ACCEPTED/DECLINED/REVOKED — 409, email не совпал — 403 — штатные исключения Nest, coding-rules §6.
  • Email принимающего берётся через UserService из user-api-feature-user (чистая либа без циклов, findOneById), а не через EmployeeService: user-api-feature-employee уже участвует в предсуществующем цикле employee → profile → user-auth → employee (красный lint на main, хендофф F2), и новая зависимость на него потянула бы этот цикл в домен команд.
  • Приглашение адресное: принять может только пользователь, чей user.email совпадает с invitation.email (сравнение без учёта регистра) — иначе утёкшая ссылка даёт вход в команду кому угодно; критерий карточки требует отказа только по сроку и повтору, привязка к email строже и не противоречит ему.
  • Приглашение на email уже активного участника отклоняется ConflictException — второе членство всё равно невозможно (@@unique), отказ на входе честнее, чем ошибка БД при приёме.
  • Токен возвращается в ответе на создание приглашения и в списке приглашений для ADMIN команды — заглушка вместо письма (O2): без токена в API продуктового сценария приглашения не существует. В Swagger поле помечено как временное, снимается вместе с рассылкой в O2.
  • Письмо не отправляется: TODO(O2) в TeamInvitationService + запись в SESSION-LOG.md — заглушка по правилу 3 фазы 6 скилла. mailer-client в user-api не подключается: SMTP-обвяз без потребителя (шаблон письма и его текст — предмет O2).
  • В T1 реализован единственный переход ONBOARDING → ACTIVE (verifiedAt штампуется) прямой записью, без StateTransition и outbox — карточка T1 («смена статуса пока прямая, без журнала переходов — журнал придёт в T2»); SUSPENDED/ARCHIVED из дополнения шага 9 не реализуются здесь по той же причине.
  • Верификация уже верифицированной команды → ConflictException, а не идемпотентный 200 — повторный штамп verifiedAt затирал бы дату первой верификации.
  • Задача TEAM_ONBOARDING администратору (шаг 7 §2) не заводится — очереди задач нет до O1; TODO(O1) в TeamService.saveOne плюс запись в лог сессии.
  • Мокаются только репозитории (в unit-спеках сервисов) — транзакции, CAS/блокировки, констрейнты БД и guard’ы проверяются интеграционными прогонами по живой PostgreSQL (фаза 6 скилла, запрет мокать).
  • HTTP-e2e user-api: новый таргет integration у apps/user-api-e2e (jest-integration.config.ts, support/integration-env.ts, support/integration-db.ts, support/integration-global-setup.ts) — копия обвяза admin-api-e2e из F2; сквозной сценарий приёмки карточки в одном спеке.
  • HTTP-e2e admin-api: спек добавляется в существующий таргет admin-api-e2e:integration — маршруты администратора живут в admin-api, и матрица доступа F2 уже гоняется там же.
  • Employee-токены фикстур выпускаются боевым TokenEmployeeService со строкой в tokensEmployeeGuard пускает только токен, у которого есть строка в БД; тот же приём, что в apps/admin-api-e2e/src/admin-api/platform-role-access.integration.spec.ts (F2), там его выпускает TokenAdminService.
  • Свои строки помечаются runId-суффиксом и убираются в afterAll; сидовые данные только читаются — интеграционные спеки делят одну базу (F2).
  • tsconfig.spec.json новых либ ставит target: es2021 — спеки тянут баррель api-shared, а он тянет api-core-money с BigInt-литералами (хендофф F2).
  • info на создание команды, изменение состава, верификацию; warn непосредственно перед throw на отказ по инварианту последнего админа и по приглашению; action = <nx-имя либы>/<Класс>/<метод> — coding-rules §10.
  • Email в логах маскируется, токен приглашения не логируется никогда — coding-rules §10.9.
  • Сущность → DTO только через MapperService, ответ списка администратора — { data, meta } через getPaginate/toPaginateResponse — coding-rules §5.
  • Группа 1 (параллельно, общих файлов нет): DTO в data-access · каркас либы team (repositories + TeamService/TeamMemberService + core-модуль) · каркас либы team-invitation · обвяз user-api-e2e под интеграционный таргет.
  • Группа 2 (после первой): guard членства и декоратор ролей · контроллеры team · контроллеры team-invitation · либа admin-team.
  • Группа 3: монтирование модулей в app.module.ts обоих приложений, интеграционные спеки, документация.
  • Правило нарезки: задачи, трогающие один файл (app.module.ts, tsconfig.base.json, data-access/index.ts), никогда не идут в одну группу — фаза 5 скилла.