Skip to content

Commit dbf718a

Browse files
committed
refactor courses to clean architecture: domain models, repository port, use-cases, facades with AsyncState as single source of truth, UI info services with computed signals; fix duplicate requests, add loaders & isAvailable guard in resolver
1 parent 6f77105 commit dbf718a

41 files changed

Lines changed: 929 additions & 664 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

projects/core/src/lib/interceptors/camelcase.interceptor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class CamelcaseInterceptor implements HttpInterceptor {
4747
let req: HttpRequest<Record<string, any>>;
4848

4949
// Обрабатываем тело запроса если оно существует
50-
if (request.body) {
50+
if (request.body && !(request.body instanceof FormData)) {
5151
// Клонируем запрос с преобразованным телом (camelCase → snake_case)
5252
req = request.clone({
5353
body: snakecaseKeys(request.body, {

projects/core/src/lib/services/file/file.service.ts

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,78 +3,36 @@
33
import { Injectable } from "@angular/core";
44
import { Observable } from "rxjs";
55
import { HttpParams } from "@angular/common/http";
6-
import { environment } from "@environment";
76
import { ApiService } from "../api/api.service";
8-
import { TokenService } from "../tokens/token.service";
97

108
/**
119
* Сервис для работы с файлами
1210
* Предоставляет методы для загрузки и удаления файлов через API
13-
* Использует авторизацию через Bearer токен
11+
* Использует ApiService + HttpClient, поэтому авторизация, retry и camelCase
12+
* обрабатываются интерсепторами автоматически
1413
*/
1514
@Injectable({
1615
providedIn: "root",
1716
})
1817
export class FileService {
1918
private readonly FILES_URL = "/files";
2019

21-
constructor(private readonly tokenService: TokenService, private apiService: ApiService) {}
20+
constructor(private readonly apiService: ApiService) {}
2221

2322
/**
2423
* Загружает файл на сервер
2524
*
2625
* @param file - объект File для загрузки
2726
* @returns Observable<{ url: string }> - Observable с URL загруженного файла
2827
*
29-
* Использует нативный fetch API вместо HttpClient для поддержки FormData
30-
* Автоматически добавляет Authorization header с Bearer токеном
28+
* Использует HttpClient через ApiService — интерсепторы (bearer token, retry, logging)
29+
* работают автоматически. CamelcaseInterceptor пропускает FormData без преобразования.
3130
*/
3231
uploadFile(file: File): Observable<{ url: string }> {
3332
const formData = new FormData();
3433
formData.append("file", file);
3534

36-
return new Observable<{ url: string }>(observer => {
37-
const doFetch = (token: string) =>
38-
fetch(`${environment.apiUrl}${this.FILES_URL}/`, {
39-
method: "POST",
40-
headers: { Authorization: `Bearer ${token}` },
41-
body: formData,
42-
});
43-
44-
const token = this.tokenService.getTokens()?.access;
45-
if (!token) {
46-
observer.error(new Error("No access token"));
47-
return;
48-
}
49-
50-
doFetch(token)
51-
.then(res => {
52-
if (res.status === 401) {
53-
this.tokenService.refreshTokens().subscribe({
54-
next: newTokens => {
55-
this.tokenService.memTokens(newTokens);
56-
doFetch(newTokens.access)
57-
.then(r => r.json())
58-
.then(data => {
59-
observer.next(data);
60-
observer.complete();
61-
})
62-
.catch(err => observer.error(err));
63-
},
64-
error: err => observer.error(err),
65-
});
66-
return;
67-
}
68-
return res.json();
69-
})
70-
.then(res => {
71-
if (res) {
72-
observer.next(res);
73-
observer.complete();
74-
}
75-
})
76-
.catch(err => observer.error(err));
77-
});
35+
return this.apiService.post<{ url: string }>(`${this.FILES_URL}/`, formData);
7836
}
7937

8038
/**
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/** @format */
2+
3+
import { inject, Injectable } from "@angular/core";
4+
import { ActivatedRoute, NavigationEnd, Router } from "@angular/router";
5+
import { filter, map, Subject, takeUntil } from "rxjs";
6+
import { CourseDetail, CourseStructure } from "@domain/courses/courses.model";
7+
import { CourseDetailUIInfoService } from "./ui/course-detail-ui-info.service";
8+
import { loading, success } from "@domain/shared/async-state";
9+
10+
@Injectable()
11+
export class CourseDetailInfoService {
12+
private readonly route = inject(ActivatedRoute);
13+
private readonly router = inject(Router);
14+
private readonly courseDetailUIInfoService = inject(CourseDetailUIInfoService);
15+
16+
private readonly destroy$ = new Subject<void>();
17+
18+
init(): void {
19+
this.loadCourseData();
20+
this.trackNavigation();
21+
}
22+
23+
destroy(): void {
24+
this.destroy$.next();
25+
this.destroy$.complete();
26+
}
27+
28+
redirectDetailInfo(courseId?: number): void {
29+
if (courseId != null) {
30+
this.router.navigateByUrl(`/office/courses/${courseId}`);
31+
} else {
32+
this.router.navigateByUrl("/office/courses/all");
33+
}
34+
}
35+
36+
redirectToProgram(): void {
37+
const course = this.courseDetailUIInfoService.course();
38+
if (!course) return;
39+
40+
this.router.navigate([`/office/program/${course.partnerProgramId}`], {
41+
queryParams: { courseId: course.id },
42+
});
43+
}
44+
45+
private loadCourseData(): void {
46+
this.courseDetailUIInfoService.courseDetail$.set(loading());
47+
this.courseDetailUIInfoService.courseStructure$.set(loading());
48+
49+
this.route.data
50+
.pipe(
51+
map(data => data["data"]),
52+
filter(data => !!data),
53+
takeUntil(this.destroy$)
54+
)
55+
.subscribe(([detail, structure]: [CourseDetail | null, CourseStructure | null]) => {
56+
if (!detail || !structure) return;
57+
58+
this.courseDetailUIInfoService.courseDetail$.set(success(detail));
59+
this.courseDetailUIInfoService.courseStructure$.set(success(structure));
60+
this.courseDetailUIInfoService.applyCourseData(structure);
61+
});
62+
}
63+
64+
private trackNavigation(): void {
65+
this.courseDetailUIInfoService.isTaskDetail.set(this.router.url.includes("lesson"));
66+
67+
this.router.events
68+
.pipe(
69+
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
70+
takeUntil(this.destroy$)
71+
)
72+
.subscribe(() => {
73+
this.courseDetailUIInfoService.isTaskDetail.set(this.router.url.includes("lesson"));
74+
});
75+
}
76+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** @format */
2+
3+
import { inject, Injectable } from "@angular/core";
4+
import { ActivatedRoute } from "@angular/router";
5+
import { map, Subject, takeUntil } from "rxjs";
6+
import { CoursesListUIInfoService } from "./ui/courses-list-ui-info.service";
7+
import { loading, success } from "@domain/shared/async-state";
8+
9+
@Injectable()
10+
export class CoursesListInfoService {
11+
private readonly route = inject(ActivatedRoute);
12+
private readonly coursesListUIInfoService = inject(CoursesListUIInfoService);
13+
14+
private readonly destroy$ = new Subject<void>();
15+
16+
init(): void {
17+
this.coursesListUIInfoService.courses$.set(loading());
18+
19+
this.route.data
20+
.pipe(
21+
map(r => r["data"]),
22+
takeUntil(this.destroy$)
23+
)
24+
.subscribe(courses => {
25+
this.coursesListUIInfoService.courses$.set(success(courses));
26+
});
27+
}
28+
29+
destroy(): void {
30+
this.destroy$.next();
31+
this.destroy$.complete();
32+
}
33+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/** @format */
2+
3+
import { inject, Injectable } from "@angular/core";
4+
import { ActivatedRoute, NavigationEnd, Router } from "@angular/router";
5+
import { filter, map, Subject, takeUntil } from "rxjs";
6+
import { CourseLesson, Task } from "@domain/courses/courses.model";
7+
import { LessonUIInfoService } from "./ui/lesson-ui-info.service";
8+
import { SubmitTaskAnswerUseCase } from "../use-cases/submit-task-answer.use-case";
9+
import { SnackbarService } from "@ui/services/snackbar/snackbar.service";
10+
import { failure, loading, success } from "@domain/shared/async-state";
11+
12+
@Injectable()
13+
export class LessonInfoService {
14+
private readonly router = inject(Router);
15+
private readonly route = inject(ActivatedRoute);
16+
private readonly submitTaskAnswerUseCase = inject(SubmitTaskAnswerUseCase);
17+
private readonly snackbarService = inject(SnackbarService);
18+
private readonly lessonUIInfoService = inject(LessonUIInfoService);
19+
20+
private readonly destroy$ = new Subject<void>();
21+
22+
init(): void {
23+
this.loadLessonData();
24+
this.trackNavigation();
25+
}
26+
27+
destroy(): void {
28+
this.destroy$.next();
29+
this.destroy$.complete();
30+
}
31+
32+
onAnswerChange(value: any): void {
33+
this.lessonUIInfoService.answerBody.set(value);
34+
}
35+
36+
onSubmitAnswer(): void {
37+
const task = this.lessonUIInfoService.currentTask();
38+
if (!task) return;
39+
40+
this.lessonUIInfoService.loader.set(true);
41+
this.lessonUIInfoService.submitAnswer$.set(loading());
42+
43+
const body = this.lessonUIInfoService.answerBody();
44+
const isTextFile = task.answerType === "text_and_files";
45+
const answerText = task.answerType === "text" || isTextFile ? body?.text : undefined;
46+
const optionIds =
47+
task.answerType === "single_choice" || task.answerType === "multiple_choice"
48+
? body
49+
: undefined;
50+
const fileIds = task.answerType === "files" ? body : isTextFile ? body?.fileUrls : undefined;
51+
52+
this.submitTaskAnswerUseCase
53+
.execute(task.id, answerText, optionIds, fileIds)
54+
.pipe(takeUntil(this.destroy$))
55+
.subscribe({
56+
next: result => {
57+
this.lessonUIInfoService.loader.set(false);
58+
59+
if (!result.ok) {
60+
this.lessonUIInfoService.hasError.set(true);
61+
this.lessonUIInfoService.submitAnswer$.set(failure("submit_error"));
62+
this.snackbarService.error("неверный ответ, попробуйте еще раз!");
63+
return;
64+
}
65+
66+
const res = result.value;
67+
this.lessonUIInfoService.submitAnswer$.set(success(undefined));
68+
69+
if (res.isCorrect) {
70+
this.lessonUIInfoService.success.set(true);
71+
this.lessonUIInfoService.hasError.set(false);
72+
this.lessonUIInfoService.markTaskCompleted(task.id);
73+
this.snackbarService.success("правильный ответ, продолжайте дальше");
74+
} else {
75+
this.lessonUIInfoService.hasError.set(true);
76+
this.lessonUIInfoService.success.set(false);
77+
this.snackbarService.error("неверный ответ, попробуйте еще раз!");
78+
setTimeout(() => this.lessonUIInfoService.hasError.set(false), 1000);
79+
return;
80+
}
81+
82+
if (!res.canContinue) return;
83+
84+
setTimeout(() => {
85+
const nextId = res.nextTaskId ?? this.getNextTask()?.id ?? null;
86+
87+
if (nextId) {
88+
this.lessonUIInfoService.currentTaskId.set(nextId);
89+
this.lessonUIInfoService.success.set(false);
90+
this.lessonUIInfoService.answerBody.set(null);
91+
} else {
92+
this.router.navigate(["results"], { relativeTo: this.route });
93+
}
94+
}, 1000);
95+
},
96+
error: () => {
97+
this.lessonUIInfoService.loader.set(false);
98+
this.lessonUIInfoService.hasError.set(true);
99+
this.lessonUIInfoService.submitAnswer$.set(failure("submit_error"));
100+
this.snackbarService.error("неверный ответ, попробуйте еще раз!");
101+
},
102+
});
103+
}
104+
105+
private loadLessonData(): void {
106+
this.route.data
107+
.pipe(
108+
map(data => data["data"] as CourseLesson | null),
109+
filter((lessonInfo): lessonInfo is CourseLesson => !!lessonInfo),
110+
takeUntil(this.destroy$)
111+
)
112+
.subscribe({
113+
next: lessonInfo => {
114+
this.lessonUIInfoService.loading.set(true);
115+
this.lessonUIInfoService.lesson$.set(success(lessonInfo));
116+
117+
if (lessonInfo.progressStatus === "completed") {
118+
setTimeout(() => {
119+
this.lessonUIInfoService.loading.set(false);
120+
this.router.navigate(["results"], { relativeTo: this.route });
121+
}, 500);
122+
return;
123+
}
124+
125+
const nextTaskId =
126+
lessonInfo.currentTaskId ??
127+
lessonInfo.tasks.find(t => t.isAvailable && !t.isCompleted)?.id ??
128+
null;
129+
130+
const allCompleted = lessonInfo.tasks.every(t => t.isCompleted);
131+
const onResultsPage = this.router.url.includes("results");
132+
133+
if (onResultsPage && !allCompleted) {
134+
this.lessonUIInfoService.currentTaskId.set(nextTaskId);
135+
setTimeout(() => {
136+
this.lessonUIInfoService.loading.set(false);
137+
this.router.navigate(["./"], { relativeTo: this.route });
138+
}, 500);
139+
} else if (nextTaskId === null && allCompleted) {
140+
setTimeout(() => {
141+
this.lessonUIInfoService.loading.set(false);
142+
this.router.navigate(["results"], { relativeTo: this.route });
143+
}, 500);
144+
} else {
145+
this.lessonUIInfoService.currentTaskId.set(nextTaskId);
146+
setTimeout(() => this.lessonUIInfoService.loading.set(false), 500);
147+
}
148+
},
149+
complete: () => {
150+
setTimeout(() => this.lessonUIInfoService.loading.set(false), 500);
151+
},
152+
});
153+
}
154+
155+
private trackNavigation(): void {
156+
this.lessonUIInfoService.isComplete.set(this.router.url.includes("results"));
157+
158+
this.router.events
159+
.pipe(
160+
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
161+
takeUntil(this.destroy$)
162+
)
163+
.subscribe(() => {
164+
this.lessonUIInfoService.isComplete.set(this.router.url.includes("results"));
165+
});
166+
}
167+
168+
private getNextTask(): Task | null {
169+
const currentId = this.lessonUIInfoService.currentTaskId();
170+
const allTasks = this.lessonUIInfoService.tasks();
171+
const currentIndex = allTasks.findIndex(t => t.id === currentId);
172+
return allTasks.slice(currentIndex + 1).find(t => t.isAvailable && !t.isCompleted) ?? null;
173+
}
174+
}

0 commit comments

Comments
 (0)