From 68685b3b1dc1abb5775d1bfcdca138dbaf0715b8 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Tue, 20 Oct 2026 02:39:08 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/auth/facades/auth-password.service.ts | 8 + .../program-registration.use-case.ts | 15 + .../social_platform/src/app/app.routes.ts | 20 + .../program-registration.repository.port.ts | 28 ++ .../program/program-management.model.ts | 4 +- .../program/program-registration.model.ts | 54 +++ .../src/app/domain/program/program.model.ts | 1 + .../program-registration-http.adapter.spec.ts | 65 +++ .../program-registration-http.adapter.ts | 56 +++ .../university-select.component.html | 34 +- .../university-select.component.ts | 15 +- .../management/management.component.html | 48 +- .../management/management.component.scss | 6 + .../management/management.component.ts | 24 +- .../registration/registration.component.html | 403 ++++++++++++++++ .../registration/registration.component.scss | 267 +++++++++++ .../registration.component.spec.ts | 115 +++++ .../registration/registration.component.ts | 342 ++++++++++++++ .../public-registration.component.html | 242 ++++++++++ .../public-registration.component.scss | 200 ++++++++ .../public-registration.component.spec.ts | 160 +++++++ .../public-registration.component.ts | 430 ++++++++++++++++++ .../program/detail-program-info.service.ts | 1 + .../registration-fields.component.html | 112 +++++ .../registration-fields.component.scss | 77 ++++ .../registration-fields.component.spec.ts | 31 ++ .../registration-fields.component.ts | 101 ++++ 27 files changed, 2826 insertions(+), 33 deletions(-) create mode 100644 projects/social_platform/src/app/api/program/use-cases/program-registration.use-case.ts create mode 100644 projects/social_platform/src/app/domain/program/ports/program-registration.repository.port.ts create mode 100644 projects/social_platform/src/app/domain/program/program-registration.model.ts create mode 100644 projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts create mode 100644 projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/management/registration/registration.component.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.html create mode 100644 projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.scss create mode 100644 projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.ts create mode 100644 projects/social_platform/src/app/ui/widgets/registration-fields/registration-fields.component.html create mode 100644 projects/social_platform/src/app/ui/widgets/registration-fields/registration-fields.component.scss create mode 100644 projects/social_platform/src/app/ui/widgets/registration-fields/registration-fields.component.spec.ts create mode 100644 projects/social_platform/src/app/ui/widgets/registration-fields/registration-fields.component.ts 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 a6c807d1c..9ef9cb95d 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 registrationProgram = + this.route.snapshot.queryParamMap.get("registration_program"); + if (registrationProgram && /^[1-9]\d{0,9}$/.test(registrationProgram)) { + void this.router.navigate(["/program-registration", registrationProgram], { + queryParams: { mode: "login" }, + }); + return; + } const organizerInvitation = this.route.snapshot.queryParamMap.get("organizer_invitation"); if (organizerInvitation && /^[A-Za-z0-9_-]{20,100}$/.test(organizerInvitation)) { diff --git a/projects/social_platform/src/app/api/program/use-cases/program-registration.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/program-registration.use-case.ts new file mode 100644 index 000000000..2094d2d3f --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/program-registration.use-case.ts @@ -0,0 +1,15 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ProgramRegistrationRepositoryPort } from "@domain/program/ports/program-registration.repository.port"; +@Injectable() +export class ProgramRegistrationUseCase { + private readonly repository = inject(ProgramRegistrationRepositoryPort); + readonly settings = this.repository.settings.bind(this.repository); + readonly save = this.repository.save.bind(this.repository); + readonly importOptions = this.repository.importOptions.bind(this.repository); + readonly connect = this.repository.connect.bind(this.repository); + readonly publicForm = this.repository.publicForm.bind(this.repository); + readonly submit = this.repository.submit.bind(this.repository); + readonly tokenAction = this.repository.tokenAction.bind(this.repository); + readonly resetPassword = this.repository.resetPassword.bind(this.repository); +} diff --git a/projects/social_platform/src/app/app.routes.ts b/projects/social_platform/src/app/app.routes.ts index 07787109a..80498cf11 100644 --- a/projects/social_platform/src/app/app.routes.ts +++ b/projects/social_platform/src/app/app.routes.ts @@ -15,6 +15,26 @@ import { AuthRequiredGuard } from "@core/lib/guards/auth/auth-required.guard"; */ export const APP_ROUTES: Routes = [ + { + path: "program-registration/:id", + loadComponent: () => + import("./ui/pages/program/registration/public-registration.component").then( + c => c.PublicRegistrationComponent, + ), + }, + { + path: "program-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-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-registration.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program-registration.repository.port.ts new file mode 100644 index 000000000..8f3c83faf --- /dev/null +++ b/projects/social_platform/src/app/domain/program/ports/program-registration.repository.port.ts @@ -0,0 +1,28 @@ +/** @format */ +import { Observable } from "rxjs"; +import { + RegistrationSettings, + RegistrationSchema, + RegistrationPublic, + RegistrationImport, + RegistrationResult, +} from "../program-registration.model"; +export abstract class ProgramRegistrationRepositoryPort { + abstract settings(id: number): Observable; + abstract save( + id: number, + revision: number, + draft: RegistrationSchema, + publish: boolean, + ): Observable; + abstract importOptions(id: number, file: File): Observable; + abstract connect(id: number, url: string, comment: string): Observable; + abstract publicForm(id: number): Observable; + abstract submit(id: number, data: object, signup: boolean): Observable; + abstract tokenAction( + id: number, + action: "confirm" | "resend", + token: string, + ): Observable; + abstract resetPassword(id: number, 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 9e625365a..14ce36dbc 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 @@ -16,7 +16,8 @@ export type SettingsSection = | "materials" | "project-form" | "criteria" - | "organizers"; + | "organizers" + | "registration"; export interface ManagementMaterial { id: number; title: string; @@ -60,6 +61,7 @@ export interface ManagementParticipant { region?: string | null; registeredAt: string; lastActivity?: string | null; + registrationAnswers?: { label: string; value: string | string[] | number | null }[]; } export type ManagementEvaluationStatus = | "not_submitted" diff --git a/projects/social_platform/src/app/domain/program/program-registration.model.ts b/projects/social_platform/src/app/domain/program/program-registration.model.ts new file mode 100644 index 000000000..216b7b4ab --- /dev/null +++ b/projects/social_platform/src/app/domain/program/program-registration.model.ts @@ -0,0 +1,54 @@ +/** @format */ +export type RegistrationValue = string | string[] | number | null; +export interface RegistrationField { + id: string; + label: string; + type: "text" | "textarea" | "number" | "date" | "select" | "multiselect"; + helpText: string; + required: boolean; + source: "own" | "universities" | "regions"; + options: string[]; + allowOther: boolean; +} +export interface RegistrationSchema { + mode: "native" | "external"; + fields: RegistrationField[]; +} +export interface RegistrationSettings { + draft: RegistrationSchema; + published: RegistrationSchema | null; + revision: number; + publishedVersion: number; + publishedAt: string | null; + link: string | null; + externalStatus: "not_connected" | "requested" | "connected"; + externalUrl: string; + connectionComment: string; + answeredFieldIds: string[]; +} +export interface RegistrationPublic { + id: number; + name: string; + version: number; + fields: RegistrationField[]; + closed: boolean; + authenticated: boolean; + enrolled: boolean; + programUrl: string; + registrationEnds: string; +} +export interface RegistrationImport { + options: string[]; + duplicates: number; + blanks: number; +} +export interface RegistrationResult { + reviewRequired?: boolean; + answers?: Record; + enrolled?: boolean; + programId?: number; + receipt?: string; + email?: string; + access?: string; + refresh?: string; +} diff --git a/projects/social_platform/src/app/domain/program/program.model.ts b/projects/social_platform/src/app/domain/program/program.model.ts index a98012cfb..d032496b6 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -24,6 +24,7 @@ export class Program { year!: number; links!: string[]; registrationLink!: string | null; + registrationManaged?: boolean; materials!: { title: string; url: string }[]; shortDescription!: string; datetimeRegistrationEnds!: string; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts new file mode 100644 index 000000000..2a29c3ede --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts @@ -0,0 +1,65 @@ +/** @format */ +import { TestBed } from "@angular/core/testing"; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { API_URL } from "@core/lib/providers"; +import { ProgramRegistrationHttpAdapter } from "./program-registration-http.adapter"; +describe("Контракт регистрации программы", () => { + let adapter: ProgramRegistrationHttpAdapter, http: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ProgramRegistrationHttpAdapter, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "https://local.test" }, + { provide: LoggerService, useValue: { warn: vi.fn() } }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + ], + }); + adapter = TestBed.inject(ProgramRegistrationHttpAdapter); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it("не меняет динамические идентификаторы ответов при snake/camel преобразовании", () => { + const key = "f0123456789abcdef0123456789abcdef"; + adapter + .submit(4, { version: 7, firstName: "Анна", answers: { [key]: "Вуз" } }, true) + .subscribe(); + const request = http.expectOne("https://local.test/programs/4/registration/signup/"); + expect(request.request.body).toEqual({ + version: 7, + first_name: "Анна", + answers: { [key]: "Вуз" }, + }); + request.flush({ receipt: "opaque", email_pending: true }); + }); + it("публикует явную ревизию и не использует endpoint проекта", () => { + adapter + .save(4, 2, { mode: "native", fields: [] }, true) + .subscribe(result => expect(result.publishedVersion).toBe(3)); + const request = http.expectOne( + "https://local.test/programs/4/management/registration/publish/", + ); + expect(request.request.method).toBe("POST"); + expect(request.request.body.revision).toBe(2); + request.flush({ published_version: 3 }); + }); + it("импорт передает бинарный файл без JSON преобразования", () => { + const file = new File(["Вуз"], "universities.csv"); + adapter.importOptions(4, file).subscribe(); + const request = http.expectOne("https://local.test/programs/4/management/registration/import/"); + expect(request.request.body).toBeInstanceOf(FormData); + expect(request.request.body.get("file")).toBe(file); + request.flush({ options: ["Вуз"], duplicates: 0, blanks: 0 }); + }); + it("подтверждает почту POST-запросом, не передает токены входа в URL", () => { + adapter.tokenAction(4, "confirm", "email-proof").subscribe(); + const request = http.expectOne("https://local.test/programs/4/registration/confirm/"); + expect(request.request.method).toBe("POST"); + expect(request.request.body).toEqual({ token: "email-proof" }); + request.flush({ enrolled: true }); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.ts new file mode 100644 index 000000000..84722469e --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.ts @@ -0,0 +1,56 @@ +/** @format */ +import { inject, Injectable } from "@angular/core"; +import { ApiService } from "@corelib"; +import { ProgramRegistrationRepositoryPort } from "@domain/program/ports/program-registration.repository.port"; +import { + RegistrationSettings, + RegistrationSchema, + RegistrationPublic, + RegistrationImport, + RegistrationResult, +} from "@domain/program/program-registration.model"; +@Injectable() +export class ProgramRegistrationHttpAdapter implements ProgramRegistrationRepositoryPort { + private readonly api = inject(ApiService); + private root(id: number) { + return `/programs/${id}`; + } + settings(id: number) { + return this.api.get(`${this.root(id)}/management/registration/`); + } + save(id: number, revision: number, draft: RegistrationSchema, publish: boolean) { + const path = `${this.root(id)}/management/registration/`; + return publish + ? this.api.post(`${path}publish/`, { revision, draft }) + : this.api.put(path, { revision, draft }); + } + importOptions(id: number, file: File) { + const data = new FormData(); + data.append("file", file); + return this.api.post( + `${this.root(id)}/management/registration/import/`, + data, + ); + } + connect(id: number, url: string, comment: string) { + return this.api.post( + `${this.root(id)}/management/registration/connection/`, + { url, comment }, + ); + } + publicForm(id: number) { + return this.api.get(`${this.root(id)}/registration/`); + } + submit(id: number, data: object, signup: boolean) { + return this.api.post( + `${this.root(id)}/registration/${signup ? "signup/" : ""}`, + data, + ); + } + tokenAction(id: number, action: "confirm" | "resend", token: string) { + return this.api.post(`${this.root(id)}/registration/${action}/`, { token }); + } + resetPassword(id: number, email: string) { + return this.api.post("/auth/reset_password/", { email, registrationProgramId: id }); + } +} diff --git a/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html index db80b865e..57c197229 100644 --- a/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html +++ b/projects/social_platform/src/app/ui/pages/profile/edit/components/university-select/university-select.component.html @@ -53,9 +53,15 @@ @if (loading()) { Поиск вузов… } @else if (failed()) { - Не удалось загрузить список. Попробуйте снова или выберите «Другое». + Не удалось загрузить список. Попробуйте снова{{ + allowOther() ? " или выберите «Другое»." : "." + }} } @else if (!universities().length) { - Совпадений нет. Выберите «Другое», чтобы указать название. + {{ + allowOther() + ? "Совпадений нет. Выберите «Другое», чтобы указать название." + : "Совпадений нет. Попробуйте другое название или сокращение." + }} }
@@ -75,17 +81,19 @@ }
} -
- Другое -
+ @if (allowOther()) { +
+ Другое +
+ } @if (universities().length < total()) { + } @else { + {{ person.name }} + } + {{ person.email || "Не указана" }} {{ person.region || "—" }} @@ -512,6 +525,12 @@

{{ evaluation.name }}

(statusChange)="organizersStatus.set($event)" (accessRemoved)="organizerAccessRemoved()" /> + } @else if (settingsTab() === "registration") { + } @else if (settingsTab() === "basic") {

Основная информация

@@ -586,13 +605,7 @@

Изображения

/> } } -