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
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"class-transformer": "^0.5.1",
"core-js": "^3.23.4",
"dayjs": "^1.11.3",
"file-saver": "^2.0.5",
"fuse.js": "^6.6.2",
"js-base64": "^3.7.7",
"js-cookie": "^3.0.5",
Expand Down
75 changes: 52 additions & 23 deletions projects/core/src/lib/interceptors/bearer-token.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
private isRefreshing = false;

/** Subject для уведомления ожидающих запросов о получении нового токена */
private refreshTokenSubject = new BehaviorSubject<any>(null);

Check warning on line 39 in projects/core/src/lib/interceptors/bearer-token.interceptor.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

/**
* Основной метод интерцептора
Expand All @@ -46,9 +46,7 @@
*/
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Базовые заголовки для всех запросов
const headers: Record<string, string> = {
Accept: "application/json",
};
const headers: Record<string, string> = {};

const tokens = this.tokenService.getTokens();

Expand All @@ -57,14 +55,24 @@
headers["Authorization"] = `Bearer ${tokens.access}`;
}

// Для blob запросов (файлы) не устанавливаем Accept, чтобы не парсить blob как JSON
const isBlobRequest =
request.url.includes("/export") ||
request.url.includes("/download") ||
(request.headers.has("X-Request-Type") && request.headers.get("X-Request-Type") === "blob");

const hasAcceptHeader = request.headers.has("Accept");

if (!isBlobRequest && !hasAcceptHeader) {
headers["Accept"] = "application/json";
}

const req = request.clone({ setHeaders: headers });

// Если токены есть, обрабатываем запрос с возможностью обновления токенов
if (tokens !== null) {
return this.handleRequestWithTokens(req, next);
} else {
// Если токенов нет, просто выполняем запрос
return next.handle(request);
return next.handle(req);
}
}

Expand Down Expand Up @@ -107,10 +115,9 @@
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
// Если токен еще не обновляется, начинаем процесс обновления
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null); // Сбрасываем subject
this.refreshTokenSubject.next(null);

return this.tokenService.refreshTokens().pipe(
catchError(err => {
Expand All @@ -119,22 +126,29 @@
}),
switchMap(res => {
this.isRefreshing = false;
this.refreshTokenSubject.next(res.access); // Уведомляем о новом токене
this.refreshTokenSubject.next(res.access);

// Сохраняем новые токены в хранилище
this.tokenService.memTokens(res);

// Подготавливаем заголовки с новым токеном
const headers: Record<string, string> = {
Accept: "application/json",
};
const headers: Record<string, string> = {};

const tokens = this.tokenService.getTokens();
if (tokens) {
headers["Authorization"] = `Bearer ${tokens.access}`;
}

// Повторяем исходный запрос с новым токеном
const isBlobRequest =
request.url.includes("/export") ||
request.url.includes("/download") ||
(request.headers.has("X-Request-Type") &&
request.headers.get("X-Request-Type") === "blob");

const hasAcceptHeader = request.headers.has("Accept");

if (!isBlobRequest && !hasAcceptHeader) {
headers["Accept"] = "application/json";
}

return next.handle(
request.clone({
setHeaders: headers,
Expand All @@ -144,17 +158,32 @@
);
}

// Если токен уже обновляется, ждем завершения процесса
return this.refreshTokenSubject.pipe(
filter(token => token !== null), // Ждем получения нового токена
take(1), // Берем только первое значение
switchMap(token =>
next.handle(
filter(token => token !== null),
take(1),
switchMap(token => {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};

const isBlobRequest =
request.url.includes("/export") ||
request.url.includes("/download") ||
(request.headers.has("X-Request-Type") &&
request.headers.get("X-Request-Type") === "blob");

const hasAcceptHeader = request.headers.has("Accept");

if (!isBlobRequest && !hasAcceptHeader) {
headers["Accept"] = "application/json";
}

return next.handle(
request.clone({
setHeaders: { Authorization: `Bearer ${token}` },
setHeaders: headers,
})
)
)
);
})
);
}
}
26 changes: 18 additions & 8 deletions projects/core/src/lib/interceptors/camelcase.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@
* @returns Observable с HTTP событием (ответ будет содержать camelCase ключи)
*/
intercept(
request: HttpRequest<Record<string, any>>,

Check warning on line 43 in projects/core/src/lib/interceptors/camelcase.interceptor.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
next: HttpHandler
): Observable<HttpEvent<unknown>> {
let req: HttpRequest<Record<string, any>>;

Check warning on line 46 in projects/core/src/lib/interceptors/camelcase.interceptor.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

// Обрабатываем тело запроса если оно существует
if (request.body) {
Expand All @@ -54,24 +54,34 @@
}),
});
} else {
// Если тела нет, просто клонируем запрос
req = request.clone();
}

// Выполняем запрос и обрабатываем ответ
return next.handle(req).pipe(
map((event: HttpEvent<any>) => {

Check warning on line 62 in projects/core/src/lib/interceptors/camelcase.interceptor.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type
// Обрабатываем только HTTP ответы (не события загрузки и т.д.)
if (event instanceof HttpResponse) {
if (event.body instanceof Blob) {
return event;
}

if (typeof event.body !== "object" || event.body === null) {
return event;
}

// Клонируем ответ с преобразованным телом (snake_case → camelCase)
return event.clone({
body: camelcaseKeys(event.body, {
deep: true, // Рекурсивное преобразование вложенных объектов
}),
});
try {
return event.clone({
body: camelcaseKeys(event.body, {
deep: true, // Рекурсивное преобразование вложенных объектов
}),
});
} catch (error) {
console.warn("CamelcaseInterceptor: Failed to transform response body", error);
return event;
}
}

// Для других типов событий возвращаем как есть
return event;
})
);
Expand Down
9 changes: 9 additions & 0 deletions projects/core/src/lib/services/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ export class ApiService {
return this.http.get(this.apiUrl + path, { params, ...options }).pipe(first()) as Observable<T>;
}

