Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/team-api-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!-- @format -->

# Команда и приглашения: согласование с backend A/B/C

AC D1: DELETE участника использует `/projects/{id}/collaborators/?id={userId}`.
AC D2: формы приглашения, команды и submission показывают безопасные сообщения typed errors.
AC D3: `team_policy.is_frozen` управляет существующими CTA; backend повторно проверяет состояние.
AC D4: передаётся реальный `program_link_id`, без выбора первой связи при неоднозначности.
AC D5: ошибка не удаляет участника из UI; приглашения перечитываются после неудачного accept.

План по слоям: HTTP adapter и port → use-case → существующие facade/component;
доменный snapshot команды и общий mapper ошибок. Проверки: adapter regression,
mapping, multi-link/freeze, существующие facade tests, полный Vitest, build и lint.
Без React, новых экранов и recommendations UI. Развёртывание только после backend C.

Актуализация 16.10.2026: база Angular dev `23663374c72d8ec4fec12b03cb2f46937bb05690`.
Full Vitest проверен после интеграции `ae4a8a0…`; затем dev получил только независимую
SCSS-правку выравнивания названия программы (#425). Она сохранена, связанные
tests и обе сборки повторяются на окончательном head.
Сохранены актуальные member rows, role labels, loading/empty states, формы приглашения
и responsive UI из dev. Единственный merge conflict — импорты team step: нужны и
новый StateComponent, и policy helpers. Дополнительный component regression проверяет,
что server freeze блокирует реальный CTA даже при draft в legacy-связи.
API contract C при переносе на свежий backend dev не менялся.
Detail remove/switch/leave учитывают выбранный `programLinkId` из URL только после
проверки по snapshot links. Leave tests различают link ID 902, program ID 9 и чужую
связь; state conflict сохраняет страницу. InviteCard component test подтверждает:
после freeze смена роли запрещена, pending revoke доступен.

## Проверка и границы

- На исходном D `4401dde0…`: полный Vitest — 402 файла, 1958 tests PASS; после последних
изменений CTA/context дополнительно 72 targeted tests PASS.
- Checked by build: `npm run build:pr` и `npm run build:prod` PASS.
- `npm run lint:ts`: 0 errors, 6 существующих warnings вне изменённых файлов.
- Prettier всех изменённых/new файлов и `git diff --check` PASS.
- Полный `npm run format:check -- --end-of-line auto` PASS: локальный checkout
использует Windows CRLF; без этого CLI override Prettier сообщает только EOL
расхождения. Настройки репозитория и исходные файлы массово не изменялись.
- Checked by source: D1–D5; обработка errors не отображает произвольный backend body;
delete/edit/revoke изменяют локальные данные только при успешном ответе;
accept/decline сохраняют существующий refresh после ошибки.
- Browser/реальные DEV роли: не проверены на этой версии, deploy не выполнялся.
Component tests используют реальные Angular templates в jsdom; это не browser E2E.
- Runner сохраняет три исходных исключения Vitest: `projects.component.spec.ts`,
`program.component.spec.ts`, `editor-submit-button.directive.spec.ts`.
- Геометрия, typography, CSS, DOM/focus order и дизайн не менялись. Изменены
существующие disabled/error states. Нового выбора программы при ambiguous
legacy invite нет: backend вернёт понятную ошибку, автоматически контекст не выбирается.
- Старый API поддерживается для обычных/однозначных операций. Полный snapshot
`team_policy` требует backend C, включая компактный список проектов пользователя.
- Schema/DB/permissions не меняются в этом PR; backend остаётся enforcement layer.

Повторные tests/build/lint/format на обновлённом head и review AC D1–D5 фиксируются
в PR #392. Результаты исходной версии не заменяют текущую verification.

## DEV rollout 17.10.2026

Angular dev обновился до `e1ef88a353a85677cada45f286078897a6a6c1e8` с панелью
организатора #428. Эта база включена обычным merge без конфликтов. Patch ID diff D
к свежему dev совпадает с ранее проверенной версией: `bac1336a7657c6463a4944f93cddc0859240217f`.
Изменений панели организатора или дополнительных изменений бизнес-логики нет.

Backend C уже развёрнут в составе `09b29bf59155b9cda1a70d8959a6dc5de140ee17`.
Read-only inspection [39035280096](https://github.com/PROCOLLAB-github/api/actions/runs/39035280096)
подтвердила migrations Invite 0004 / PartnerProgram 0019, отсутствие pending migrations
и расхождений backfill, совпадение runtime source и ответ team_policy в трёх GET views.
Права БД, ownership, .env и legacy данные в рамках этой проверки не менялись.

Новые результаты полного Vitest, обеих сборок и static checks перед Angular merge
фиксируются в PR #392. Live browser E2E не подтверждён: browser tool не инициализируется;
HTTP-проверка static assets не заменяет взаимодействие с реальными ролями в браузере.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @format */

import { isTeamFrozen } from "@domain/project/team-policy";
import { inject } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivateFn, Router, UrlTree } from "@angular/router";
import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port";
Expand All @@ -23,7 +24,7 @@ export const ProjectEditRequiredGuard: CanActivateFn = (

return projectRepository.getOne(projectId).pipe(
map(project => {
if (project.partnerProgram?.isSubmitted) {
if (isTeamFrozen(project)) {
return router.createUrlTree([`/office/projects/${projectId}`]);
}
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/** @format */
import { teamOperationErrorMessage } from "@api/project/team-operation-error";
import { SnackbarService } from "@domain/shared/snackbar.service";

import { Injectable, computed, inject, signal } from "@angular/core";
import { Invite } from "@domain/invite/invite.model";
Expand All @@ -21,6 +23,7 @@ import { GetMyInvitesUseCase } from "../use-cases/get-my-invites.use-case";
/** Facade приглашений: список «моих» инвайтов + accept/reject через use-case'ы. */
@Injectable({ providedIn: "root" })
export class InviteInfoService {
private readonly snackbar = inject(SnackbarService);
private readonly rejectInviteUseCase = inject(RejectInviteUseCase);
private readonly acceptInviteUseCase = inject(AcceptInviteUseCase);
private readonly getMyInvitesUseCase = inject(GetMyInvitesUseCase);
Expand Down Expand Up @@ -88,7 +91,13 @@ export class InviteInfoService {
tap(result => {
// На ошибке (в т.ч. 409 «уже обработан») ре-синк с сервером, а не revert:
// иначе стухший инвайт возвращается в список и «не пропадает».
if (!result.ok) this.refresh();
if (!result.ok) {
this.snackbar.error(
teamOperationErrorMessage(result.error.cause) ??
"Не удалось обработать приглашение. Обновите список и попробуйте ещё раз.",
);
this.refresh();
}
}),
);
}
Expand All @@ -98,7 +107,13 @@ export class InviteInfoService {

return this.acceptInviteUseCase.execute(inviteId).pipe(
tap(result => {
if (!result.ok) this.refresh();
if (!result.ok) {
this.snackbar.error(
teamOperationErrorMessage(result.error.cause) ??
"Не удалось обработать приглашение. Обновите список и попробуйте ещё раз.",
);
this.refresh();
}
}),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { HttpErrorResponse } from "@angular/common/http";
import { InviteSendError } from "@domain/invite/invite-send-error";

import { teamOperationErrorMessage } from "@api/project/team-operation-error";

const messages: Record<InviteSendError["kind"], string> = {
user_not_found: "Пользователь не найден. Выберите другого участника.",
already_leader: "Вы уже являетесь руководителем этого проекта.",
Expand Down Expand Up @@ -33,6 +35,8 @@ function mapped(kind: InviteSendError["kind"]): InviteSendError {

/** Recognizes only expected user validation errors; all other bodies stay private. */
export function mapInviteSendError(error: unknown): InviteSendError {
const teamMessage = teamOperationErrorMessage(error);
if (teamMessage) return { kind: "validation", message: teamMessage };
if (!(error instanceof HttpErrorResponse)) return mapped("unknown");
if (error.status === 0) return mapped("network");
if (error.status === 401) return mapped("unauthorized");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class AcceptInviteUseCase {
private readonly inviteRepositoryPort = inject(InviteRepositoryPort);
private readonly eventBus = inject(EventBus);

execute(inviteId: number): Observable<Result<void, { kind: "unknown" }>> {
execute(inviteId: number): Observable<Result<void, { kind: "unknown"; cause?: unknown }>> {
return this.inviteRepositoryPort.acceptInvite(inviteId).pipe(
tap(invite => {
if (invite?.project && invite?.user) {
Expand All @@ -23,7 +23,7 @@ export class AcceptInviteUseCase {
}
}),
map(() => ok<void>(undefined)),
catchError(() => of(fail({ kind: "unknown" as const }))),
catchError(cause => of(fail({ kind: "unknown" as const, cause }))),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class RejectInviteUseCase {
private readonly inviteRepositoryPort = inject(InviteRepositoryPort);
private readonly eventBus = inject(EventBus);

execute(inviteId: number): Observable<Result<void, { kind: "unknown" }>> {
execute(inviteId: number): Observable<Result<void, { kind: "unknown"; cause?: unknown }>> {
return this.inviteRepositoryPort.rejectInvite(inviteId).pipe(
tap(invite => {
// best-effort: тонкий ответ reject не должен ронять успешную операцию.
Expand All @@ -22,7 +22,7 @@ export class RejectInviteUseCase {
}
}),
map(() => ok<void>(undefined)),
catchError(() => of(fail({ kind: "unknown" as const }))),
catchError(cause => of(fail({ kind: "unknown" as const, cause }))),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@ export class SendForUserUseCase {
projectId,
role,
specialization,
programLinkId,
}: SendForUserCommand): Observable<Result<Invite, InviteSendError>> {
return this.inviteRepositoryPort.sendForUser(userId, projectId, role, specialization).pipe(
map(invite => ok<Invite>(invite)),
catchError(error => of(fail(mapInviteSendError(error)))),
);
return this.inviteRepositoryPort
.sendForUser(
userId,
projectId,
role,
specialization,
...[programLinkId].filter((id): id is number => id !== undefined),
)
.pipe(
map(invite => ok<Invite>(invite)),
catchError(error => of(fail(mapInviteSendError(error)))),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export class OfficeInfoService {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
if (!result.ok) {
this.officeUIInfoService.applyOpenInviteErrorModal();
// InviteInfoService показывает ошибку и перечитывает список.
}
});
}
Expand All @@ -144,7 +144,7 @@ export class OfficeInfoService {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
if (!result.ok) {
this.officeUIInfoService.applyOpenInviteErrorModal();
// InviteInfoService показывает ошибку и перечитывает список.
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
/** @format */
import { teamOperationErrorMessage } from "@api/project/team-operation-error";
import { isTeamFrozen, teamProgramLinkId } from "@domain/project/team-policy";
import { SnackbarService } from "@domain/shared/snackbar.service";

import { DestroyRef, inject, Injectable } from "@angular/core";
import { filter, map, Observable, tap } from "rxjs";
Expand All @@ -25,6 +28,22 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
/** Фасад детали проекта: участники (удаление/передача владения) и новости проекта (CRUD, лайк, read по видимости). */
@Injectable()
export class ProjectsDetailService {
private readonly snackbar = inject(SnackbarService);
private teamMutable(): boolean {
const project = this.project();
if (project && isTeamFrozen(project)) {
this.snackbar.error("Состав команды зафиксирован после сдачи проекта.");
return false;
}
return true;
}
private teamContext(): number | undefined {
const project = this.project();
const selected = this.route.snapshot?.queryParamMap?.get("programLinkId");
return project
? teamProgramLinkId(project, selected ? Number(selected) : undefined)
: undefined;
}
private readonly route = inject(ActivatedRoute);
private readonly navService = inject(NavService);
private readonly expandService = inject(ExpandService);
Expand Down Expand Up @@ -112,15 +131,20 @@ export class ProjectsDetailService {
}

removeCollaboratorFromProject(userId: number): void {
if (!this.teamMutable()) return;
const projectId = this.projectId();
if (!projectId) return;

this.removeProjectCollaboratorUseCase
.execute(this.projectId()!, userId)
.execute(this.projectId()!, userId, this.teamContext())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: result => {
if (!result.ok) {
this.snackbar.error(
teamOperationErrorMessage(result.error.cause) ??
"Не удалось изменить команду. Попробуйте ещё раз.",
);
return;
}

Expand Down Expand Up @@ -205,11 +229,16 @@ export class ProjectsDetailService {
}

onRemoveMember(id: Collaborator["userId"]) {
if (!this.teamMutable()) return;
this.removeProjectCollaboratorUseCase
.execute(this.projectId()!, id)
.execute(this.projectId()!, id, this.teamContext())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
if (!result.ok) {
this.snackbar.error(
teamOperationErrorMessage(result.error.cause) ??
"Не удалось изменить команду. Попробуйте ещё раз.",
);
return;
}

Expand All @@ -218,11 +247,16 @@ export class ProjectsDetailService {
}

onTransferOwnership(id: Collaborator["userId"]) {
if (!this.teamMutable()) return;
this.transferProjectOwnershipUseCase
.execute(this.projectId()!, id)
.execute(this.projectId()!, id, this.teamContext())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(result => {
if (!result.ok) {
this.snackbar.error(
teamOperationErrorMessage(result.error.cause) ??
"Не удалось изменить команду. Попробуйте ещё раз.",
);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RevokeInviteUseCase } from "@api/invite/use-cases/revoke-invite.use-cas
import { SnackbarService } from "@domain/shared/snackbar.service";
import { ProjectTeamService } from "./project-team.service";
import { ProjectTeamUIService } from "./ui/project-team-ui.service";
import { Project } from "@domain/project/project.model";

describe("Отправка приглашения из команды", () => {
let service: ProjectTeamService;
Expand Down Expand Up @@ -149,4 +150,32 @@ describe("Отправка приглашения из команды", () => {
"Не удалось отозвать приглашение. Попробуйте ещё раз.",
);
});
it("не отправляет invite для frozen snapshot", () => {
service.submitInvite(5, { teamPolicy: { isFrozen: true } } as Project);
expect(repo.sendForUser).not.toHaveBeenCalled();
});
it("передаёт выбранный link ID через use-case и repository port", () => {
service.submitInvite(
5,
{
teamPolicy: {
isFrozen: false,
requiresProgramContext: true,
programLinkId: null,
programLinks: [
{ id: 901, programId: 9 },
{ id: 902, programId: 10 },
],
},
} as Project,
902,
);
expect(repo.sendForUser).toHaveBeenCalledWith(
13,
5,
"Эксперт по работе с промышленными партнёрами",
undefined,
902,
);
});
});
Loading