From 4401dde0ff81543a9577c68005f2eced03403836 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 29 Sep 2026 15:52:22 +0300 Subject: [PATCH 1/3] fix: align Angular team actions with hardened API --- docs/team-api-alignment.md | 38 ++++++++++++++++++ .../projects-edit/projects-edit.guard.ts | 3 +- .../api/invite/facades/invite-info.service.ts | 19 ++++++++- .../invite/mappers/map-invite-send-error.ts | 4 ++ .../use-cases/accept-invite.use-case.ts | 4 +- .../use-cases/reject-invite.use-case.ts | 4 +- .../use-cases/send-for-user.use-case.ts | 17 ++++++-- .../api/office/facades/office-info.service.ts | 4 +- .../facades/detail/projects-detail.service.ts | 37 +++++++++++++++-- .../facades/edit/project-team.service.spec.ts | 29 ++++++++++++++ .../facades/edit/project-team.service.ts | 27 +++++++++++-- .../api/project/program-link-fields-error.ts | 4 ++ .../api/project/team-operation-error.spec.ts | 40 +++++++++++++++++++ .../app/api/project/team-operation-error.ts | 31 ++++++++++++++ .../use-cases/leave-project.use-case.ts | 11 +++-- .../remove-project-collaborator.use-case.ts | 21 ++++++---- .../transfer-project-ownership.use-case.ts | 19 ++++++--- .../invite/commands/send-for-user.command.ts | 1 + .../app/domain/invite/invite-candidates.ts | 6 ++- .../invite/ports/invite.repository.port.ts | 1 + .../project-collaborators.repository.port.ts | 14 +++++-- .../src/app/domain/project/project.model.ts | 8 ++++ .../app/domain/project/team-policy.spec.ts | 34 ++++++++++++++++ .../src/app/domain/project/team-policy.ts | 21 ++++++++++ .../invite/invite-http.adapter.spec.ts | 12 ++++++ .../adapters/invite/invite-http.adapter.ts | 2 + ...project-collaborators-http.adapter.spec.ts | 17 +++++++- .../project-collaborators-http.adapter.ts | 16 +++++--- .../repository/invite/invite.repository.ts | 25 ++++++++---- .../project-collaborators.repository.ts | 23 ++++++++--- .../projects/detail/team/team.component.html | 1 + .../projects/detail/team/team.component.ts | 3 ++ .../collaborator-card.component.html | 2 +- .../collaborator-card.component.spec.ts | 13 +++++- .../collaborator-card.component.ts | 12 ++++-- .../invite-card/invite-card.component.html | 1 + .../invite-card/invite-card.component.ts | 3 ++ .../project-team-step.component.html | 6 ++- .../project-team-step.component.ts | 14 ++++++- .../profile/detail-profile-info.service.ts | 8 +++- .../project/detail-project-info.service.ts | 13 +++++- .../info-card/info-card.component.html | 1 + .../widgets/info-card/info-card.component.ts | 2 + ...project-member-invite-modal.component.html | 2 +- .../project-member-invite-modal.component.ts | 1 + 45 files changed, 502 insertions(+), 72 deletions(-) create mode 100644 docs/team-api-alignment.md create mode 100644 projects/social_platform/src/app/api/project/team-operation-error.spec.ts create mode 100644 projects/social_platform/src/app/api/project/team-operation-error.ts create mode 100644 projects/social_platform/src/app/domain/project/team-policy.spec.ts create mode 100644 projects/social_platform/src/app/domain/project/team-policy.ts diff --git a/docs/team-api-alignment.md b/docs/team-api-alignment.md new file mode 100644 index 000000000..a17a0ac68 --- /dev/null +++ b/docs/team-api-alignment.md @@ -0,0 +1,38 @@ + + +# Команда и приглашения: согласование с 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. + +## Проверка и границы + +- Checked by tests: полный 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. diff --git a/projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts b/projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts index 751005dcf..e7fc36cd7 100644 --- a/projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts +++ b/projects/core/src/lib/guards/projects-edit/projects-edit.guard.ts @@ -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"; @@ -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; diff --git a/projects/social_platform/src/app/api/invite/facades/invite-info.service.ts b/projects/social_platform/src/app/api/invite/facades/invite-info.service.ts index fa5ecdb86..4b8eae03e 100644 --- a/projects/social_platform/src/app/api/invite/facades/invite-info.service.ts +++ b/projects/social_platform/src/app/api/invite/facades/invite-info.service.ts @@ -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"; @@ -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); @@ -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(); + } }), ); } @@ -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(); + } }), ); } diff --git a/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts b/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts index bb2bd9b66..faf31e62d 100644 --- a/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts +++ b/projects/social_platform/src/app/api/invite/mappers/map-invite-send-error.ts @@ -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 = { user_not_found: "Пользователь не найден. Выберите другого участника.", already_leader: "Вы уже являетесь руководителем этого проекта.", @@ -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"); diff --git a/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts index 971289ec9..d5444b1e3 100644 --- a/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts +++ b/projects/social_platform/src/app/api/invite/use-cases/accept-invite.use-case.ts @@ -13,7 +13,7 @@ export class AcceptInviteUseCase { private readonly inviteRepositoryPort = inject(InviteRepositoryPort); private readonly eventBus = inject(EventBus); - execute(inviteId: number): Observable> { + execute(inviteId: number): Observable> { return this.inviteRepositoryPort.acceptInvite(inviteId).pipe( tap(invite => { if (invite?.project && invite?.user) { @@ -23,7 +23,7 @@ export class AcceptInviteUseCase { } }), map(() => ok(undefined)), - catchError(() => of(fail({ kind: "unknown" as const }))), + catchError(cause => of(fail({ kind: "unknown" as const, cause }))), ); } } diff --git a/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts index a214ce001..489de46bb 100644 --- a/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts +++ b/projects/social_platform/src/app/api/invite/use-cases/reject-invite.use-case.ts @@ -13,7 +13,7 @@ export class RejectInviteUseCase { private readonly inviteRepositoryPort = inject(InviteRepositoryPort); private readonly eventBus = inject(EventBus); - execute(inviteId: number): Observable> { + execute(inviteId: number): Observable> { return this.inviteRepositoryPort.rejectInvite(inviteId).pipe( tap(invite => { // best-effort: тонкий ответ reject не должен ронять успешную операцию. @@ -22,7 +22,7 @@ export class RejectInviteUseCase { } }), map(() => ok(undefined)), - catchError(() => of(fail({ kind: "unknown" as const }))), + catchError(cause => of(fail({ kind: "unknown" as const, cause }))), ); } } diff --git a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts index 3b4cabf1e..48bf59c24 100644 --- a/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts +++ b/projects/social_platform/src/app/api/invite/use-cases/send-for-user.use-case.ts @@ -19,10 +19,19 @@ export class SendForUserUseCase { projectId, role, specialization, + programLinkId, }: SendForUserCommand): Observable> { - return this.inviteRepositoryPort.sendForUser(userId, projectId, role, specialization).pipe( - map(invite => ok(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)), + catchError(error => of(fail(mapInviteSendError(error)))), + ); } } diff --git a/projects/social_platform/src/app/api/office/facades/office-info.service.ts b/projects/social_platform/src/app/api/office/facades/office-info.service.ts index e68eddaed..79e945a07 100644 --- a/projects/social_platform/src/app/api/office/facades/office-info.service.ts +++ b/projects/social_platform/src/app/api/office/facades/office-info.service.ts @@ -130,7 +130,7 @@ export class OfficeInfoService { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(result => { if (!result.ok) { - this.officeUIInfoService.applyOpenInviteErrorModal(); + // InviteInfoService показывает ошибку и перечитывает список. } }); } @@ -144,7 +144,7 @@ export class OfficeInfoService { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(result => { if (!result.ok) { - this.officeUIInfoService.applyOpenInviteErrorModal(); + // InviteInfoService показывает ошибку и перечитывает список. return; } diff --git a/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts index e3dda18a9..a0e37e873 100644 --- a/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts +++ b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts @@ -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"; @@ -25,6 +28,19 @@ 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(); + return project ? teamProgramLinkId(project) : undefined; + } private readonly route = inject(ActivatedRoute); private readonly navService = inject(NavService); private readonly expandService = inject(ExpandService); @@ -112,15 +128,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; } @@ -205,11 +226,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; } @@ -218,11 +244,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; } diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts index 5b80e9651..f901f96a1 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.spec.ts @@ -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; @@ -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, + ); + }); }); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts index 5ff4e56b6..332fd5ffb 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-team.service.ts @@ -1,4 +1,7 @@ /** @format */ +import { teamOperationErrorMessage } from "@api/project/team-operation-error"; +import { isTeamFrozen, teamProgramLinkId } from "@domain/project/team-policy"; +import { Project } from "@domain/project/project.model"; import { DestroyRef, inject, Injectable } from "@angular/core"; import { ValidationService } from "@corelib"; @@ -29,8 +32,19 @@ export class ProjectTeamService { private readonly inviteSubmitInitiated = this.projectTeamUIService.inviteSubmitInitiated; private readonly inviteFormIsSubmitting = this.projectTeamUIService.inviteFormIsSubmitting; - public submitInvite(projectId: number): void { + public submitInvite( + projectId: number, + project?: Project | null, + selectedLinkId?: number | null, + ): void { if (isLoading(this.inviteFormIsSubmitting())) return; + if (project && isTeamFrozen(project)) { + this.projectTeamUIService.applyErrorSubmitInvite({ + kind: "validation", + message: "Состав команды зафиксирован после сдачи проекта.", + }); + return; + } if (!Number.isSafeInteger(projectId) || projectId <= 0) return; this.projectTeamUIService.applyClearInviteError(); this.inviteForm.controls.role.setValue( @@ -48,6 +62,7 @@ export class ProjectTeamService { .execute({ userId: this.inviteForm.controls.recipientId.value!, projectId, + ...(project ? { programLinkId: teamProgramLinkId(project, selectedLinkId) } : {}), role: this.inviteForm.controls.role.value, }) .pipe(takeUntil(this.projectTeamUIService.inviteClosed), takeUntilDestroyed(this.destroyRef)) @@ -72,7 +87,10 @@ export class ProjectTeamService { .subscribe({ next: result => { if (!result.ok) { - this.snackbar.error("Не удалось изменить роль в приглашении. Попробуйте ещё раз."); + this.snackbar.error( + teamOperationErrorMessage(result.error.cause) ?? + "Не удалось изменить роль в приглашении. Попробуйте ещё раз.", + ); return; } @@ -87,7 +105,10 @@ export class ProjectTeamService { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(result => { if (!result.ok) { - this.snackbar.error("Не удалось отозвать приглашение. Попробуйте ещё раз."); + this.snackbar.error( + teamOperationErrorMessage(result.error.cause) ?? + "Не удалось отозвать приглашение. Попробуйте ещё раз.", + ); return; } diff --git a/projects/social_platform/src/app/api/project/program-link-fields-error.ts b/projects/social_platform/src/app/api/project/program-link-fields-error.ts index 9ec6aa9ec..cb08f3cdb 100644 --- a/projects/social_platform/src/app/api/project/program-link-fields-error.ts +++ b/projects/social_platform/src/app/api/project/program-link-fields-error.ts @@ -3,6 +3,8 @@ import { HttpErrorResponse } from "@angular/common/http"; import { ProgramLinkFieldsError } from "@domain/project/program-link-fields.model"; +import { teamOperationErrorMessage } from "./team-operation-error"; + export function mapProgramLinkFieldsError(cause: unknown): ProgramLinkFieldsError { if (cause instanceof HttpErrorResponse) { if (cause.status === 0) return { kind: "network", cause }; @@ -33,6 +35,8 @@ export function mapProgramLinkFieldsError(cause: unknown): ProgramLinkFieldsErro } export function programLinkFieldsErrorMessage(error: ProgramLinkFieldsError): string { + const teamMessage = teamOperationErrorMessage(error.cause); + if (teamMessage) return teamMessage; switch (error.kind) { case "submission_closed": return "Срок подачи проектов в программу завершён."; diff --git a/projects/social_platform/src/app/api/project/team-operation-error.spec.ts b/projects/social_platform/src/app/api/project/team-operation-error.spec.ts new file mode 100644 index 000000000..f58e67c38 --- /dev/null +++ b/projects/social_platform/src/app/api/project/team-operation-error.spec.ts @@ -0,0 +1,40 @@ +/** @format */ +import { HttpErrorResponse } from "@angular/common/http"; +import { teamOperationErrorMessage } from "./team-operation-error"; +import { mapInviteSendError } from "@api/invite/mappers/map-invite-send-error"; +import { + mapProgramLinkFieldsError, + programLinkFieldsErrorMessage, +} from "./program-link-fields-error"; + +describe("Typed team conflicts", () => { + it.each([ + "team_frozen", + "already_in_program_team", + "team_min_size", + "team_max_size", + "program_context_required", + "not_program_member", + "invite_already_processed", + "duplicate_pending_invite", + ])("показывает безопасное сообщение %s в invite и submission", code => { + const cause = new HttpErrorResponse({ + status: 409, + error: { code, detail: "PRIVATE SERVER TEXT" }, + }); + const message = teamOperationErrorMessage(cause); + expect(message).toBeTruthy(); + expect(message).not.toContain("PRIVATE"); + expect(mapInviteSendError(cause).message).toBe(message); + expect(programLinkFieldsErrorMessage(mapProgramLinkFieldsError(cause))).toBe(message); + }); + it("не выводит произвольный body или prototype key", () => { + for (const code of ["unknown", "toString", "__proto__"]) { + expect( + teamOperationErrorMessage( + new HttpErrorResponse({ status: 409, error: { code, detail: "PRIVATE" } }), + ), + ).toBeUndefined(); + } + }); +}); diff --git a/projects/social_platform/src/app/api/project/team-operation-error.ts b/projects/social_platform/src/app/api/project/team-operation-error.ts new file mode 100644 index 000000000..4fc1f8007 --- /dev/null +++ b/projects/social_platform/src/app/api/project/team-operation-error.ts @@ -0,0 +1,31 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; + +const messages: Record = { + team_frozen: "Состав команды зафиксирован после сдачи проекта.", + already_in_program_team: "Пользователь уже состоит в другой команде этой программы.", + team_min_size: "Для сдачи проекта в команде недостаточно участников.", + team_max_size: "В команде нет свободных мест. Ожидающие приглашения резервируют места.", + program_context_required: "У проекта несколько программ. Откройте проект из нужной программы.", + invalid_program_context: "Контекст программы изменился. Обновите страницу проекта.", + not_program_member: "Участник должен быть зарегистрирован в программе.", + duplicate_pending_invite: "Этому пользователю уже отправлено приглашение.", + invite_already_processed: "Приглашение уже обработано. Обновите список приглашений.", + not_project_team_manager: "Управлять командой может только руководитель проекта.", + leader_cannot_leave: "Перед выходом передайте права руководителя другому участнику.", + collaborator_not_found: "Участник уже отсутствует в команде. Обновите страницу.", + already_project_member: "Пользователь уже состоит в команде проекта.", + already_project_leader: "Пользователь уже является руководителем проекта.", +}; + +/** Выводим только известные коды; произвольный ответ сервера не попадает в UI. */ +export function teamOperationErrorMessage(error: unknown): string | undefined { + if (!(error instanceof HttpErrorResponse) || ![403, 409, 422].includes(error.status)) { + return undefined; + } + const code: unknown = error.error?.code; + return typeof code === "string" && Object.prototype.hasOwnProperty.call(messages, code) + ? messages[code] + : undefined; +} diff --git a/projects/social_platform/src/app/api/project/use-cases/leave-project.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/leave-project.use-case.ts index 7920979a4..970912ae0 100644 --- a/projects/social_platform/src/app/api/project/use-cases/leave-project.use-case.ts +++ b/projects/social_platform/src/app/api/project/use-cases/leave-project.use-case.ts @@ -12,10 +12,13 @@ export class LeaveProjectUseCase { execute( projectId: number, + programLinkId?: number, ): Observable> { - return this.projectCollaboratorsRepositoryPort.deleteLeave(projectId).pipe( - map(() => ok(undefined)), - catchError(error => of(fail({ kind: "leave_project_error" as const, cause: error }))), - ); + return this.projectCollaboratorsRepositoryPort + .deleteLeave(projectId, ...[programLinkId].filter((id): id is number => id !== undefined)) + .pipe( + map(() => ok(undefined)), + catchError(error => of(fail({ kind: "leave_project_error" as const, cause: error }))), + ); } } diff --git a/projects/social_platform/src/app/api/project/use-cases/remove-project-collaborator.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/remove-project-collaborator.use-case.ts index 357a74359..20b96680b 100644 --- a/projects/social_platform/src/app/api/project/use-cases/remove-project-collaborator.use-case.ts +++ b/projects/social_platform/src/app/api/project/use-cases/remove-project-collaborator.use-case.ts @@ -16,13 +16,20 @@ export class RemoveProjectCollaboratorUseCase { execute( projectId: number, userId: number, + programLinkId?: number, ): Observable> { - return this.projectCollaboratorsRepositoryPort.deleteCollaborator(projectId, userId).pipe( - tap(() => this.eventBus.emit(removeProjectCollaborator(projectId, userId))), - map(() => ok(userId)), - catchError(error => - of(fail({ kind: "remove_project_collaborator_error" as const, cause: error })), - ), - ); + return this.projectCollaboratorsRepositoryPort + .deleteCollaborator( + projectId, + userId, + ...[programLinkId].filter((id): id is number => id !== undefined), + ) + .pipe( + tap(() => this.eventBus.emit(removeProjectCollaborator(projectId, userId))), + map(() => ok(userId)), + catchError(error => + of(fail({ kind: "remove_project_collaborator_error" as const, cause: error })), + ), + ); } } diff --git a/projects/social_platform/src/app/api/project/use-cases/transfer-project-ownership.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/transfer-project-ownership.use-case.ts index 112c3e247..172650bd7 100644 --- a/projects/social_platform/src/app/api/project/use-cases/transfer-project-ownership.use-case.ts +++ b/projects/social_platform/src/app/api/project/use-cases/transfer-project-ownership.use-case.ts @@ -13,12 +13,19 @@ export class TransferProjectOwnershipUseCase { execute( projectId: number, userId: number, + programLinkId?: number, ): Observable> { - return this.projectCollaboratorsRepositoryPort.patchSwitchLeader(projectId, userId).pipe( - map(() => ok(userId)), - catchError(error => - of(fail({ kind: "transfer_project_ownership_error" as const, cause: error })), - ), - ); + return this.projectCollaboratorsRepositoryPort + .patchSwitchLeader( + projectId, + userId, + ...[programLinkId].filter((id): id is number => id !== undefined), + ) + .pipe( + map(() => ok(userId)), + catchError(error => + of(fail({ kind: "transfer_project_ownership_error" as const, cause: error })), + ), + ); } } diff --git a/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts b/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts index 3b4fbb16c..a015684db 100644 --- a/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts +++ b/projects/social_platform/src/app/domain/invite/commands/send-for-user.command.ts @@ -5,4 +5,5 @@ export interface SendForUserCommand { projectId: number; role: string; specialization?: string; + programLinkId?: number; } diff --git a/projects/social_platform/src/app/domain/invite/invite-candidates.ts b/projects/social_platform/src/app/domain/invite/invite-candidates.ts index d5efcf4d4..b0c314be5 100644 --- a/projects/social_platform/src/app/domain/invite/invite-candidates.ts +++ b/projects/social_platform/src/app/domain/invite/invite-candidates.ts @@ -5,7 +5,11 @@ import { Invite } from "./invite.model"; /** Только programId из загруженного Project: programLinkId обозначает другую сущность. */ export function inviteProgramId(project: Project | null): number | null { - const id = project?.partnerProgram?.programId; + const policy = project?.teamPolicy; + if (policy?.requiresProgramContext) return null; + const id = policy + ? policy.programLinks.find(link => link.id === policy.programLinkId)?.programId + : project?.partnerProgram?.programId; return typeof id === "number" && Number.isSafeInteger(id) && id > 0 ? id : null; } diff --git a/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts b/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts index 9732eaff0..aa940ea9f 100644 --- a/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts +++ b/projects/social_platform/src/app/domain/invite/ports/invite.repository.port.ts @@ -10,6 +10,7 @@ export abstract class InviteRepositoryPort { projectId: number, role: string, specialization?: string, + programLinkId?: number, ): Observable; abstract revokeInvite(invitationId: number): Observable; abstract acceptInvite(inviteId: number): Observable; diff --git a/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts index 4eb0dfefe..e6af235f8 100644 --- a/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts +++ b/projects/social_platform/src/app/domain/project/ports/project-collaborators.repository.port.ts @@ -3,7 +3,15 @@ import { Observable } from "rxjs"; export abstract class ProjectCollaboratorsRepositoryPort { - abstract deleteCollaborator(projectId: number, userId: number): Observable; - abstract patchSwitchLeader(projectId: number, userId: number): Observable; - abstract deleteLeave(projectId: number): Observable; + abstract deleteCollaborator( + projectId: number, + userId: number, + programLinkId?: number, + ): Observable; + abstract patchSwitchLeader( + projectId: number, + userId: number, + programLinkId?: number, + ): Observable; + abstract deleteLeave(projectId: number, programLinkId?: number): Observable; } diff --git a/projects/social_platform/src/app/domain/project/project.model.ts b/projects/social_platform/src/app/domain/project/project.model.ts index b08a904eb..6b9f216d0 100644 --- a/projects/social_platform/src/app/domain/project/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -22,7 +22,15 @@ export class PartnerProgramInfo { programFieldValues!: PartnerProgramFieldsValues[]; } +export interface TeamPolicy { + isFrozen: boolean; + programLinkId: number | null; + requiresProgramContext: boolean; + programLinks: { id: number; programId: number; isSubmitted: boolean; isCompetitive: boolean }[]; +} + export class Project { + teamPolicy?: TeamPolicy; id!: number; name!: string; description!: string; diff --git a/projects/social_platform/src/app/domain/project/team-policy.spec.ts b/projects/social_platform/src/app/domain/project/team-policy.spec.ts new file mode 100644 index 000000000..ed4ad4300 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/team-policy.spec.ts @@ -0,0 +1,34 @@ +/** @format */ +import { isTeamFrozen, teamProgramLinkId } from "./team-policy"; +import { Project } from "./project.model"; + +describe("Server team policy", () => { + const project = { + teamPolicy: { + isFrozen: true, + requiresProgramContext: true, + programLinkId: null, + programLinks: [ + { id: 901, programId: 9 }, + { id: 902, programId: 10 }, + ], + }, + partnerProgram: { isSubmitted: false, programLinkId: 901, programId: 9 }, + } as Project; + it("учитывает frozen snapshot даже если legacy first link draft", () => { + expect(isTeamFrozen(project)).toBe(true); + }); + it("не выбирает первую связь и не путает её с programId", () => { + expect(teamProgramLinkId(project)).toBeUndefined(); + expect(teamProgramLinkId(project, 9)).toBeUndefined(); + expect(teamProgramLinkId(project, 902)).toBe(902); + }); + it("единственная связь приходит с backend; старый API не выдумывает контекст", () => { + expect( + teamProgramLinkId({ + teamPolicy: { ...project.teamPolicy!, requiresProgramContext: false, programLinkId: 901 }, + }), + ).toBe(901); + expect(teamProgramLinkId({})).toBeUndefined(); + }); +}); diff --git a/projects/social_platform/src/app/domain/project/team-policy.ts b/projects/social_platform/src/app/domain/project/team-policy.ts new file mode 100644 index 000000000..79716d74a --- /dev/null +++ b/projects/social_platform/src/app/domain/project/team-policy.ts @@ -0,0 +1,21 @@ +/** @format */ + +import { Project } from "./project.model"; + +/** Совместимость с прежним API; новый snapshot учитывает все связи проекта. */ +export function isTeamFrozen(project: Pick): boolean { + return project.teamPolicy?.isFrozen ?? !!project.partnerProgram?.isSubmitted; +} + +/** ID принадлежит связи Project × Program; programId никогда не является fallback. */ +export function teamProgramLinkId( + project: Pick, + selectedLinkId?: number | null, +): number | undefined { + const policy = project.teamPolicy; + if (!policy) return undefined; + if (selectedLinkId && policy.programLinks.some(link => link.id === selectedLinkId)) { + return selectedLinkId; + } + return policy.requiresProgramContext ? undefined : (policy.programLinkId ?? undefined); +} diff --git a/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.spec.ts index d0985e5f1..f23102f5d 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.spec.ts @@ -40,6 +40,18 @@ describe("InviteHttpAdapter", () => { expect(api.delete).toHaveBeenCalledExactlyOnceWith("/invites/5/"); }); + it("передаёт реальный programLinkId в POST, casing преобразует interceptor", () => { + setup(); + api.post.mockReturnValue(of({} as Invite)); + adapter.sendForUser(7, 42, "dev", undefined, 901).subscribe(); + expect(api.post).toHaveBeenCalledExactlyOnceWith("/invites/", { + user: 7, + project: 42, + role: "dev", + specialization: undefined, + programLinkId: 901, + }); + }); it("acceptInvite идёт в POST /invites/:id/accept/", () => { setup(); diff --git a/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts index bfc24f96b..70faaba8d 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/invite/invite-http.adapter.ts @@ -17,12 +17,14 @@ export class InviteHttpAdapter { projectId: number, role: string, specialization?: string, + programLinkId?: number, ): Observable { return this.apiService.post(`${this.INVITES_URL}/`, { user: userId, project: projectId, role, specialization, + ...(programLinkId !== undefined ? { programLinkId } : {}), }); } diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.spec.ts index 006085c49..c6c9262b3 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.spec.ts @@ -23,7 +23,7 @@ describe("ProjectCollaboratorsHttpAdapter", () => { adapter.deleteCollaborator(42, 7).subscribe(); - expect(api.delete).toHaveBeenCalledExactlyOnceWith("/projects/42/collaborators?id=7/"); + expect(api.delete).toHaveBeenCalledExactlyOnceWith("/projects/42/collaborators/?id=7"); }); it("patchSwitchLeader идёт в PATCH /projects/:pid/collaborators/:uid/switch-leader/", () => { @@ -46,4 +46,19 @@ describe("ProjectCollaboratorsHttpAdapter", () => { expect(api.delete).toHaveBeenCalledExactlyOnceWith("/projects/42/collaborators/leave/"); }); + it("передаёт link ID в query удаления/выхода и body смены лидера", () => { + setup(); + api.delete.mockReturnValue(of(undefined)); + api.patch.mockReturnValue(of(undefined)); + adapter.deleteCollaborator(42, 7, 901).subscribe(); + adapter.deleteLeave(42, 901).subscribe(); + adapter.patchSwitchLeader(42, 7, 901).subscribe(); + expect(api.delete).toHaveBeenCalledWith("/projects/42/collaborators/?id=7&program_link_id=901"); + expect(api.delete).toHaveBeenCalledWith( + "/projects/42/collaborators/leave/?program_link_id=901", + ); + expect(api.patch).toHaveBeenCalledWith("/projects/42/collaborators/7/switch-leader/", { + programLinkId: 901, + }); + }); }); diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts index 746a52538..9540dc6e7 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-collaborators-http.adapter.ts @@ -10,18 +10,22 @@ export class ProjectCollaboratorsHttpAdapter { private readonly PROJECTS_URL = "/projects"; private readonly apiService = inject(ApiService); - deleteCollaborator(projectId: number, userId: number): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators?id=${userId}/`); + deleteCollaborator(projectId: number, userId: number, programLinkId?: number): Observable { + return this.apiService.delete( + `${this.PROJECTS_URL}/${projectId}/collaborators/?id=${userId}${programLinkId !== undefined ? `&program_link_id=${programLinkId}` : ""}`, + ); } - patchSwitchLeader(projectId: number, userId: number): Observable { + patchSwitchLeader(projectId: number, userId: number, programLinkId?: number): Observable { return this.apiService.patch( `${this.PROJECTS_URL}/${projectId}/collaborators/${userId}/switch-leader/`, - {}, + programLinkId !== undefined ? { programLinkId } : {}, ); } - deleteLeave(projectId: number): Observable { - return this.apiService.delete(`${this.PROJECTS_URL}/${projectId}/collaborators/leave/`); + deleteLeave(projectId: number, programLinkId?: number): Observable { + return this.apiService.delete( + `${this.PROJECTS_URL}/${projectId}/collaborators/leave/${programLinkId !== undefined ? `?program_link_id=${programLinkId}` : ""}`, + ); } } diff --git a/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts b/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts index ee12dd809..8a4268be3 100644 --- a/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/invite/invite.repository.ts @@ -56,15 +56,24 @@ export class InviteRepository implements InviteRepositoryPort { projectId: number, role: string, specialization?: string, + programLinkId?: number, ): Observable { - return this.inviteAdapter.sendForUser(userId, projectId, role, specialization).pipe( - map(raw => { - const invite = plainToInstance(Invite, raw); - if (raw.sender) invite.sender = userFromRaw(raw.sender); - if (raw.user) invite.user = userFromRaw(raw.user); - return invite; - }), - ); + return this.inviteAdapter + .sendForUser( + userId, + projectId, + role, + specialization, + ...[programLinkId].filter((id): id is number => id !== undefined), + ) + .pipe( + map(raw => { + const invite = plainToInstance(Invite, raw); + if (raw.sender) invite.sender = userFromRaw(raw.sender); + if (raw.user) invite.user = userFromRaw(raw.user); + return invite; + }), + ); } revokeInvite(invitationId: number): Observable { diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts index dcf5c0c93..1dba19d88 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project-collaborators.repository.ts @@ -10,15 +10,26 @@ import { ProjectCollaboratorsHttpAdapter } from "../../adapters/project/project- export class ProjectCollaboratorsRepository implements ProjectCollaboratorsRepositoryPort { private readonly projectCollaboratorsAdapter = inject(ProjectCollaboratorsHttpAdapter); - deleteCollaborator(projectId: number, userId: number): Observable { - return this.projectCollaboratorsAdapter.deleteCollaborator(projectId, userId); + deleteCollaborator(projectId: number, userId: number, programLinkId?: number): Observable { + return this.projectCollaboratorsAdapter.deleteCollaborator( + projectId, + userId, + ...[programLinkId].filter((id): id is number => id !== undefined), + ); } - patchSwitchLeader(projectId: number, userId: number): Observable { - return this.projectCollaboratorsAdapter.patchSwitchLeader(projectId, userId); + patchSwitchLeader(projectId: number, userId: number, programLinkId?: number): Observable { + return this.projectCollaboratorsAdapter.patchSwitchLeader( + projectId, + userId, + ...[programLinkId].filter((id): id is number => id !== undefined), + ); } - deleteLeave(projectId: number): Observable { - return this.projectCollaboratorsAdapter.deleteLeave(projectId); + deleteLeave(projectId: number, programLinkId?: number): Observable { + return this.projectCollaboratorsAdapter.deleteLeave( + projectId, + ...[programLinkId].filter((id): id is number => id !== undefined), + ); } } diff --git a/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html index da920cd1a..2dd04167e 100644 --- a/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/detail/team/team.component.html @@ -6,6 +6,7 @@ @for (collaborator of collaborators(); track $index) { {{ collaborator().firstName }} {{ collaborator().la 'Исключить участника: ' + collaborator().firstName + ' ' + collaborator().lastName " title="Исключить участника" - [disabled]="removing()" + [disabled]="removing() || frozen() || isLeader()" [attr.aria-busy]="removing()" (click)="onDeleteCollaborator(collaborator().userId)" > diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts index fb15f42ea..64b6dcc13 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.spec.ts @@ -53,7 +53,7 @@ describe("CollaboratorCardComponent", () => { execute.mockReturnValue(request); component.onDeleteCollaborator(1); component.onDeleteCollaborator(1); - expect(execute).toHaveBeenCalledExactlyOnceWith(5, 1); + expect(execute).toHaveBeenCalledExactlyOnceWith(5, 1, undefined); request.next({ ok: false, error: { kind: "remove_project_collaborator_error" } }); request.complete(); fixture.detectChanges(); @@ -67,4 +67,15 @@ describe("CollaboratorCardComponent", () => { expect(removed).toHaveBeenCalledExactlyOnceWith(1); confirm.mockRestore(); }); + it("не отправляет удаление замороженной команды или лидера", () => { + const execute = vi.mocked(TestBed.inject(RemoveProjectCollaboratorUseCase).execute); + fixture.componentRef.setInput("frozen", true); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("button").disabled).toBe(true); + component.onDeleteCollaborator(1); + fixture.componentRef.setInput("frozen", false); + fixture.componentRef.setInput("isLeader", true); + component.onDeleteCollaborator(1); + expect(execute).not.toHaveBeenCalled(); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts index 7b479be68..c1f51e832 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/collaborator-card/collaborator-card.component.ts @@ -1,4 +1,5 @@ /** @format */ +import { teamOperationErrorMessage } from "@api/project/team-operation-error"; import { ChangeDetectionStrategy, Component, @@ -29,19 +30,21 @@ export class CollaboratorCardComponent { private readonly destroyRef = inject(DestroyRef); readonly collaborator = input.required(); readonly isCurrentUser = input(false); + readonly frozen = input(false); + readonly programLinkId = input(); readonly isLeader = input(false); readonly collaboratorRemoved = output(); readonly removing = signal(false); readonly error = signal(""); onDeleteCollaborator(collaboratorId: number): void { - if (this.removing()) return; + if (this.removing() || this.frozen() || this.isLeader()) return; const projectId = this.route.snapshot.params["projectId"]; if (!confirm("Вы точно хотите удалить участника проекта?")) return; this.removing.set(true); this.error.set(""); this.removeProjectCollaboratorUseCase - .execute(+projectId, collaboratorId) + .execute(+projectId, collaboratorId, this.programLinkId()) .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => this.removing.set(false)), @@ -49,7 +52,10 @@ export class CollaboratorCardComponent { .subscribe({ next: result => { if (!result.ok) { - this.error.set("Не удалось исключить участника. Попробуйте ещё раз."); + this.error.set( + teamOperationErrorMessage(result.error.cause) ?? + "Не удалось исключить участника. Попробуйте ещё раз.", + ); return; } this.collaboratorRemoved.emit(result.value); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html index 893bba396..9baef5973 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.html @@ -18,6 +18,7 @@

