From 45cb4483a5b35311365090356ac9a821d577f9c8 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 19 Oct 2026 18:18:24 +0300 Subject: [PATCH] feat: program organizer invitations and access management --- docs/modules/program-management.md | 29 ++ .../api/auth/facades/auth-password.service.ts | 8 + .../use-cases/program-organizers.use-case.ts | 33 ++ .../social_platform/src/app/app.config.ts | 2 + .../social_platform/src/app/app.routes.ts | 21 + .../program-organizers.repository.port.ts | 29 ++ .../program/program-management.model.ts | 8 +- .../program/program-organizers.model.ts | 39 ++ .../program-organizers-http.adapter.ts | 58 +++ .../program/program-organizers.providers.ts | 7 + .../program/program-organizers.repository.ts | 16 + .../expert-invitation.component.html | 86 +++- .../expert-invitation.component.scss | 5 + .../invitation/expert-invitation.component.ts | 21 +- .../organizer-invitation.component.spec.ts | 87 ++++ .../management/management.component.html | 7 + .../management/management.component.scss | 4 +- .../management/management.component.ts | 29 +- .../organizers/organizers.component.html | 280 +++++++++++++ .../organizers/organizers.component.scss | 340 ++++++++++++++++ .../organizers/organizers.component.spec.ts | 169 ++++++++ .../organizers/organizers.component.ts | 372 ++++++++++++++++++ 22 files changed, 1624 insertions(+), 26 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/use-cases/program-organizers.use-case.ts create mode 100644 projects/social_platform/src/app/domain/program/ports/program-organizers.repository.port.ts create mode 100644 projects/social_platform/src/app/domain/program/program-organizers.model.ts create mode 100644 projects/social_platform/src/app/infrastructure/adapters/program/program-organizers-http.adapter.ts create mode 100644 projects/social_platform/src/app/infrastructure/di/program/program-organizers.providers.ts create mode 100644 projects/social_platform/src/app/infrastructure/repository/program/program-organizers.repository.ts create mode 100644 projects/social_platform/src/app/ui/pages/evaluation/invitation/organizer-invitation.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/organizers/organizers.component.ts diff --git a/docs/modules/program-management.md b/docs/modules/program-management.md index 316af89a9..c0698f1ca 100644 --- a/docs/modules/program-management.md +++ b/docs/modules/program-management.md @@ -19,6 +19,35 @@ Индивидуальные действия с участниками, массовые операции, исключение проектов, возврат на доработку и смена кейса отложены. +## Организаторы · 19.10.2026 + +В «Настройки → Организаторы» добавлены приватный список с поиском/статусом, +приглашение по email, повторная отправка, отзыв ссылки и снятие доступа. Все +организаторы равны в пределах программы. Последнего активного организатора нельзя +убрать; непринятые приглашения его не заменяют. Выход из организаторов очищает +приватный список и возвращает на страницу программы. Сервер проверяет права, +версию состава и последнее активное членство под блокировкой строки программы. + +Новый `ProgramOrganizersUseCase` использует отдельный port/repository/HTTP adapter, +без общего EntityCache для email. Выбор строки не запрашивает данные: одна +фиксированная плашка расположена вне потока таблицы. Кнопки 32 px с областью +нажатия 44 px на телефоне; модалки по центру. Шесть вкладок настроек на телефоне +занимают два ряда по три вкладки. Mont и существующие DS-компоненты сохранены. + +`/organizer-invitation/:token` использует общий компонент приглашения с отдельной +ролью маршрута и API. Открытие ссылки не выдаёт права; требуется принятие под +приглашённой почтой. Для нового организатора обязательны имя, фамилия, дата +рождения, пароль и согласия. Регистрация эксперта остаётся без даты рождения. +Восстановление пароля проходит через `/organizer-password-reset` и возвращает к +приглашению. Роль организатора не добавляет право оценивания или доступ к Django. + +Backend: API #796, миграция `partner_programs.0023_organizerinvitation_and_more`. +Backend разворачивается до frontend. Ссылка приглашения действует 7 дней; +повторная отправка меняет токен, отзыв и снятие доступа закрывают прежние ссылки. +Проверены 20 новых PostgreSQL tests (включая две гонки), 20 прежних expert tests, +36 focused Vitest и полный набор 2243 tests. Реальная доставка внешнему получателю +отдельно в эти проверки не входит: локальный браузер использует locmem-почту. + ## Интеграция Новый приватный port/use case/repository/HTTP adapter использует `/programs/:id/management/`. CamelcaseInterceptor сохраняет существующее преобразование JSON. Почта и прочие приватные данные не попадают в общий EntityCache. После записи событие ProgramUpdated очищает кеш страницы и списка программ, включая persistent cache; старый ответ списка не заменяет новые данные. diff --git a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts index ce313336e..a6c807d1c 100644 --- a/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts +++ b/projects/social_platform/src/app/api/auth/facades/auth-password.service.ts @@ -87,6 +87,14 @@ export class AuthPasswordService { .pipe( tap(result => { if (result.ok) { + const organizerInvitation = + this.route.snapshot.queryParamMap.get("organizer_invitation"); + if (organizerInvitation && /^[A-Za-z0-9_-]{20,100}$/.test(organizerInvitation)) { + void this.router.navigate(["/organizer-invitation", organizerInvitation], { + queryParams: { mode: "login" }, + }); + return; + } const invitation = this.route.snapshot.queryParamMap.get("expert_invitation"); if (invitation && /^[A-Za-z0-9_-]{20,100}$/.test(invitation)) { void this.router.navigate(["/expert-invitation", invitation], { diff --git a/projects/social_platform/src/app/api/program/use-cases/program-organizers.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-organizers.use-case.ts new file mode 100644 index 000000000..63a9e5b7f --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-organizers.use-case.ts @@ -0,0 +1,33 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { tap } from "rxjs"; +import { ProgramOrganizersRepositoryPort } from "@domain/program/ports/program-organizers.repository.port"; +import { OrganizerRegistration } from "@domain/program/program-organizers.model"; +import { EventBus } from "@domain/shared/event-bus"; +import { ProgramUpdated } from "@domain/program/program-updated.event"; +@Injectable({ providedIn: "root" }) +export class ProgramOrganizersUseCase { + private readonly repository = inject(ProgramOrganizersRepositoryPort); + private readonly events = inject(EventBus); + private updated(programId: number): void { + this.events.emit({ + type: "ProgramUpdated", + payload: { programId }, + occurredAt: new Date(), + }); + } + readonly list = this.repository.list.bind(this.repository); + readonly invite = this.repository.invite.bind(this.repository); + readonly invitationAction = this.repository.invitationAction.bind(this.repository); + readonly invitation = this.repository.invitation.bind(this.repository); + readonly resetPassword = this.repository.resetPassword.bind(this.repository); + remove(id: number, userId: number, revision: string) { + return this.repository.remove(id, userId, revision).pipe(tap(() => this.updated(id))); + } + accept(token: string) { + return this.repository.accept(token).pipe(tap(r => this.updated(r.programId))); + } + register(token: string, data: OrganizerRegistration) { + return this.repository.register(token, data).pipe(tap(r => this.updated(r.programId))); + } +} diff --git a/projects/social_platform/src/app/app.config.ts b/projects/social_platform/src/app/app.config.ts index 6ecfc2ef6..7d948795f 100644 --- a/projects/social_platform/src/app/app.config.ts +++ b/projects/social_platform/src/app/app.config.ts @@ -16,6 +16,7 @@ import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@a import { provideRouter, withRouterConfig } from "@angular/router"; import { APP_ROUTES } from "./app.routes"; import { EVALUATION_PROVIDERS } from "./infrastructure/di/program/evaluation.providers"; +import { PROGRAM_ORGANIZERS_PROVIDERS } from "./infrastructure/di/program/program-organizers.providers"; import { API_URL, BearerTokenInterceptor, @@ -97,6 +98,7 @@ export const APP_CONFIG: ApplicationConfig = { provideAnimations(), ...AUTH_PROVIDERS, ...EVALUATION_PROVIDERS, + ...PROGRAM_ORGANIZERS_PROVIDERS, ...FEED_PROVIDERS, ...INDUSTRY_PROVIDERS, ...INVITE_PROVIDERS, diff --git a/projects/social_platform/src/app/app.routes.ts b/projects/social_platform/src/app/app.routes.ts index e5915755a..07787109a 100644 --- a/projects/social_platform/src/app/app.routes.ts +++ b/projects/social_platform/src/app/app.routes.ts @@ -15,6 +15,27 @@ import { AuthRequiredGuard } from "@core/lib/guards/auth/auth-required.guard"; */ export const APP_ROUTES: Routes = [ + { + path: "organizer-password-reset", + loadComponent: () => import("./ui/pages/auth/auth.component").then(c => c.AuthComponent), + children: [ + { + path: "", + loadComponent: () => + import("./ui/pages/auth/set-password/set-password.component").then( + c => c.SetPasswordComponent, + ), + }, + ], + }, + { + path: "organizer-invitation/:token", + data: { invitationRole: "organizer" }, + loadComponent: () => + import("./ui/pages/evaluation/invitation/expert-invitation.component").then( + c => c.ExpertInvitationComponent, + ), + }, { path: "expert-password-reset", loadComponent: () => import("./ui/pages/auth/auth.component").then(c => c.AuthComponent), diff --git a/projects/social_platform/src/app/domain/program/ports/program-organizers.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-organizers.repository.port.ts new file mode 100644 index 000000000..36ca72edb --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program-organizers.repository.port.ts @@ -0,0 +1,29 @@ +/** @format */ +import { Observable } from "rxjs"; +import { + ProgramOrganizers, + OrganizerAccessRemoved, + OrganizerInvitation, + OrganizerRegistration, + OrganizerRegistered, + InvitationAccepted, +} from "../program-organizers.model"; +export abstract class ProgramOrganizersRepositoryPort { + abstract list(id: number): Observable; + abstract invite(id: number, email: string, revision: string): Observable; + abstract invitationAction( + id: number, + invitationId: number, + action: "resend" | "revoke", + revision: string, + ): Observable; + abstract remove( + id: number, + userId: number, + revision: string, + ): Observable; + abstract invitation(token: string): Observable; + abstract accept(token: string): Observable; + abstract register(token: string, data: OrganizerRegistration): Observable; + abstract resetPassword(token: string, email: string): Observable; +} diff --git a/projects/social_platform/src/app/domain/program/program-management.model.ts b/projects/social_platform/src/app/domain/program/program-management.model.ts index 6bdcf7773..20be97503 100644 --- a/projects/social_platform/src/app/domain/program/program-management.model.ts +++ b/projects/social_platform/src/app/domain/program/program-management.model.ts @@ -10,7 +10,13 @@ export type ManagementSection = | "analytics" | "settings" | "evaluation"; -export type SettingsSection = "basic" | "dates" | "materials" | "project-form" | "criteria"; +export type SettingsSection = + | "basic" + | "dates" + | "materials" + | "project-form" + | "criteria" + | "organizers"; export interface ManagementMaterial { id: number; title: string; diff --git a/projects/social_platform/src/app/domain/program/program-organizers.model.ts b/projects/social_platform/src/app/domain/program/program-organizers.model.ts new file mode 100644 index 000000000..cc0a40e54 --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-organizers.model.ts @@ -0,0 +1,39 @@ +/** @format */ +import { + ExpertInvitation, + ExpertRegistration, + ExpertRegistered, + InvitationAccepted, +} from "./evaluation-workspace.model"; +export interface OrganizerRow { + id: string; + userId?: number; + invitationId?: number; + name: string | null; + email: string; + avatar: string | null; + status: "joined" | "pending" | "expired"; + active?: boolean; + deliveryStatus?: "queued" | "sent" | "failed"; + isSelf: boolean; + canRemove: boolean; +} +export interface ProgramOrganizers { + revision: string; + organizers: OrganizerRow[]; + organizerCount: number; + activeOrganizerCount: number; + invitationCount: number; +} +export interface OrganizerAccessRemoved { + accessRemoved: true; + programId: number; +} +export interface OrganizerRegistration extends ExpertRegistration { + birthday?: string; +} +export type { + ExpertInvitation as OrganizerInvitation, + ExpertRegistered as OrganizerRegistered, + InvitationAccepted, +}; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-organizers-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-organizers-http.adapter.ts new file mode 100644 index 000000000..fa75916fb --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-organizers-http.adapter.ts @@ -0,0 +1,58 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { Observable } from "rxjs"; +import { ProgramOrganizersRepositoryPort } from "@domain/program/ports/program-organizers.repository.port"; +import { + ProgramOrganizers, + OrganizerAccessRemoved, + OrganizerInvitation, + OrganizerRegistration, + OrganizerRegistered, + InvitationAccepted, +} from "@domain/program/program-organizers.model"; +@Injectable({ providedIn: "root" }) +export class ProgramOrganizersHttpAdapter implements ProgramOrganizersRepositoryPort { + private readonly api = inject(ApiService); + private root(id: number) { + return `/programs/${id}/management`; + } + private invitationRoot(token: string) { + return `/programs/organizer-invitations/${encodeURIComponent(token)}`; + } + list(id: number): Observable { + return this.api.get(`${this.root(id)}/organizers/`); + } + invite(id: number, email: string, revision: string): Observable { + return this.api.post(`${this.root(id)}/organizers/`, { email, revision }); + } + invitationAction( + id: number, + invitationId: number, + action: "resend" | "revoke", + revision: string, + ): Observable { + return this.api.post(`${this.root(id)}/organizer-invitations/${invitationId}/${action}/`, { + revision, + }); + } + remove( + id: number, + userId: number, + revision: string, + ): Observable { + return this.api.post(`${this.root(id)}/organizers/${userId}/remove/`, { revision }); + } + invitation(token: string): Observable { + return this.api.get(`${this.invitationRoot(token)}/`); + } + accept(token: string): Observable { + return this.api.post(`${this.invitationRoot(token)}/accept/`, {}); + } + register(token: string, data: OrganizerRegistration): Observable { + return this.api.post(`${this.invitationRoot(token)}/register/`, data); + } + resetPassword(token: string, email: string): Observable { + return this.api.post("/auth/reset_password/", { email, organizerInvitationToken: token }); + } +} diff --git a/projects/social_platform/src/app/infrastructure/di/program/program-organizers.providers.ts b/projects/social_platform/src/app/infrastructure/di/program/program-organizers.providers.ts new file mode 100644 index 000000000..d862754df --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/di/program/program-organizers.providers.ts @@ -0,0 +1,7 @@ +/** @format */ +import { Provider } from "@angular/core"; +import { ProgramOrganizersRepositoryPort } from "@domain/program/ports/program-organizers.repository.port"; +import { ProgramOrganizersRepository } from "../../repository/program/program-organizers.repository"; +export const PROGRAM_ORGANIZERS_PROVIDERS: Provider[] = [ + { provide: ProgramOrganizersRepositoryPort, useExisting: ProgramOrganizersRepository }, +]; diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program-organizers.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program-organizers.repository.ts new file mode 100644 index 000000000..f46acadc1 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/repository/program/program-organizers.repository.ts @@ -0,0 +1,16 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ProgramOrganizersRepositoryPort } from "@domain/program/ports/program-organizers.repository.port"; +import { ProgramOrganizersHttpAdapter } from "../../adapters/program/program-organizers-http.adapter"; +@Injectable({ providedIn: "root" }) +export class ProgramOrganizersRepository implements ProgramOrganizersRepositoryPort { + private readonly adapter = inject(ProgramOrganizersHttpAdapter); + readonly list = this.adapter.list.bind(this.adapter); + readonly invite = this.adapter.invite.bind(this.adapter); + readonly invitationAction = this.adapter.invitationAction.bind(this.adapter); + readonly remove = this.adapter.remove.bind(this.adapter); + readonly invitation = this.adapter.invitation.bind(this.adapter); + readonly accept = this.adapter.accept.bind(this.adapter); + readonly register = this.adapter.register.bind(this.adapter); + readonly resetPassword = this.adapter.resetPassword.bind(this.adapter); +} diff --git a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html index 30fc80801..8a56b617d 100644 --- a/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html +++ b/projects/social_platform/src/app/ui/pages/evaluation/invitation/expert-invitation.component.html @@ -1,6 +1,6 @@ -
+
-