getFile(path: string, params?: HttpParams): Observable<Blob> {
return this.http
.get(this.apiUrl + path, {
params,
responseType: "blob",
})
.pipe(first()) as Observable<Blob>;
}

/**
* Выполняет PUT запрос к API (полное обновление ресурса)
* @param path - Относительный путь к ресурсу
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,8 @@ export class AuthService {
.pipe(map(json => plainToInstance(RegisterResponse, json)));
}

/**
* Отправить резюме по email
* @returns Observable завершения операции
*/
sendCV(): Observable<any> {
return this.apiService.get(`${this.AUTH_USERS_URL}/send_mail_cv/`);
downloadCV() {
return this.apiService.getFile(`${this.AUTH_USERS_URL}/download_cv/`);
}

/** Поток данных профиля пользователя */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,9 @@ <h3 class="text-body-14 cancel__title" style="flex-wrap: wrap; width: 90%">

@if (+profile.id === +info().id) {
<app-button
(click)="sendCVEmail()"
(click)="downloadCV()"
size="medium"
style="opacity: 0, 5"
[disabled]="true"
[loader]="isSended"
appearance="outline"
customTypographyClass="text-body-12"
>cкачать CV</app-button
Expand Down Expand Up @@ -645,18 +644,19 @@ <h3 class="text-body-12 lists__title">подтвердить владение н
<i
(click)="isDelayModalOpen = false"
appIcon
appSquare="8"
icon="cross"
class="cancel__cross"
></i>
<p class="cancel__title text-bold-body-16">Повторите загрузку позже</p>
</div>
<p class="text-body-14 cancel__text">
Скачивание будет доступно через {{ errorMessageModal() }} секунд.
Скачивание будет доступно через несколько секунд.
</p>
</div>
</app-modal>

<app-modal [open]="isSended" (openChange)="isSended = !isSended">
<!-- <app-modal [open]="isSended" (openChange)="isSended = !isSended">
<div class="cancel">
<div class="cancel__top">
<i
Expand All @@ -673,7 +673,7 @@ <h3 class="text-body-12 lists__title">подтвердить владение н
Технические сложности? Мы всегда на связи в Telegram — {{ "@procollab_support" }}
</p>
</div>
</app-modal>
</app-modal> -->
</ng-template>

<router-outlet></router-outlet>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import { CommonModule, Location } from "@angular/common";
import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, signal } from "@angular/core";
import { ButtonComponent, InputComponent } from "@ui/components";
import { BackComponent, IconComponent } from "@uilib";
import { ButtonComponent } from "@ui/components";
import { IconComponent } from "@uilib";
import { ModalComponent } from "@ui/components/modal/modal.component";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { AuthService } from "@auth/services";
Expand All @@ -23,15 +23,13 @@ import { ProfileDataService } from "@office/profile/detail/services/profile-date
import { ProfileService } from "projects/skills/src/app/profile/services/profile.service";
import { SnackbarService } from "@ui/services/snackbar.service";
import { ApproveSkillComponent } from "../approve-skill/approve-skill.component";
import { ProjectsService } from "@office/projects/services/projects.service";
import { TruncatePipe } from "projects/core/src/lib/pipes/truncate.pipe";
import { ProgramService } from "@office/program/services/program.service";
import { ProjectFormService } from "@office/projects/edit/services/project-form.service";
import {
PartnerProgramFields,
projectNewAdditionalProgramVields,
} from "@office/models/partner-program-fields.model";
import { HttpRequest, HttpResponse } from "@angular/common/http";
import { saveFile } from "@utils/helpers/export-file";

@Component({
selector: "app-detail",
Expand All @@ -42,13 +40,10 @@ import { HttpRequest, HttpResponse } from "@angular/common/http";
RouterModule,
IconComponent,
ButtonComponent,
BackComponent,
ModalComponent,
AvatarComponent,
TooltipComponent,
InputComponent,
ApproveSkillComponent,
TruncatePipe,
],
standalone: true,
})
Expand All @@ -64,7 +59,6 @@ export class DeatilComponent implements OnInit, OnDestroy {
private readonly location = inject(Location);
private readonly profileDataService = inject(ProfileDataService);
public readonly skillsProfileService = inject(ProfileService);
private readonly projectsService = inject(ProjectsService);
public readonly chatService = inject(ChatService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly programService = inject(ProgramService);
Expand Down Expand Up @@ -388,15 +382,17 @@ export class DeatilComponent implements OnInit, OnDestroy {
* Отправка CV пользователя на email
* Проверяет ограничения по времени и отправляет CV на почту пользователя
*/
sendCVEmail() {
this.authService.sendCV().subscribe({
next: () => {
this.isSended = true;
downloadCV() {
this.isSended = true;
this.authService.downloadCV().subscribe({
next: blob => {
saveFile(blob, "cv", this.profile?.firstName + " " + this.profile?.lastName);
this.isSended = false;
},
error: err => {
this.isSended = false;
if (err.status === 400) {
this.isDelayModalOpen = true;
this.errorMessageModal.set(err.error.seconds_after_retry);
}
},
});
Expand Down
Loading
Loading