{{ invite().user.firstName }} {{ invite().user.last type="button" class="member-row__action" (click)="openEdit($event)" + [disabled]="frozen()" [attr.aria-label]=" 'Изменить роль в приглашении: ' + invite().user.firstName + ' ' + invite().user.lastName " diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts index 4494a0159..c6bcd0908 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.ts @@ -24,6 +24,7 @@ import { ProjectInviteRoleInputComponent } from "@ui/widgets/project-invite/proj changeDetection: ChangeDetectionStrategy.OnPush, }) export class InviteCardComponent { + readonly frozen = input(false); readonly invite = input.required(); readonly remove = output(); readonly edit = output<{ inviteId: number; role: string; specialization: string }>(); @@ -44,6 +45,7 @@ export class InviteCardComponent { }).format(date); }); openEdit(event: Event): void { + if (this.frozen()) return; this.trigger = event.currentTarget as HTMLElement; this.role.reset(this.invite().role ?? ""); this.isEditInviteModal.set(true); @@ -57,6 +59,7 @@ export class InviteCardComponent { this.remove.emit(this.invite().id); } onEdit(): void { + if (this.frozen()) return; this.role.setValue(normalizeInviteText(this.role.value)); this.role.markAsTouched(); if (this.role.invalid) return; diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html index f64551e0d..3e7131714 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.html @@ -9,7 +9,7 @@

Команда проекта

class="invite__submit" appearance="outline" size="big" - [disabled]="teamLoading() || isLoading(inviteFormIsSubmitting())" + [disabled]="frozen() || teamLoading() || isLoading(inviteFormIsSubmitting())" (click)="openInviteModal($event)" >
  • @@ -97,6 +100,7 @@

    diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts index 015a8fac9..9ac710fb8 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/project-team-step.component.ts @@ -1,4 +1,5 @@ /** @format */ +import { isTeamFrozen, teamProgramLinkId } from "@domain/project/team-policy"; import { ChangeDetectionStrategy, Component, @@ -51,12 +52,23 @@ export class ProjectTeamStepComponent implements OnInit, OnDestroy { protected readonly isInviteModalOpen = this.projectTeamUIService.isInviteModalOpen; protected readonly isLoading = isLoading; protected readonly inviteFormIsSubmitting = this.projectTeamUIService.inviteFormIsSubmitting; + protected readonly frozen = computed(() => { + const project = this.invitationProject(); + return project ? isTeamFrozen(project) : false; + }); + protected readonly programLinkId = computed(() => { + const project = this.invitationProject(); + return project + ? teamProgramLinkId(project, this.projectsEditInfoService.activeProgramLinkId?.()) + : undefined; + }); protected readonly projectId = this.projectsEditInfoService.profileId; ngOnInit(): void { this.projectTeamService.setupDynamicValidation(); } openInviteModal(event: Event): void { + if (this.frozen()) return; this.inviteTrigger = (event.currentTarget as HTMLElement).querySelector("button") ?? (event.currentTarget as HTMLElement); @@ -69,7 +81,7 @@ export class ProjectTeamStepComponent implements OnInit, OnDestroy { this.closeInviteModal(); } editInvitation(params: { inviteId: number; role: string; specialization: string }): void { - this.projectTeamService.editInvitation(params); + if (!this.frozen()) this.projectTeamService.editInvitation(params); } removeInvitation(invitationId: number): void { this.projectTeamService.removeInvitation(invitationId); diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts index 79912c71f..7bdc31245 100644 --- a/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts +++ b/projects/social_platform/src/app/ui/widgets/detail/services/profile/detail-profile-info.service.ts @@ -1,4 +1,5 @@ /** @format */ +import { isTeamFrozen, teamProgramLinkId } from "@domain/project/team-policy"; import { DestroyRef, inject, Injectable, Injector, signal } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; @@ -68,7 +69,9 @@ export class DetailProfileInfoService { toObservable(this.profileInfoService.leaderProjects, { injector: this.injector }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(projects => this.profileProjects.set(projects)); + .subscribe(projects => + this.profileProjects.set(projects.filter(project => !isTeamFrozen(project))), + ); } initializationProfile(): void { @@ -148,12 +151,15 @@ export class DetailProfileInfoService { ) return; + const project = this.profileProjects().find(item => item.id === projectId)!; + if (isTeamFrozen(project)) return; this.inviteLoading.set(true); this.inviteError.set(null); this.sendForUserUseCase .execute({ userId: this.inviteForm.controls.recipientId.value!, projectId, + programLinkId: teamProgramLinkId(project), role: role.value, }) .pipe(takeUntil(this.inviteClosed), takeUntilDestroyed(this.destroyRef)) diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts index 9ed344f8a..112bef3ac 100644 --- a/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts +++ b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts @@ -1,4 +1,7 @@ /** @format */ +import { teamOperationErrorMessage } from "@api/project/team-operation-error"; +import { teamProgramLinkId } from "@domain/project/team-policy"; +import { SnackbarService } from "@domain/shared/snackbar.service"; import { DestroyRef, inject, Injectable, signal } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; @@ -10,6 +13,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @Injectable() export class DetailProjectInfoService { + private readonly snackbar = inject(SnackbarService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly logger = inject(LoggerService); @@ -59,12 +63,17 @@ export class DetailProjectInfoService { this.route.data .pipe( map(r => r["data"][0]), - concatMap(p => this.leaveProjectUseCase.execute(p.id)), + concatMap(p => this.leaveProjectUseCase.execute(p.id, teamProgramLinkId(p))), takeUntilDestroyed(this.destroyRef), ) .subscribe(result => { if (!result.ok) { - this.leaderLeaveModal.set(true); + const message = teamOperationErrorMessage(result.error.cause); + if (message) this.snackbar.error(message); + else + this.snackbar.error( + "Не удалось выйти из команды. Обновите страницу и попробуйте ещё раз.", + ); return; } diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html index b07e47fbe..62c002d4b 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.html @@ -189,6 +189,7 @@ color="red" appearance="outline" (click)="removeCollaboratorFromProject(info()?.userId)" + [disabled]="teamFrozen()" >удалить diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts index e22775360..ea3f42233 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts @@ -71,6 +71,7 @@ export class InfoCardComponent { readonly appereance = input<"my" | "subs" | "base" | "empty">("base"); readonly section = input<"projects" | "subscriptions" | "other">("projects"); readonly canDelete = input(false); + readonly teamFrozen = input(false); @Input() isSubscribed?: boolean | null = false; readonly profileId = input(); readonly leaderId = input(); @@ -136,6 +137,7 @@ export class InfoCardComponent { inviteErrorModal = false; removeCollaboratorFromProject(userId: number): void { + if (this.teamFrozen()) return; this.onRemoveCollaborator.emit(userId); } diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html index 71b838e6a..d9b8772cf 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html +++ b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.html @@ -59,7 +59,7 @@ class="invite-button invite-button--primary" [disabled]="ui.inviteForm.invalid || isLoading(ui.inviteFormIsSubmitting())" [attr.aria-busy]="isLoading(ui.inviteFormIsSubmitting())" - (click)="team.submitInvite(projectId())" + (click)="team.submitInvite(projectId(), project(), programLinkId())" > @if (isLoading(ui.inviteFormIsSubmitting())) { diff --git a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts index ea03687ca..4dd712506 100644 --- a/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts +++ b/projects/social_platform/src/app/ui/widgets/project-invite/project-member-invite-modal.component.ts @@ -37,6 +37,7 @@ import { ParticipantPickerComponent } from "./participant-picker.component"; }) export class ProjectMemberInviteModalComponent { readonly project = input(null); + readonly programLinkId = input(null); readonly projectId = input.required(); readonly trigger = input(null); readonly closed = output(); From bb52786015cf56a732d5489b19b8a03f429d2784 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 16 Oct 2026 17:49:06 +0300 Subject: [PATCH 2/3] fix: preserve selected program context in team detail operations --- docs/team-api-alignment.md | 4 + .../facades/detail/projects-detail.service.ts | 5 +- .../invite-card/invite-card.component.spec.ts | 22 +++++ .../detail-project-info.service.spec.ts | 86 +++++++++++++++++++ .../project/detail-project-info.service.ts | 8 +- 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.spec.ts diff --git a/docs/team-api-alignment.md b/docs/team-api-alignment.md index a719a20cf..e70cccc64 100644 --- a/docs/team-api-alignment.md +++ b/docs/team-api-alignment.md @@ -19,6 +19,10 @@ mapping, multi-link/freeze, существующие facade tests, полный новый 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 доступен. ## Проверка и границы diff --git a/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts index a0e37e873..e990993d0 100644 --- a/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts +++ b/projects/social_platform/src/app/api/project/facades/detail/projects-detail.service.ts @@ -39,7 +39,10 @@ export class ProjectsDetailService { } private teamContext(): number | undefined { const project = this.project(); - return project ? teamProgramLinkId(project) : undefined; + 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); diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts index 80d776b46..94563f022 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-team-step/invite-card/invite-card.component.spec.ts @@ -73,6 +73,28 @@ describe("InviteCardComponent", () => { }); expect(component.isEditInviteModal()).toBe(false); }); + it("freeze запрещает смену роли и сохраняет отзыв pending приглашения", () => { + fixture.componentRef.setInput("frozen", true); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + const editButton = root.querySelector('[title="Изменить роль"]')!; + const revokeButton = root.querySelector('[title="Отозвать приглашение"]')!; + const edit = vi.fn(); + const remove = vi.fn(); + component.edit.subscribe(edit); + component.remove.subscribe(remove); + expect(editButton.disabled).toBe(true); + expect(revokeButton.disabled).toBe(false); + editButton.click(); + component.role.setValue("Новая роль"); + component.onEdit(); + expect(component.isEditInviteModal()).toBe(false); + expect(edit).not.toHaveBeenCalled(); + revokeButton.click(); + expect(component.isRemoveInviteModal()).toBe(true); + component.onRemove(); + expect(remove).toHaveBeenCalledExactlyOnceWith(1); + }); it("отмена отзыва не удаляет, подтверждение передаёт ID приглашения", () => { const remove = vi.fn(); component.remove.subscribe(remove); diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.spec.ts b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.spec.ts new file mode 100644 index 000000000..b94704382 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.spec.ts @@ -0,0 +1,86 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, convertToParamMap, Router } from "@angular/router"; +import { of } from "rxjs"; +import { LeaveProjectUseCase } from "@api/project/use-cases/leave-project.use-case"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { Project } from "@domain/project/project.model"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { DetailProjectInfoService } from "./detail-project-info.service"; + +describe("Выход из команды в выбранном контексте программы", () => { + const project = { + id: 5, + teamPolicy: { + isFrozen: false, + requiresProgramContext: true, + programLinkId: null, + programLinks: [ + { id: 901, programId: 9 }, + { id: 902, programId: 10 }, + ], + }, + } as Project; + let route: { + data: ReturnType; + snapshot: { queryParamMap: ReturnType }; + }; + const leave = { execute: vi.fn() }; + const router = { navigateByUrl: vi.fn() }; + const snackbar = { error: vi.fn() }; + + beforeEach(() => { + vi.clearAllMocks(); + leave.execute.mockReturnValue(of({ ok: true })); + router.navigateByUrl.mockResolvedValue(true); + route = { + data: of({ data: [project] }), + snapshot: { queryParamMap: convertToParamMap({}) }, + }; + TestBed.configureTestingModule({ + providers: [ + DetailProjectInfoService, + { provide: ActivatedRoute, useValue: route }, + { provide: LeaveProjectUseCase, useValue: leave }, + { provide: Router, useValue: router }, + { provide: LoggerService, useValue: { debug: vi.fn() } }, + { provide: SnackbarService, useValue: snackbar }, + ], + }); + }); + + it.each([ + ["902", 902], + ["9", undefined], + ["999", undefined], + [null, undefined], + ])("передаёт только подтверждённый link ID из URL %s", (selected, expected) => { + route.snapshot.queryParamMap = convertToParamMap( + selected === null ? {} : { programLinkId: selected }, + ); + TestBed.inject(DetailProjectInfoService).onLeave(); + expect(leave.execute).toHaveBeenCalledExactlyOnceWith(5, expected); + }); + + it("state conflict сохраняет страницу и показывает понятную причину", () => { + leave.execute.mockReturnValue( + of({ + ok: false, + error: { + kind: "leave_project_error", + cause: new HttpErrorResponse({ + status: 409, + error: { code: "team_frozen", detail: "PRIVATE SERVER TEXT" }, + }), + }, + }), + ); + const service = TestBed.inject(DetailProjectInfoService); + service.onLeave(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(snackbar.error).toHaveBeenCalledWith("Состав команды зафиксирован после сдачи проекта."); + expect(service.leaderLeaveModal()).toBe(false); + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts index 112bef3ac..fa1b90b16 100644 --- a/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts +++ b/projects/social_platform/src/app/ui/widgets/detail/services/project/detail-project-info.service.ts @@ -63,7 +63,13 @@ export class DetailProjectInfoService { this.route.data .pipe( map(r => r["data"][0]), - concatMap(p => this.leaveProjectUseCase.execute(p.id, teamProgramLinkId(p))), + concatMap(p => { + const selected = this.route.snapshot?.queryParamMap?.get("programLinkId"); + return this.leaveProjectUseCase.execute( + p.id, + teamProgramLinkId(p, selected ? Number(selected) : undefined), + ); + }), takeUntilDestroyed(this.destroyRef), ) .subscribe(result => { From 1bcd27978c611bd5f8cc17d8bae99f83f4b75faa Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sat, 17 Oct 2026 16:16:41 +0300 Subject: [PATCH 3/3] docs: record current DEV team rollout verification --- docs/team-api-alignment.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/team-api-alignment.md b/docs/team-api-alignment.md index 2decaad6d..edfa5e4ea 100644 --- a/docs/team-api-alignment.md +++ b/docs/team-api-alignment.md @@ -53,3 +53,20 @@ Detail remove/switch/leave учитывают выбранный `programLinkId` Повторные 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 не заменяет взаимодействие с реальными ролями в браузере.