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
29 changes: 29 additions & 0 deletions docs/modules/program-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; старый ответ списка не заменяет новые данные.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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], {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProgramUpdated>({
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)));
}
}
2 changes: 2 additions & 0 deletions projects/social_platform/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -97,6 +98,7 @@ export const APP_CONFIG: ApplicationConfig = {
provideAnimations(),
...AUTH_PROVIDERS,
...EVALUATION_PROVIDERS,
...PROGRAM_ORGANIZERS_PROVIDERS,
...FEED_PROVIDERS,
...INDUSTRY_PROVIDERS,
...INVITE_PROVIDERS,
Expand Down
21 changes: 21 additions & 0 deletions projects/social_platform/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProgramOrganizers>;
abstract invite(id: number, email: string, revision: string): Observable<ProgramOrganizers>;
abstract invitationAction(
id: number,
invitationId: number,
action: "resend" | "revoke",
revision: string,
): Observable<ProgramOrganizers>;
abstract remove(
id: number,
userId: number,
revision: string,
): Observable<ProgramOrganizers | OrganizerAccessRemoved>;
abstract invitation(token: string): Observable<OrganizerInvitation>;
abstract accept(token: string): Observable<InvitationAccepted>;
abstract register(token: string, data: OrganizerRegistration): Observable<OrganizerRegistered>;
abstract resetPassword(token: string, email: string): Observable<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -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<ProgramOrganizers> {
return this.api.get(`${this.root(id)}/organizers/`);
}
invite(id: number, email: string, revision: string): Observable<ProgramOrganizers> {
return this.api.post(`${this.root(id)}/organizers/`, { email, revision });
}
invitationAction(
id: number,
invitationId: number,
action: "resend" | "revoke",
revision: string,
): Observable<ProgramOrganizers> {
return this.api.post(`${this.root(id)}/organizer-invitations/${invitationId}/${action}/`, {
revision,
});
}
remove(
id: number,
userId: number,
revision: string,
): Observable<ProgramOrganizers | OrganizerAccessRemoved> {
return this.api.post(`${this.root(id)}/organizers/${userId}/remove/`, { revision });
}
invitation(token: string): Observable<OrganizerInvitation> {
return this.api.get(`${this.invitationRoot(token)}/`);
}
accept(token: string): Observable<InvitationAccepted> {
return this.api.post(`${this.invitationRoot(token)}/accept/`, {});
}
register(token: string, data: OrganizerRegistration): Observable<OrganizerRegistered> {
return this.api.post(`${this.invitationRoot(token)}/register/`, data);
}
resetPassword(token: string, email: string): Observable<void> {
return this.api.post("/auth/reset_password/", { email, organizerInvitationToken: token });
}
}
Original file line number Diff line number Diff line change
@@ -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 },
];
Original file line number Diff line number Diff line change
@@ -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);
}
Loading