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/teams | employee | создать команду; создатель — ADMIN с обоими полномочиями | карточка команды |
| GET | /api/v1/teams/:teamId | участник | профиль команды | карточка |
| PATCH | /api/v1/teams/:teamId | ADMIN команды | правка имени, описания, внешнего опыта | карточка |
| GET | /api/v1/teams/:teamId/members | участник | состав с ролями и полномочиями | список членств |
| PATCH | /api/v1/teams/:teamId/members/:memberId | ADMIN команды | сменить роль и/или canSign/canSubmit | членство |
| DELETE | /api/v1/teams/:teamId/members/:memberId | ADMIN команды | убрать участника (→ REMOVED) | 204 |
| DELETE | /api/v1/teams/:teamId/members/me | участник | выйти из команды | 204 |
| GET | /api/v1/teams/:teamId/invitations | ADMIN команды | приглашения команды, фильтр по статусу | список |
| POST | /api/v1/teams/:teamId/invitations | ADMIN команды | пригласить по email с назначенной ролью | приглашение + токен |
| DELETE | /api/v1/teams/:teamId/invitations/:invitationId | ADMIN команды | отозвать (→ REVOKED) | 204 |
| GET | /api/v1/team-invitations/:token | employee | что за приглашение: команда, роль, срок | краткая карточка |
| POST | /api/v1/team-invitations/:token/accept | employee | принять | созданное членство |
| POST | /api/v1/team-invitations/:token/decline | employee | отклонить | 204 |
| GET | /api/v1/employees/me/teams | employee | мои членства | список |
| GET | /api/v1/administrator/teams | администратор | список команд, фильтр по статусу, пагинация | { data, meta } |
| GET | /api/v1/administrator/teams/:teamId | администратор | карточка команды с составом | карточка |
| POST | /api/v1/administrator/teams/:teamId/verify | администратор | ONBOARDING → ACTIVE, штамп verifiedAt | карточка |
Критерии приёмки (из карточки):
- HTTP-e2e на реальной БД: создать команду → пригласить → принять приглашение → выдать
canSign→ снять рольADMINс себя при единственном админе (отказ) → убрать участника → строка членства осталась со статусомREMOVED. - Оси независимы: участник с
role: VIEWERиcanSign: trueсуществует и проходит валидацию. - Приглашение по истёкшему или уже использованному токену отвергается.
- Чужой
teamIdв пути — 403, а не 404 с утечкой существования. - 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.tslibs/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.tslibs/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.tslibs/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.tsapps/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.ts—TeamModule,TeamInvitationModuleapps/admin-api/src/app/app.module.ts—AdminTeamModulelibs/apis/providers/user-api/data-access/src/lib/dtos/index.ts,constants/index.ts— реэкспорт новых DTO и константapps/user-api-e2e/project.json— таргетintegrationapps/user-api-e2e/tsconfig.spec.json—target: es2021(баррельapi-shared→api-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:integrationdocs/delivery/ROADMAP.md,docs/delivery/SESSION-LOG.md— закрытие единицы
Группа 1 (параллельно, общих файлов нет)
Заголовок раздела «Группа 1 (параллельно, общих файлов нет)»- 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— тест: прогон стартует и падает с внятным сообщением при погашенной БД.
Группа 2 (после первой)
Заголовок раздела «Группа 2 (после первой)»- 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.
Группа 3 (после второй)
Заголовок раздела «Группа 3 (после второй)»- T9. Монтирование:
app.module.tsuser-api и admin-api, алиасы вtsconfig.base.json, реэкспорты вdata-access/index.ts. - T10. Сквозной HTTP-e2e user-api по живой БД: сценарий приёмки карточки целиком плюс
негативные — чужой
teamId403, истёкший и повторно использованный токен, параллельное снятие двух последних админов (гонка). - 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-invitation—TeamInvitation;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отдаёт только активные членства: список отвечает на вопрос «куда я могу зайти», а сREMOVEDTeamMembershipGuardвсё равно не пустит. Ушедший команду в своём списке не видит.- Повторный приём приглашения при существующей строке
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со строкой вtokens—EmployeeGuardпускает только токен, у которого есть строка в БД; тот же приём, что в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 скилла.