diff --git a/docs/modal-acknowledgements.md b/docs/modal-acknowledgements.md new file mode 100644 index 000000000..0e456ee88 --- /dev/null +++ b/docs/modal-acknowledgements.md @@ -0,0 +1,37 @@ + + +# Одноразовые пользовательские окна + +## Источники состояния + +Согласие на cookie относится к браузеру и хранится локально в ключе `cookieConsent`. +Значения `accepted` и `declined` одинаково закрывают баннер при следующих загрузках. Только +`accepted` разрешает загрузку необязательной аналитики. + +Остальные одноразовые окна относятся к аккаунту и синхронизируются с backend: + +- ожидание верификации — `verificationNoticeAcknowledgedAt` текущего пользователя; +- предложение заполнить профиль — `profileFillPromptAcknowledgedAt` текущего пользователя; +- приветствие программы — `welcomeAcknowledgedAt` членства пользователя в программе. + +Frontend не записывает эти состояния в `localStorage`. Загрузка страницы только читает +серверные признаки и не подтверждает показ автоматически. Timestamp появляется после явного +закрытия окна или нажатия его основной кнопки. + +## Пользовательские правила + +- Ожидание верификации показывается, пока аккаунт не верифицирован и уведомление не подтверждено. +- Предложение заполнить профиль показывается только владельцу неполного профиля. +- Приветствие показывается только участнику конкретной программы и только в её detail-разделе. +- Повторный вызов любого подтверждения безопасен и возвращает уже зафиксированное состояние. +- Подтверждения аккаунта работают одинаково на разных устройствах и не переносятся между + пользователями; приветствие программы дополнительно изолировано по программе. + +## Используемые API + +- `POST /auth/users/current/acknowledge-verification-notice/`; +- `POST /auth/users/current/acknowledge-profile-fill-prompt/`; +- `POST /programs//acknowledge-welcome/`. + +Дублирующее приветствие на главной странице офиса удалено. Единственный сценарий приветствия +остаётся на detail-странице программы. diff --git a/projects/social_platform/src/app/api/office/facades/office-info.service.ts b/projects/social_platform/src/app/api/office/facades/office-info.service.ts index 98232d481..e68eddaed 100644 --- a/projects/social_platform/src/app/api/office/facades/office-info.service.ts +++ b/projects/social_platform/src/app/api/office/facades/office-info.service.ts @@ -1,7 +1,7 @@ /** @format */ import { DestroyRef, inject, Injectable, Injector } from "@angular/core"; -import { catchError, EMPTY, filter, tap } from "rxjs"; +import { catchError, EMPTY, filter, finalize, tap } from "rxjs"; import { Router } from "@angular/router"; import { OfficeUIInfoService } from "./ui/office-ui-info.service"; import { AuthRepositoryPort } from "@domain/auth/ports/auth.repository.port"; @@ -60,8 +60,6 @@ export class OfficeInfoService { if (!this.router.url.includes("chats")) { this.chatUnreadState.ensureLoaded(); } - - this.officeUIInfoService.applyVerificationModal(); } private initializationNavItems(): void { @@ -78,13 +76,31 @@ export class OfficeInfoService { .then(() => this.logger.debug("Route changed from OfficeComponent")); } else if ( profile!.relations.verificationDate === null && - localStorage.getItem("waitVerificationAccepted") !== "true" + profile!.relations.verificationNoticeAcknowledgedAt === null ) { this.officeUIInfoService.applyOpenVerificationModal(); } }); } + onAcknowledgeVerificationNotice(): void { + if (this.officeUIInfoService.verificationAcknowledgementPending()) return; + + this.officeUIInfoService.verificationAcknowledgementPending.set(true); + this.authRepository + .acknowledgeVerificationNotice() + .pipe( + finalize(() => this.officeUIInfoService.verificationAcknowledgementPending.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: profile => { + this.profileInfoService.applyProfileUpdated(profile); + this.officeUIInfoService.applyVerificationAcknowledged(); + }, + }); + } + private initializationStatus(): void { this.connectChatUseCase.execute().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); diff --git a/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts b/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts index e79c80c9f..cc1e53ccc 100644 --- a/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts +++ b/projects/social_platform/src/app/api/office/facades/ui/office-ui-info.service.ts @@ -6,21 +6,13 @@ import { Injectable, inject, signal } from "@angular/core"; @Injectable() export class OfficeUIInfoService { readonly waitVerificationModal = signal(false); - readonly waitVerificationAccepted = signal(false); + readonly verificationAcknowledgementPending = signal(false); readonly inviteErrorModal = signal(false); readonly navItems = signal< { name: string; link: string; icon: string; isExternal?: boolean; isActive?: boolean }[] >([]); - applyVerificationModal(): void { - // Не показываем модалку, если пользователь уже принял подтверждение - if (localStorage.getItem("waitVerificationAccepted") === "true") { - // eslint-disable-next-line no-useless-return - return; - } - } - applyOpenVerificationModal(): void { this.waitVerificationModal.set(true); } @@ -29,9 +21,8 @@ export class OfficeUIInfoService { this.inviteErrorModal.set(true); } - applyAcceptWaitVerification() { - this.waitVerificationAccepted.set(true); - localStorage.setItem("waitVerificationAccepted", "true"); + applyVerificationAcknowledged(): void { + this.waitVerificationModal.set(false); } applyCreateNavItems(profileId: number): void { diff --git a/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts index e31f3f14f..8f96d7770 100644 --- a/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts +++ b/projects/social_platform/src/app/api/profile/facades/detail/profile-detail-info.service.ts @@ -65,7 +65,7 @@ export class ProfileDetailInfoService { ) .subscribe({ next: data => { - this.profileDetailUIInfoService.applyInitProfile(data); + this.profileDetailUIInfoService.applyInitProfile(data, this.profile()?.id); }, }); diff --git a/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.spec.ts b/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.spec.ts new file mode 100644 index 000000000..2b317f92c --- /dev/null +++ b/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.spec.ts @@ -0,0 +1,52 @@ +/** @format */ + +import { ProfileDetailUIInfoService } from "./profile-detail-ui-info.service"; +import { User } from "@domain/auth/user.model"; + +function createUser(id: number, acknowledgedAt: string | null = null): User { + return { + id, + relations: { + progress: 40, + skills: [], + achievements: [], + profileFillPromptAcknowledgedAt: acknowledgedAt, + }, + } as User; +} + +describe("ProfileDetailUIInfoService", () => { + it("показывает предложение заполнить только собственный неполный профиль", () => { + const service = new ProfileDetailUIInfoService(); + + service.applyInitProfile({ data: { user: createUser(7) } }, 7); + + expect(service.isProfileFill()).toBe(true); + }); + + it("не показывает предложение в чужом профиле", () => { + const service = new ProfileDetailUIInfoService(); + + service.applyInitProfile({ data: { user: createUser(7) } }, 8); + + expect(service.isProfileFill()).toBe(false); + }); + + it("не показывает уже подтверждённое предложение на другом устройстве", () => { + const service = new ProfileDetailUIInfoService(); + + service.applyInitProfile({ data: { user: createUser(7, "2026-08-24T10:00:00Z") } }, 7); + + expect(service.isProfileFill()).toBe(false); + }); + + it("закрывает предложение только после подтверждения", () => { + const service = new ProfileDetailUIInfoService(); + service.applyInitProfile({ data: { user: createUser(7) } }, 7); + + service.applyProfileFillAcknowledged("2026-08-24T10:00:00Z"); + + expect(service.isProfileFill()).toBe(false); + expect(service.user()?.relations.profileFillPromptAcknowledgedAt).toBe("2026-08-24T10:00:00Z"); + }); +}); diff --git a/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts b/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts index 269601802..b925f5829 100644 --- a/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts +++ b/projects/social_platform/src/app/api/profile/facades/detail/ui/profile-detail-ui-info.service.ts @@ -18,18 +18,18 @@ export class ProfileDetailUIInfoService { readonly directions = signal([]); readonly isShowModal = signal(false); - applyInitProfile(data: any): void { + applyInitProfile(data: any, currentUserId?: number): void { const userWithProgress = data["data"]["user"] as User; this.user.set(userWithProgress); this.initializationDirections(userWithProgress); if ( - userWithProgress?.id !== undefined && + currentUserId !== undefined && + userWithProgress?.id === currentUserId && userWithProgress.relations.progress! < 100 && - !this.hasSeenProfileFillModal(userWithProgress.id) + userWithProgress.relations.profileFillPromptAcknowledgedAt === null ) { this.isProfileFill.set(true); - this.markSeenProfileFillModal(userWithProgress.id); } else { this.isProfileFill.set(false); } @@ -55,6 +55,20 @@ export class ProfileDetailUIInfoService { this.isShowModal.set(true); } + applyProfileFillAcknowledged(acknowledgedAt: string): void { + this.user.update(user => + user + ? Object.assign(new User(), user, { + relations: { + ...user.relations, + profileFillPromptAcknowledgedAt: acknowledgedAt, + }, + }) + : user, + ); + this.isProfileFill.set(false); + } + private initializationDirections(user: User): void { this.directions.set( directionItemBuilder( @@ -66,24 +80,4 @@ export class ProfileDetailUIInfoService { )!.filter(item => !Array.isArray(item.about) || item.about.length > 0), ); } - - private getProfileFillSeenKey(userId: number): string { - return `profile_${userId}_fill_modal_seen`; - } - - private hasSeenProfileFillModal(userId: number): boolean { - try { - return !!localStorage.getItem(this.getProfileFillSeenKey(userId)); - } catch (e) { - return false; - } - } - - private markSeenProfileFillModal(userId: number): void { - try { - localStorage.setItem(this.getProfileFillSeenKey(userId), "1"); - } catch (e) { - // ignore storage errors - } - } } diff --git a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts index e71305a7c..5455debde 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/program-detail-main-info.service.ts @@ -2,7 +2,7 @@ import { DestroyRef, ElementRef, inject, Injectable, signal } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { concatMap, fromEvent, map, of, tap, throttleTime } from "rxjs"; +import { concatMap, EMPTY, finalize, fromEvent, map, of, tap, throttleTime } from "rxjs"; import { FeedNews } from "@domain/news/project-news.model"; import { LoadingService } from "@api/shared/loading.service"; import { ExpandService } from "../../../expand/expand.service"; @@ -16,6 +16,7 @@ import { DeleteNewsUseCase } from "../../use-cases/delete-news.use-case"; import { ToggleLikeUseCase } from "../../use-cases/toggle-like.use-case"; import { EditNewsUseCase } from "../../use-cases/edit-news.use-case"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { AcknowledgeProgramWelcomeUseCase } from "../../use-cases/acknowledge-program-welcome.use-case"; /** Координирует основную вкладку программы: данные резолвера, новости, пагинация. */ @Injectable() @@ -35,6 +36,7 @@ export class ProgramDetailMainService { private readonly deleteNewsUseCase = inject(DeleteNewsUseCase); private readonly toggleLikeUseCase = inject(ToggleLikeUseCase); private readonly editNewsUseCase = inject(EditNewsUseCase); + private readonly acknowledgeProgramWelcomeUseCase = inject(AcknowledgeProgramWelcomeUseCase); private observer?: IntersectionObserver; @@ -247,6 +249,24 @@ export class ProgramDetailMainService { this.loadingService.hide(); } + acknowledgeProgramWelcome() { + if (this.programDetailMainUIInfoService.welcomeAcknowledgementPending()) return EMPTY; + + const programId = Number(this.programId()); + if (!Number.isInteger(programId) || programId <= 0) return EMPTY; + + this.programDetailMainUIInfoService.welcomeAcknowledgementPending.set(true); + return this.acknowledgeProgramWelcomeUseCase.execute(programId).pipe( + tap(result => { + if (!result.ok) return; + this.programDetailMainUIInfoService.applyProgramWelcomeAcknowledged( + result.value.welcomeAcknowledgedAt, + ); + }), + finalize(() => this.programDetailMainUIInfoService.welcomeAcknowledgementPending.set(false)), + ); + } + private setupNewsObserver(): void { this.observer?.disconnect(); diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts new file mode 100644 index 000000000..63d292f9e --- /dev/null +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.spec.ts @@ -0,0 +1,51 @@ +/** @format */ + +import { ProgramDetailMainUIInfoService } from "./program-detail-main-ui-info.service"; +import { Program } from "@domain/program/program.model"; + +function createProgram(id: number, acknowledgedAt: string | null = null): Program { + return { + ...Program.default(), + id, + isUserMember: true, + datetimeRegistrationEnds: "2099-01-01T00:00:00Z", + welcomeAcknowledgedAt: acknowledgedAt, + }; +} + +describe("ProgramDetailMainUIInfoService", () => { + it("показывает приветствие участнику до явного подтверждения", () => { + const service = new ProgramDetailMainUIInfoService(); + + service.applyFormatingProgramData(createProgram(1)); + + expect(service.registeredProgramModal()).toBe(true); + }); + + it("не показывает подтверждённое приветствие на другом устройстве", () => { + const service = new ProgramDetailMainUIInfoService(); + + service.applyFormatingProgramData(createProgram(1, "2026-08-24T10:00:00Z")); + + expect(service.registeredProgramModal()).toBe(false); + }); + + it("не переносит подтверждение между программами", () => { + const service = new ProgramDetailMainUIInfoService(); + service.applyFormatingProgramData(createProgram(1, "2026-08-24T10:00:00Z")); + + service.applyFormatingProgramData(createProgram(2)); + + expect(service.registeredProgramModal()).toBe(true); + }); + + it("закрывает приветствие после ответа backend", () => { + const service = new ProgramDetailMainUIInfoService(); + service.applyFormatingProgramData(createProgram(1)); + + service.applyProgramWelcomeAcknowledged("2026-08-24T10:00:00Z"); + + expect(service.registeredProgramModal()).toBe(false); + expect(service.program()?.welcomeAcknowledgedAt).toBe("2026-08-24T10:00:00Z"); + }); +}); diff --git a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts index 1fafcf944..4ddfc1451 100644 --- a/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts +++ b/projects/social_platform/src/app/api/program/facades/detail/ui/program-detail-main-ui-info.service.ts @@ -25,6 +25,7 @@ export class ProgramDetailMainUIInfoService { readonly showProgramModal = signal(false); readonly showProgramModalErrorMessage = signal(null); readonly registeredProgramModal = signal(false); + readonly welcomeAcknowledgementPending = signal(false); readonly registerDateExpired = signal(false); @@ -48,13 +49,7 @@ export class ProgramDetailMainUIInfoService { applyFormatingProgramData(program: Program): void { this.program.set(program); this.registerDateExpired.set(Date.now() > Date.parse(program.datetimeRegistrationEnds)); - if (program.isUserMember) { - const seen = this.hasSeenRegisteredProgramModal(program.id); - if (!seen) { - this.registeredProgramModal.set(true); - this.markSeenRegisteredProgramModal(program.id); - } - } + this.registeredProgramModal.set(program.isUserMember && program.welcomeAcknowledgedAt === null); } applyProgramOpenModal(type: "access" | "error"): void { @@ -71,21 +66,12 @@ export class ProgramDetailMainUIInfoService { this.showProgramModal.set(false); } - private getRegisteredProgramSeenKey(programId: number): string { - return `program_registered_modal_seen_${programId}`; - } - - private hasSeenRegisteredProgramModal(programId: number): boolean { - try { - return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); - } catch (e) { - return false; - } - } - - private markSeenRegisteredProgramModal(programId: number): void { - try { - localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); - } catch (e) {} + applyProgramWelcomeAcknowledged(acknowledgedAt: string): void { + this.program.update(program => + program + ? Object.assign(new Program(), program, { welcomeAcknowledgedAt: acknowledgedAt }) + : program, + ); + this.registeredProgramModal.set(false); } } diff --git a/projects/social_platform/src/app/api/program/use-cases/acknowledge-program-welcome.use-case.ts b/projects/social_platform/src/app/api/program/use-cases/acknowledge-program-welcome.use-case.ts new file mode 100644 index 000000000..b60933c03 --- /dev/null +++ b/projects/social_platform/src/app/api/program/use-cases/acknowledge-program-welcome.use-case.ts @@ -0,0 +1,23 @@ +/** @format */ + +import { inject, Injectable } from "@angular/core"; +import { catchError, map, Observable, of } from "rxjs"; +import { ProgramRepositoryPort } from "@domain/program/ports/program.repository.port"; +import { fail, ok, Result } from "@domain/shared/result.type"; + +/** Подтверждает приветствие конкретной программы для текущего участника. */ +@Injectable({ providedIn: "root" }) +export class AcknowledgeProgramWelcomeUseCase { + private readonly programRepository = inject(ProgramRepositoryPort); + + execute( + programId: number, + ): Observable< + Result<{ welcomeAcknowledgedAt: string }, { kind: "acknowledge_program_welcome_error" }> + > { + return this.programRepository.acknowledgeWelcome(programId).pipe( + map(response => ok(response)), + catchError(() => of(fail({ kind: "acknowledge_program_welcome_error" as const }))), + ); + } +} diff --git a/projects/social_platform/src/app/domain/auth/ports/auth.repository.port.ts b/projects/social_platform/src/app/domain/auth/ports/auth.repository.port.ts index a067c4340..bf8b9f7fa 100644 --- a/projects/social_platform/src/app/domain/auth/ports/auth.repository.port.ts +++ b/projects/social_platform/src/app/domain/auth/ports/auth.repository.port.ts @@ -19,6 +19,8 @@ export abstract class AuthRepositoryPort { abstract resendEmail(email: string): Observable; abstract fetchUser(id: number): Observable; abstract fetchProfile(): Observable; + abstract acknowledgeVerificationNotice(): Observable; + abstract acknowledgeProfileFillPrompt(): Observable; abstract updateProfile(profileId: number, data: UserInput): Observable; abstract updateOnboardingStage(stage: number | null, userId: number): Observable; abstract updateAvatar(url: string, userId: number): Observable; diff --git a/projects/social_platform/src/app/domain/auth/user.model.ts b/projects/social_platform/src/app/domain/auth/user.model.ts index 07f452848..196f5bb38 100644 --- a/projects/social_platform/src/app/domain/auth/user.model.ts +++ b/projects/social_platform/src/app/domain/auth/user.model.ts @@ -80,7 +80,9 @@ export class UserRelations { isActive!: boolean; timeCreated!: string; timeUpdated!: string; - verificationDate!: string; + verificationDate!: string | null; + verificationNoticeAcknowledgedAt!: string | null; + profileFillPromptAcknowledgedAt!: string | null; } export class UserPersonal { diff --git a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts index 8b2f016d8..c5f1ee0ba 100644 --- a/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts +++ b/projects/social_platform/src/app/domain/program/ports/program.repository.port.ts @@ -22,6 +22,8 @@ export abstract class ProgramRepositoryPort { abstract getOne(programId: number): Observable; + abstract acknowledgeWelcome(programId: number): Observable<{ welcomeAcknowledgedAt: string }>; + abstract create(program: ProgramCreate): Observable; abstract getDataSchema(programId: number): Observable; 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 1c5b0c950..c3646cb16 100644 --- a/projects/social_platform/src/app/domain/program/program.model.ts +++ b/projects/social_platform/src/app/domain/program/program.model.ts @@ -26,6 +26,7 @@ export class Program { isUserLiked!: boolean; isUserManager!: boolean; isUserMember!: boolean; + welcomeAcknowledgedAt!: string | null; publishProjectsAfterFinish!: boolean; courseId!: number | null; courses!: { id: number; title: string; isAvailable: boolean }[]; @@ -55,6 +56,7 @@ export class Program { year: 0, isUserLiked: false, isUserMember: false, + welcomeAcknowledgedAt: null, isUserManager: false, publishProjectsAfterFinish: false, courseId: null, diff --git a/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.spec.ts index 3da079fb4..a04ab6a5c 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.spec.ts @@ -76,6 +76,30 @@ describe("AuthHttpAdapter", () => { expect(api.get).toHaveBeenCalledExactlyOnceWith("/auth/users/current/"); }); + it("подтверждает уведомление о верификации текущего аккаунта", () => { + setup(); + api.post.mockReturnValue(of({} as User)); + + adapter.acknowledgeVerificationNotice().subscribe(); + + expect(api.post).toHaveBeenCalledExactlyOnceWith( + "/auth/users/current/acknowledge-verification-notice/", + {}, + ); + }); + + it("подтверждает предложение заполнить профиль текущего аккаунта", () => { + setup(); + api.post.mockReturnValue(of({} as User)); + + adapter.acknowledgeProfileFillPrompt().subscribe(); + + expect(api.post).toHaveBeenCalledExactlyOnceWith( + "/auth/users/current/acknowledge-profile-fill-prompt/", + {}, + ); + }); + it("getUserRoles идёт в GET /auth/users/types/", () => { setup(); api.get.mockReturnValue(of([[1, "r"]] as [[number, string]])); diff --git a/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts index ebadd9359..85695f553 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/auth/auth-http.adapter.ts @@ -44,6 +44,20 @@ export class AuthHttpAdapter { return this.apiService.get(`${this.AUTH_USERS_URL}/current/`); } + acknowledgeVerificationNotice(): Observable { + return this.apiService.post( + `${this.AUTH_USERS_URL}/current/acknowledge-verification-notice/`, + {}, + ); + } + + acknowledgeProfileFillPrompt(): Observable { + return this.apiService.post( + `${this.AUTH_USERS_URL}/current/acknowledge-profile-fill-prompt/`, + {}, + ); + } + getUserRoles(): Observable<[[number, string]]> { return this.apiService.get(`${this.AUTH_USERS_URL}/types/`); } diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts index 92b3da5a7..2f806feac 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.spec.ts @@ -81,6 +81,15 @@ describe("ProgramHttpAdapter", () => { expect(api.post).toHaveBeenCalledExactlyOnceWith("/programs/5/register/", { foo: "bar" }); }); + it("подтверждает приветствие в конкретной программе", () => { + setup(); + api.post.mockReturnValue(of({ welcomeAcknowledgedAt: "2026-08-24T10:00:00Z" })); + + adapter.acknowledgeWelcome(5).subscribe(); + + expect(api.post).toHaveBeenCalledExactlyOnceWith("/programs/5/acknowledge-welcome/", {}); + }); + it("getAllProjects идёт в GET /programs/:id/projects", () => { setup(); api.get.mockReturnValue(of(pagination())); diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts index 39cddb3bf..a7364e321 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-http.adapter.ts @@ -40,6 +40,10 @@ export class ProgramHttpAdapter { return this.apiService.get(`${this.PROGRAMS_URL}/${programId}/`); } + acknowledgeWelcome(programId: number): Observable<{ welcomeAcknowledgedAt: string }> { + return this.apiService.post(`${this.PROGRAMS_URL}/${programId}/acknowledge-welcome/`, {}); + } + create(program: ProgramCreate): Observable { return this.apiService.post(`${this.PROGRAMS_URL}/`, program); } diff --git a/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.spec.ts index f30bcc79f..803c30085 100644 --- a/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.spec.ts @@ -22,6 +22,8 @@ describe("AuthRepository", () => { register: vi.fn(), downloadCV: vi.fn(), getProfile: vi.fn(), + acknowledgeVerificationNotice: vi.fn(), + acknowledgeProfileFillPrompt: vi.fn(), getUserRoles: vi.fn(), getLeaderProjects: vi.fn(), getChangeableRoles: vi.fn(), @@ -116,6 +118,36 @@ describe("AuthRepository", () => { }); })); + it("подтверждает уведомление о верификации и мапит актуальный профиль", () => + new Promise(done => { + setup(); + adapter.acknowledgeVerificationNotice.mockReturnValue( + of({ id: 1, verificationNoticeAcknowledgedAt: "2026-08-24T10:00:00Z" }), + ); + + repository.acknowledgeVerificationNotice().subscribe(profile => { + expect(adapter.acknowledgeVerificationNotice).toHaveBeenCalledExactlyOnceWith(); + expect(profile).toBeInstanceOf(User); + expect(profile.relations.verificationNoticeAcknowledgedAt).toBe("2026-08-24T10:00:00Z"); + done(); + }); + })); + + it("подтверждает предложение заполнить профиль и мапит актуальный профиль", () => + new Promise(done => { + setup(); + adapter.acknowledgeProfileFillPrompt.mockReturnValue( + of({ id: 1, profileFillPromptAcknowledgedAt: "2026-08-24T10:00:00Z" }), + ); + + repository.acknowledgeProfileFillPrompt().subscribe(profile => { + expect(adapter.acknowledgeProfileFillPrompt).toHaveBeenCalledExactlyOnceWith(); + expect(profile).toBeInstanceOf(User); + expect(profile.relations.profileFillPromptAcknowledgedAt).toBe("2026-08-24T10:00:00Z"); + done(); + }); + })); + it("updateProfile передает profileId отдельно и преобразует данные в обе стороны", () => new Promise(done => { setup(); diff --git a/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts b/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts index 44e5428c9..65f387350 100644 --- a/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/auth/auth.repository.ts @@ -60,6 +60,14 @@ export class AuthRepository implements AuthRepositoryPort { return this.authAdapter.getProfile().pipe(map(user => userFromRaw(user))); } + acknowledgeVerificationNotice(): Observable { + return this.authAdapter.acknowledgeVerificationNotice().pipe(map(user => userFromRaw(user))); + } + + acknowledgeProfileFillPrompt(): Observable { + return this.authAdapter.acknowledgeProfileFillPrompt().pipe(map(user => userFromRaw(user))); + } + updateProfile(profileId: number, data: UserInput): Observable { const rawData = userToRaw(data); return this.authAdapter.saveProfile(profileId, rawData).pipe(map(user => userFromRaw(user))); diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts index 0d0811324..1182a254b 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.spec.ts @@ -24,6 +24,7 @@ describe("ProgramRepository", () => { create: vi.fn(), getDataSchema: vi.fn(), register: vi.fn(), + acknowledgeWelcome: vi.fn(), getAllProjects: vi.fn(), getAllMembers: vi.fn(), getProgramFilters: vi.fn(), @@ -90,6 +91,21 @@ describe("ProgramRepository", () => { expect(adapter.register).toHaveBeenCalledExactlyOnceWith(1, { city: "Москва" }); }); + it("подтверждает приветствие и сбрасывает кеш detail программы", () => { + setup(); + adapter.getOne.mockReturnValue(of({ id: 1 } as Program)); + adapter.acknowledgeWelcome.mockReturnValue( + of({ welcomeAcknowledgedAt: "2026-08-24T10:00:00Z" }), + ); + + repository.getOne(1).subscribe(); + repository.acknowledgeWelcome(1).subscribe(); + repository.getOne(1).subscribe(); + + expect(adapter.acknowledgeWelcome).toHaveBeenCalledExactlyOnceWith(1); + expect(adapter.getOne).toHaveBeenCalledTimes(2); + }); + it("getAllProjects делегирует в adapter", () => { setup(); const params = new HttpParams(); diff --git a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts index b1100da87..89a009149 100644 --- a/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/program/program.repository.ts @@ -2,7 +2,7 @@ import { HttpParams } from "@angular/common/http"; import { inject, Injectable } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { map, Observable, tap } from "rxjs"; import { User } from "@domain/auth/user.model"; import { ApiPagination } from "@domain/other/api-pagination.model"; import { PartnerProgramFields } from "@domain/program/partner-program-fields.model"; @@ -46,6 +46,12 @@ export class ProgramRepository implements ProgramRepositoryPort { return this.entityCache.getOrFetch(programId, () => this.programAdapter.getOne(programId)); } + acknowledgeWelcome(programId: number): Observable<{ welcomeAcknowledgedAt: string }> { + return this.programAdapter + .acknowledgeWelcome(programId) + .pipe(tap(() => this.entityCache.invalidate(programId))); + } + create(program: ProgramCreate): Observable { return this.programAdapter.create(program); } diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.html b/projects/social_platform/src/app/ui/pages/office/office.component.html index 470af74a1..5f840178f 100644 --- a/projects/social_platform/src/app/ui/pages/office/office.component.html +++ b/projects/social_platform/src/app/ui/pages/office/office.component.html @@ -60,7 +60,7 @@ } - +
Ваш аккаунт проход Мы проверяем ваши данные и скоро сообщим о подтверждении аккаунта, а пока можете уже пользоваться платформой

