Skip to content

Commit 7fd81a2

Browse files
committed
fix: prevent duplicate news creation on POST retry + disable form while in-flight
ApiService.post больше не повторяет запрос (retry оставлен только на идемпотентных GET/PUT/PATCH/DELETE): повтор неидемпотентного POST на 5xx/обрыве плодил дубликаты, напр. вторую новость по /programs/<id>/news/. NewsFormComponent получает pending-инпут: на время создания новости форма, кнопка отправки и input файла блокируются, onSubmit защищён guard'ом. Программа/проект/профиль выставляют pending через finalize (успех, ошибка, отписка), что важно для проектного фасада с filter(result.ok).
1 parent 667fa3f commit 7fd81a2

9 files changed

Lines changed: 65 additions & 18 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,8 @@ export class ApiService {
9898
* apiService.post<User>('/users', { name: 'John', email: 'john@example.com' })
9999
*/
100100
post<T>(path: string, body: object): Observable<T> {
101-
return this.http
102-
.post<T>(this.apiUrl + path, body)
103-
.pipe(retry(exponentialBackoff(this.RETRY_COUNT)), first()) as Observable<T>;
101+
// Без retry: POST неидемпотентен, повтор на 5xx/обрыве плодит дубликаты ресурсов.
102+
return this.http.post<T>(this.apiUrl + path, body).pipe(first()) as Observable<T>;
104103
}
105104

106105
/**

projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ <h3 class="text-body-12 about__title">обо мне</h3>
5454
} @else {
5555
<div class="profile__news news">
5656
@if (loggedUserId() === user()!.id) {
57-
<app-news-form class="news__form" (addNews)="onAddNews($event)"></app-news-form>
57+
<app-news-form
58+
class="news__form"
59+
[pending]="newsPending()"
60+
(addNews)="onAddNews($event)"
61+
></app-news-form>
5862
}
5963
<ul>
6064
@for (n of news(); track n.id) {

projects/social_platform/src/app/ui/pages/profile/detail/main/components/profile-mid-side/profile-mid-side.component.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import {
55
ChangeDetectionStrategy,
66
Component,
77
DestroyRef,
8-
ElementRef,
98
inject,
109
input,
10+
signal,
1111
viewChild,
1212
} from "@angular/core";
1313
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
14+
import { finalize } from "rxjs";
1415
import { RouterModule } from "@angular/router";
1516
import { ParseBreaksPipe, ParseLinksPipe } from "@corelib";
1617
import { IconComponent, ButtonComponent } from "@ui/primitives";
@@ -60,6 +61,8 @@ export class ProfileMidSideComponent {
6061
protected readonly directions = this.profileDetailUIInfoService.directions;
6162
protected readonly news = this.newsInfoService.news;
6263

64+
protected readonly newsPending = signal(false);
65+
6366
protected readonly descriptionExpandable = this.expandService.descriptionExpandable;
6467
protected readonly readFullDescription = this.expandService.readFullDescription;
6568

@@ -68,9 +71,13 @@ export class ProfileMidSideComponent {
6871
protected readonly AppRoutes = AppRoutes;
6972

7073
onAddNews(news: { text: string; files: string[] }): void {
74+
this.newsPending.set(true);
7175
this.profileDetailInfoService
7276
.onAddNews(news)
73-
.pipe(takeUntilDestroyed(this.destroyRef$))
77+
.pipe(
78+
finalize(() => this.newsPending.set(false)),
79+
takeUntilDestroyed(this.destroyRef$),
80+
)
7481
.subscribe({
7582
next: () => this.newsFormComponent()?.onResetForm(),
7683
});

projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ <h3 class="text-body-12 about__title">о программе</h3>
5050
</div>
5151
<div class="news">
5252
@if (program()!.isUserManager) {
53-
<app-news-form class="news__form" (addNews)="onAddNews($event)"></app-news-form>
53+
<app-news-form
54+
class="news__form"
55+
[pending]="newsPending()"
56+
(addNews)="onAddNews($event)"
57+
></app-news-form>
5458
}
5559
@for (n of news(); track n.id) {
5660
<app-news-card

projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
inject,
1010
OnDestroy,
1111
OnInit,
12+
signal,
1213
viewChild,
13-
ViewChild,
1414
} from "@angular/core";
1515
import { isFailure } from "@domain/shared/async-state";
1616
import { RouterModule } from "@angular/router";
@@ -29,6 +29,7 @@ import { NewsInfoService } from "@api/news/news-info.service";
2929
import { ProjectAdditionalService } from "@api/project/facades/edit/project-additional.service";
3030
import { AppRoutes } from "@api/paths/app-routes";
3131
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
32+
import { finalize } from "rxjs";
3233
import { ProgramLinksComponent } from "@ui/widgets/program-links/program-links.component";
3334

3435
/** Страница основной вкладки программы с описанием, ссылками и новостями. */
@@ -79,6 +80,8 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy {
7980
protected readonly program = this.programDetailMainUIInfoService.program;
8081
protected readonly news = this.newsInfoService.news;
8182

83+
protected readonly newsPending = signal(false);
84+
8285
protected readonly AppRoutes = AppRoutes;
8386

8487
protected readonly showProgramModal = this.programDetailMainUIInfoService.showProgramModal;
@@ -115,9 +118,13 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy {
115118
}
116119

117120
onAddNews(news: { text: string; files: string[] }): void {
121+
this.newsPending.set(true);
118122
this.programDetailMainService
119123
.onAddNews(news)
120-
.pipe(takeUntilDestroyed(this.destroyRef))
124+
.pipe(
125+
finalize(() => this.newsPending.set(false)),
126+
takeUntilDestroyed(this.destroyRef),
127+
)
121128
.subscribe({
122129
next: () => this.newsFormComponent()?.onResetForm(),
123130
});

projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ <h3 class="text-body-12 about__title">о проекте</h3>
2424
@if (profile()) {
2525
<div #newsEl class="project__news news">
2626
@if (project()!.leader === profile()!.id) {
27-
<app-news-form class="news__form" (addNews)="onAddNews($event)"></app-news-form>
27+
<app-news-form
28+
class="news__form"
29+
[pending]="newsPending()"
30+
(addNews)="onAddNews($event)"
31+
></app-news-form>
2832
}
2933

3034
<div class="project__directions">

projects/social_platform/src/app/ui/pages/projects/detail/info/components/projects-mid-side/projects-mid-side.component.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import {
88
ElementRef,
99
inject,
1010
input,
11-
Input,
11+
signal,
1212
viewChild,
13-
WritableSignal,
1413
} from "@angular/core";
1514
import { NewsFormComponent } from "@ui/widgets/news-form/news-form.component";
1615
import { ProjectDirectionCard } from "@ui/widgets/project-direction-card/project-direction-card.component";
@@ -25,6 +24,7 @@ import { ParseBreaksPipe, ParseLinksPipe } from "@corelib";
2524
import { FeedNews } from "@domain/news/project-news.model";
2625
import { Collaborator } from "@domain/project/collaborator.model";
2726
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
27+
import { finalize } from "rxjs";
2828
import { ProfileInfoService } from "@api/profile/facades/profile-info.service";
2929

3030
/** Центральная колонка детали проекта: описание, новости. */
@@ -69,6 +69,7 @@ export class ProjectsMidSideComponent {
6969
// Состояние компонента
7070
protected readonly profile = this.profileInfoService.profile;
7171
protected readonly news = this.newsInfoService.news; // Массив новостей
72+
protected readonly newsPending = signal(false);
7273
protected readonly readFullDescription = this.expandService.readFullDescription; // Флаг развернутого описания
7374
protected readonly descriptionExpandable = this.expandService.descriptionExpandable; // Флаг необходимости кнопки "Читать полностью"
7475

@@ -77,9 +78,13 @@ export class ProjectsMidSideComponent {
7778
}
7879

7980
onAddNews(news: { text: string; files: string[] }): void {
81+
this.newsPending.set(true);
8082
this.projectsDetailService
8183
.onAddNews(news)
82-
.pipe(takeUntilDestroyed(this.destroyRef))
84+
.pipe(
85+
finalize(() => this.newsPending.set(false)),
86+
takeUntilDestroyed(this.destroyRef),
87+
)
8388
.subscribe({
8489
next: () => this.newsFormComponent()?.onResetForm(),
8590
});

projects/social_platform/src/app/ui/widgets/news-form/news-form.component.html

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
icon="send"
1818
appSquare="12"
1919
class="form__send"
20-
[style.opacity]="isTextOverflow ? '0.5' : '1'"
21-
[style.pointer-events]="isTextOverflow ? 'none' : 'auto'"
20+
[style.opacity]="isTextOverflow || pending() ? '0.5' : '1'"
21+
[style.pointer-events]="isTextOverflow || pending() ? 'none' : 'auto'"
2222
(click)="onSubmit()"
2323
></i>
2424
</div>
@@ -48,7 +48,13 @@
4848
<div class="footer">
4949
<label class="footer__attach">
5050
<i appIcon icon="attach" appSquare="12"></i>
51-
<input type="file" accept="*/*" multiple (change)="onInputFiles($event)" />
51+
<input
52+
type="file"
53+
accept="*/*"
54+
multiple
55+
[disabled]="pending()"
56+
(change)="onInputFiles($event)"
57+
/>
5258
</label>
5359
</div>
5460
</form>

projects/social_platform/src/app/ui/widgets/news-form/news-form.component.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import {
44
ChangeDetectionStrategy,
55
Component,
66
DestroyRef,
7-
EventEmitter,
7+
effect,
88
inject,
9+
input,
910
OnInit,
1011
output,
11-
Output,
1212
} from "@angular/core";
1313
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
1414
import { ValidationService } from "@corelib";
@@ -66,10 +66,20 @@ export class NewsFormComponent implements OnInit {
6666
this.messageForm = this.fb.group({
6767
text: ["", [Validators.required]],
6868
});
69+
70+
effect(() => {
71+
if (this.pending()) {
72+
this.messageForm.disable({ emitEvent: false });
73+
} else {
74+
this.messageForm.enable({ emitEvent: false });
75+
}
76+
});
6977
}
7078

7179
readonly addNews = output<{ text: string; files: string[] }>();
7280

81+
readonly pending = input(false);
82+
7383
ngOnInit(): void {}
7484

7585
messageForm: FormGroup;
@@ -85,6 +95,7 @@ export class NewsFormComponent implements OnInit {
8595
* Валидирует форму и эмитит событие с данными новости
8696
*/
8797
onSubmit() {
98+
if (this.pending()) return;
8899
if (this.isTextOverflow) return;
89100
if (!this.validationService.getFormValidation(this.messageForm)) {
90101
return;

0 commit comments

Comments
 (0)