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
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 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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
20 changes: 20 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,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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RegistrationSettings>;
abstract save(
id: number,
revision: number,
draft: RegistrationSchema,
publish: boolean,
): Observable<RegistrationSettings>;
abstract importOptions(id: number, file: File): Observable<RegistrationImport>;
abstract connect(id: number, url: string, comment: string): Observable<RegistrationSettings>;
abstract publicForm(id: number): Observable<RegistrationPublic>;
abstract submit(id: number, data: object, signup: boolean): Observable<RegistrationResult>;
abstract tokenAction(
id: number,
action: "confirm" | "resend",
token: string,
): Observable<RegistrationResult>;
abstract resetPassword(id: number, email: string): Observable<void>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type SettingsSection =
| "materials"
| "project-form"
| "criteria"
| "organizers";
| "organizers"
| "registration";
export interface ManagementMaterial {
id: number;
title: string;
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, RegistrationValue>;
enrolled?: boolean;
programId?: number;
receipt?: string;
email?: string;
access?: string;
refresh?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Original file line number Diff line number Diff line change
@@ -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<RegistrationSettings>(`${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<RegistrationSettings>(`${path}publish/`, { revision, draft })
: this.api.put<RegistrationSettings>(path, { revision, draft });
}
importOptions(id: number, file: File) {
const data = new FormData();
data.append("file", file);
return this.api.post<RegistrationImport>(
`${this.root(id)}/management/registration/import/`,
data,
);
}
connect(id: number, url: string, comment: string) {
return this.api.post<RegistrationSettings>(
`${this.root(id)}/management/registration/connection/`,
{ url, comment },
);
}
publicForm(id: number) {
return this.api.get<RegistrationPublic>(`${this.root(id)}/registration/`);
}
submit(id: number, data: object, signup: boolean) {
return this.api.post<RegistrationResult>(
`${this.root(id)}/registration/${signup ? "signup/" : ""}`,
data,
);
}
tokenAction(id: number, action: "confirm" | "resend", token: string) {
return this.api.post<RegistrationResult>(`${this.root(id)}/registration/${action}/`, { token });
}
resetPassword(id: number, email: string) {
return this.api.post<void>("/auth/reset_password/", { email, registrationProgramId: id });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,15 @@
@if (loading()) {
Поиск вузов…
} @else if (failed()) {
Не удалось загрузить список. Попробуйте снова или выберите «Другое».
Не удалось загрузить список. Попробуйте снова{{
allowOther() ? " или выберите «Другое»." : "."
}}
} @else if (!universities().length) {
Совпадений нет. Выберите «Другое», чтобы указать название.
{{
allowOther()
? "Совпадений нет. Выберите «Другое», чтобы указать название."
: "Совпадений нет. Попробуйте другое название или сокращение."
}}
}
</div>
<div [id]="inputId() + '-list'" role="listbox" aria-label="Учебные учреждения">
Expand All @@ -75,17 +81,19 @@
}
</div>
}
<div
[id]="inputId() + '-option-' + universities().length"
role="option"
class="university__option university__option--other"
[class.university__option--active]="activeIndex() === universities().length"
aria-selected="false"
(mousedown)="$event.preventDefault()"
(click)="selectOther()"
>
Другое
</div>
@if (allowOther()) {
<div
[id]="inputId() + '-option-' + universities().length"
role="option"
class="university__option university__option--other"
[class.university__option--active]="activeIndex() === universities().length"
aria-selected="false"
(mousedown)="$event.preventDefault()"
(click)="selectOther()"
>
Другое
</div>
}
</div>
@if (universities().length < total()) {
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { catchError, map, of, Subject, switchMap, timer } from "rxjs";
})
export class UniversitySelectComponent implements ControlValueAccessor {
readonly error = input(false);
readonly allowOther = input(true);
readonly prefixOther = input(false);
readonly inputId = input("organizationName");
readonly inputElement = viewChild<ElementRef<HTMLInputElement>>("nameInput");
readonly text = signal("");
Expand Down Expand Up @@ -80,8 +82,9 @@ export class UniversitySelectComponent implements ControlValueAccessor {
}

writeValue(value: string | null): void {
this.text.set(value ?? "");
this.custom.set(false);
const custom = this.prefixOther() && !!value?.startsWith("Другое: ");
this.text.set(custom ? value!.slice(8) : (value ?? ""));
this.custom.set(custom);
this.close();
}

Expand All @@ -101,7 +104,7 @@ export class UniversitySelectComponent implements ControlValueAccessor {
onInput(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.text.set(value);
this.onChange(this.custom() ? value.trim() : "");
this.onChange(this.custom() ? (this.prefixOther() ? "Другое: " : "") + value.trim() : "");
if (!this.custom()) this.search(value, 200);
}

Expand Down Expand Up @@ -141,8 +144,9 @@ export class UniversitySelectComponent implements ControlValueAccessor {
}

selectOther(): void {
if (!this.allowOther()) return;
this.custom.set(true);
this.onChange(this.text().trim());
this.onChange((this.prefixOther() ? "Другое: " : "") + this.text().trim());
this.onTouched();
this.close();
this.inputElement()?.nativeElement.focus();
Expand All @@ -168,7 +172,8 @@ export class UniversitySelectComponent implements ControlValueAccessor {
if (this.custom()) return;
event.preventDefault();
if (!this.open()) this.onFocus();
const count = this.universities().length + 1; // Includes Other.
const count = this.universities().length + (this.allowOther() ? 1 : 0);
if (!count) return;
this.activeIndex.update(index => {
if (index < 0) return event.key === "ArrowDown" ? 0 : count - 1;
return (index + (event.key === "ArrowDown" ? 1 : -1) + count) % count;
Expand Down
Loading