- - Хорошо - -
-
- - -
-
-

Привет!

-
- -
-

- Рады знакомству 🙌
- Вы находитесь на платформе procollab – здесь проходит программа, на которую вы ранее - регистрировались через форму заявки -

- -

- Ваша программа и её закрытая группа (к которой у вас автоматически есть доступ) находится - в одноименной вкладке «программы» -

- -

- На платформе есть еще много интересного: цифровой профиль, проекты, новостная лента, чаты - и другое – рекомендуем изучить -

- -

- Поздравляем с регистрацией! Желаем удачи в прохождении программы :) -

-
- спасибо, понятно + Хорошо +
diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts b/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts index 7b6545546..b3d617dcc 100644 --- a/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/office/office.component.spec.ts @@ -32,6 +32,7 @@ describe("OfficeComponent", () => { const officeInfoServiceSpy = { initializationOffice: vi.fn(), destroy: vi.fn(), + onAcknowledgeVerificationNotice: vi.fn(), onRejectInvite: vi.fn(), onAcceptInvite: vi.fn(), onLogout: vi.fn(), @@ -40,10 +41,9 @@ describe("OfficeComponent", () => { const officeUIInfoServiceSpy = { waitVerificationModal: signal(false), - waitVerificationAccepted: signal(false), + verificationAcknowledgementPending: signal(false), inviteErrorModal: signal(false), navItems: signal([]), - applyAcceptWaitVerification: vi.fn(), }; const authUIInfoServiceSpy = {}; @@ -107,4 +107,16 @@ describe("OfficeComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("подтверждает уведомление о верификации через backend", () => { + const officeInfoService = fixture.debugElement.injector.get(OfficeInfoService); + + component.onAcceptWaitVerification(); + + expect(officeInfoService.onAcknowledgeVerificationNotice).toHaveBeenCalledOnce(); + }); + + it("не содержит дублирующее приветствие программы", () => { + expect(fixture.nativeElement.textContent).not.toContain("Привет! Рады знакомству"); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/office/office.component.ts b/projects/social_platform/src/app/ui/pages/office/office.component.ts index d0d261f4c..607b581c9 100644 --- a/projects/social_platform/src/app/ui/pages/office/office.component.ts +++ b/projects/social_platform/src/app/ui/pages/office/office.component.ts @@ -1,6 +1,6 @@ /** @format */ -import { Component, OnInit, signal, effect, inject, ChangeDetectionStrategy } from "@angular/core"; +import { Component, OnInit, signal, inject, ChangeDetectionStrategy } from "@angular/core"; import { RouterLink, RouterOutlet } from "@angular/router"; import { ProgramSidebarCardComponent } from "./program-sidebar-card/program-sidebar-card.component"; import { ButtonComponent } from "@ui/primitives"; @@ -9,7 +9,6 @@ import { ModalComponent } from "@ui/primitives/modal/modal.component"; import { NavComponent } from "./nav/nav.component"; import { SnackbarComponent } from "./snackbar/snackbar.component"; import { ProfileControlPanelComponent, SidebarComponent } from "@uilib"; -import { Program } from "@domain/program/program.model"; import { OfficeInfoService } from "@api/office/facades/office-info.service"; import { OfficeUIInfoService } from "@api/office/facades/ui/office-ui-info.service"; import { AppRoutes } from "@api/paths/app-routes"; @@ -52,7 +51,8 @@ export class OfficeComponent implements OnInit { protected readonly invites = this.officeInfoService.invites; protected readonly waitVerificationModal = this.officeUIInfoService.waitVerificationModal; - protected readonly waitVerificationAccepted = this.officeUIInfoService.waitVerificationAccepted; + protected readonly verificationAcknowledgementPending = + this.officeUIInfoService.verificationAcknowledgementPending; protected readonly inviteErrorModal = this.officeUIInfoService.inviteErrorModal; @@ -63,29 +63,13 @@ export class OfficeComponent implements OnInit { protected currentYear = signal(new Date().getFullYear()); - protected readonly showRegisteredProgramModal = signal(false); - - protected registeredProgramToShow?: Program | null = null; - - private readonly _ = effect(() => { - const programs = this.programs(); - if (programs && programs.length) { - this.tryShowRegisteredProgramModal(); - } - }); - ngOnInit(): void { this.officeInfoService.initializationOffice(); - - if (localStorage.getItem("waitVerificationAccepted") === "true") { - this.waitVerificationAccepted.set(true); - } - this.programShellInfoService.ensureProgramsLoaded(); } onAcceptWaitVerification() { - this.officeUIInfoService.applyAcceptWaitVerification(); + this.officeInfoService.onAcknowledgeVerificationNotice(); } onRejectInvite(inviteId: number): void { @@ -105,38 +89,4 @@ export class OfficeComponent implements OnInit { event.stopPropagation(); this.authRegisterService.downloadPolicy(); } - - private tryShowRegisteredProgramModal(): void { - const programs = this.programs(); - if (!programs || programs.length === 0) return; - - const memberProgram = programs.find(p => p.isUserMember); - if (!memberProgram) return; - - if (this.hasSeenRegisteredProgramModal(memberProgram.id)) return; - - this.registeredProgramToShow = memberProgram; - this.showRegisteredProgramModal.set(true); - this.markSeenRegisteredProgramModal(memberProgram.id); - } - - private getRegisteredProgramSeenKey(programId: number): string { - return `program_${this.profile()?.id}_registered_modal_seen_${programId}`; - } - - private hasSeenRegisteredProgramModal(programId: number): boolean { - try { - return !!localStorage.getItem(this.getRegisteredProgramSeenKey(programId)); - } catch (e) { - return false; - } - } - - private markSeenRegisteredProgramModal(programId: number): void { - try { - localStorage.setItem(this.getRegisteredProgramSeenKey(programId), "1"); - } catch (e) { - // ignore storage errors - } - } } diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html index 79012d263..8dafbcb17 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.html @@ -146,7 +146,10 @@

о программе

- +

поздравляем с регистрацией на программу! 🎉

@@ -181,7 +184,8 @@

о программе

спасибо, понятно
diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts index b10ba24c6..62aa62d28 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.spec.ts @@ -25,6 +25,7 @@ describe("MainComponent", () => { onLike: vi.fn(), onEdit: vi.fn().mockReturnValue(of({ ok: true })), closeModal: vi.fn(), + acknowledgeProgramWelcome: vi.fn().mockReturnValue(of({ ok: true })), }; const programDetailMainUIInfoServiceSpy = { @@ -32,6 +33,7 @@ describe("MainComponent", () => { showProgramModal: signal(false), showProgramModalErrorMessage: signal(""), registeredProgramModal: signal(false), + welcomeAcknowledgementPending: signal(false), contactLinks: signal([]), materialLinks: signal([]), }; @@ -90,4 +92,12 @@ describe("MainComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("подтверждает приветствие только по действию пользователя", () => { + const service = fixture.debugElement.injector.get(ProgramDetailMainService); + + component.onAcknowledgeProgramWelcome(); + + expect(service.acknowledgeProgramWelcome).toHaveBeenCalledOnce(); + }); }); diff --git a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts index 9fd58b7c6..b9cd5d07a 100644 --- a/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/detail/main/main.component.ts @@ -90,6 +90,8 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { protected readonly registeredProgramModal = this.programDetailMainUIInfoService.registeredProgramModal; + protected readonly welcomeAcknowledgementPending = + this.programDetailMainUIInfoService.welcomeAcknowledgementPending; protected readonly contactLinks = this.programDetailMainUIInfoService.contactLinks; protected readonly materialLinks = this.programDetailMainUIInfoService.materialLinks; @@ -158,4 +160,11 @@ export class ProgramDetailMainComponent implements OnInit, OnDestroy { clearAssignProjectToProgramError(): void { this.projectAdditionalService.clearAssignProjectToProgramError(); } + + onAcknowledgeProgramWelcome(): void { + this.programDetailMainService + .acknowledgeProgramWelcome() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } } diff --git a/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html index 5682d7fea..cb80011a7 100644 --- a/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html +++ b/projects/social_platform/src/app/ui/widgets/cookie-consent/cookie-consent.component.html @@ -2,22 +2,14 @@ @if (visible) {