Приглашение эксперта

+

+ {{ isOrganizer ? "Приглашение организатора" : "Приглашение эксперта" }} +

{{ invite.program.name }}

@if (mode() === "joined") {

Вы присоединились к программе

-

Теперь вы эксперт программы. Организатор сможет назначить вам проекты на оценку.

-
Перейти к проектамМои программы +

+ {{ + isOrganizer + ? "Теперь вы можете управлять этой программой и приглашать коллег." + : "Теперь вы эксперт программы. Организатор сможет назначить вам проекты на оценку." + }} +

+ {{ isOrganizer ? "Открыть управление" : "Перейти к проектам" }} + @if (isOrganizer) { + На страницу программы + } @else { + Мои программы + } } @else if ( invite.status === "accepted" && mode() !== "login" && @@ -34,10 +52,16 @@

Вы присоединились к программе

mode() !== "sent" ) {

Приглашение уже принято

-

Войдите с почтой {{ invite.email }}, чтобы открыть свои программы.

+

+ Войдите с почтой {{ invite.email }}, чтобы + {{ isOrganizer ? "открыть управление программой." : "открыть свои программы." }} +

@if (invite.matchesAccount) { - Перейти к проектам{{ isOrganizer ? "Открыть управление" : "Перейти к проектам" }} } @else { } @else if (hasSession() && invite.matchesAccount) { -

Стать экспертом программы

+

{{ isOrganizer ? "Стать организатором программы" : "Стать экспертом программы" }}

Приглашение для {{ invite.email }}. Ваш аккаунт и другие роли сохранятся.

Вернуться к программам + >{{ isOrganizer ? "На страницу программы" : "Вернуться к программам" }} } @else { @switch (mode()) { @case ("register") { -

Создание аккаунта эксперта

+

+ {{ isOrganizer ? "Создание аккаунта организатора" : "Создание аккаунта эксперта" }} +

+ @if (isOrganizer) { + + }