mirror of
https://github.com/n08i40k/schedule-parser-next.git
synced 2025-12-06 17:57:45 +03:00
1.0.0
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AppController } from "./app.controller";
|
||||
import { AppService } from "./app.service";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { ScheduleModule } from "./schedule/schedule.module";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [AuthModule, UsersModule, ScheduleModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
86
src/auth/auth.controller.ts
Normal file
86
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiExtraModels,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiUnauthorizedResponse,
|
||||
refs,
|
||||
} from "@nestjs/swagger";
|
||||
import {
|
||||
SignInDto,
|
||||
SignInResultDto,
|
||||
SignUpDto,
|
||||
SignUpResultDto,
|
||||
UpdateTokenDto,
|
||||
UpdateTokenResultDto,
|
||||
} from "../dto/auth.dto";
|
||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||
|
||||
@Controller("api/v1/auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@ApiExtraModels(SignInDto)
|
||||
@ApiExtraModels(SignInResultDto)
|
||||
@ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] })
|
||||
@ApiBody({ schema: refs(SignInDto)[0] })
|
||||
@ApiOkResponse({
|
||||
description: "Авторизация прошла успешно",
|
||||
schema: refs(SignInResultDto)[0],
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: "Некорректное имя пользователя или пароль",
|
||||
})
|
||||
@ResultDto(SignInResultDto)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post("signIn")
|
||||
signIn(@Body() signInDto: SignInDto) {
|
||||
return this.authService.signIn(signInDto);
|
||||
}
|
||||
|
||||
@ApiExtraModels(SignUpDto)
|
||||
@ApiExtraModels(SignUpResultDto)
|
||||
@ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] })
|
||||
@ApiBody({ schema: refs(SignUpDto)[0] })
|
||||
@ApiCreatedResponse({
|
||||
description: "Регистрация прошла успешно",
|
||||
schema: refs(SignUpResultDto)[0],
|
||||
})
|
||||
@ApiConflictResponse({
|
||||
description: "Такой пользователь уже существует",
|
||||
})
|
||||
@ResultDto(SignUpResultDto)
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Post("signUp")
|
||||
signUp(@Body() signUpDto: SignUpDto) {
|
||||
return this.authService.signUp(signUpDto);
|
||||
}
|
||||
|
||||
@ApiExtraModels(UpdateTokenDto)
|
||||
@ApiExtraModels(UpdateTokenResultDto)
|
||||
@ApiOperation({
|
||||
summary: "Обновление просроченного токена",
|
||||
tags: ["auth"],
|
||||
})
|
||||
@ApiBody({ schema: refs(UpdateTokenDto)[0] })
|
||||
@ApiOkResponse({
|
||||
description: "Токен обновлён успешно",
|
||||
schema: refs(UpdateTokenResultDto)[0],
|
||||
})
|
||||
@ApiNotFoundResponse({
|
||||
description: "Передан несуществующий или недействительный токен",
|
||||
})
|
||||
@ResultDto(UpdateTokenResultDto)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post("updateToken")
|
||||
updateToken(
|
||||
@Body() updateTokenDto: UpdateTokenDto,
|
||||
): Promise<UpdateTokenResultDto> {
|
||||
return this.authService.updateToken(updateTokenDto);
|
||||
}
|
||||
}
|
||||
8
src/auth/auth.decorator.ts
Normal file
8
src/auth/auth.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
import { AuthGuard } from "./auth.guard";
|
||||
|
||||
export const UserId = createParamDecorator((_, context: ExecutionContext) => {
|
||||
return AuthGuard.extractTokenFromRequest(
|
||||
context.switchToHttp().getRequest(),
|
||||
);
|
||||
});
|
||||
43
src/auth/auth.guard.ts
Normal file
43
src/auth/auth.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
import { UsersService } from "../users/users.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
public static extractTokenFromRequest(req: Request): string | null {
|
||||
const [type, token] = req.headers.authorization?.split(" ") ?? [];
|
||||
return type === "Bearer" ? token : null;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = AuthGuard.extractTokenFromRequest(request);
|
||||
|
||||
if (!token) throw new UnauthorizedException("Не указан токен!");
|
||||
|
||||
try {
|
||||
if (
|
||||
!(await this.jwtService.verifyAsync(token)) ||
|
||||
!(await this.usersService.has({ access_token: token }))
|
||||
) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error();
|
||||
}
|
||||
} catch {
|
||||
throw new UnauthorizedException("Указан неверный токен!");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
src/auth/auth.module.ts
Normal file
23
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { jwtConstants } from "../contants";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
JwtModule.register({
|
||||
global: true,
|
||||
secret: jwtConstants.secret,
|
||||
signOptions: { expiresIn: "720h" },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, UsersService, PrismaService],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
30
src/auth/auth.pipe.ts
Normal file
30
src/auth/auth.pipe.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
Injectable,
|
||||
PipeTransform,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { user } from "@prisma/client";
|
||||
import { UsersService } from "../users/users.service";
|
||||
|
||||
@Injectable()
|
||||
export class UserFromTokenPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
async transform(token: string): Promise<user | null> {
|
||||
const jwt_user: { id: string } = await this.jwtService.decode(token);
|
||||
|
||||
if (!jwt_user)
|
||||
throw new UnauthorizedException("Передан некорректный токен!");
|
||||
|
||||
const user = await this.usersService.findUnique({ id: jwt_user.id });
|
||||
if (!user)
|
||||
throw new UnauthorizedException("Передан некорректный токен!");
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
112
src/auth/auth.service.ts
Normal file
112
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import {
|
||||
SignInDto,
|
||||
SignInResultDto,
|
||||
SignUpDto,
|
||||
SignUpResultDto,
|
||||
UpdateTokenDto,
|
||||
UpdateTokenResultDto,
|
||||
} from "../dto/auth.dto";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { genSalt, hash } from "bcrypt";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async signUp(signUpDto: SignUpDto): Promise<SignUpResultDto> {
|
||||
if (await this.usersService.has({ username: signUpDto.username }))
|
||||
throw new ConflictException(
|
||||
"Пользователь с таким именем уже существует!",
|
||||
);
|
||||
|
||||
const salt = await genSalt(8);
|
||||
const id = new Types.ObjectId().toString("hex");
|
||||
|
||||
const input: Prisma.userCreateInput = {
|
||||
id: id,
|
||||
username: signUpDto.username,
|
||||
salt: salt,
|
||||
password: await hash(signUpDto.password, salt),
|
||||
access_token: await this.jwtService.signAsync({
|
||||
id: id,
|
||||
}),
|
||||
};
|
||||
|
||||
return this.usersService.create(input).then((user) => {
|
||||
return {
|
||||
id: user.id,
|
||||
access_token: user.access_token,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async signIn(signInDto: SignInDto): Promise<SignInResultDto> {
|
||||
const user = await this.usersService.findUnique({
|
||||
username: signInDto.username,
|
||||
});
|
||||
|
||||
if (
|
||||
!user ||
|
||||
user.password !== (await hash(signInDto.password, user.salt))
|
||||
) {
|
||||
throw new UnauthorizedException(
|
||||
"Некорректное имя пользователя или пароль!",
|
||||
);
|
||||
}
|
||||
|
||||
const access_token = await this.jwtService.signAsync({ id: user.id });
|
||||
|
||||
await this.usersService.update({
|
||||
where: { id: user.id },
|
||||
data: { access_token: access_token },
|
||||
});
|
||||
|
||||
return { id: user.id, access_token: access_token };
|
||||
}
|
||||
|
||||
async updateToken(
|
||||
updateTokenDto: UpdateTokenDto,
|
||||
): Promise<UpdateTokenResultDto> {
|
||||
if (
|
||||
!(await this.jwtService.verifyAsync(updateTokenDto.access_token, {
|
||||
ignoreExpiration: true,
|
||||
}))
|
||||
) {
|
||||
throw new NotFoundException(
|
||||
"Некорректный или недействительный токен!",
|
||||
);
|
||||
}
|
||||
|
||||
const jwt_user: { id: string } = await this.jwtService.decode(
|
||||
updateTokenDto.access_token,
|
||||
);
|
||||
|
||||
const user = await this.usersService.findUnique({ id: jwt_user.id });
|
||||
if (!user || user.access_token !== updateTokenDto.access_token) {
|
||||
throw new NotFoundException(
|
||||
"Некорректный или недействительный токен!",
|
||||
);
|
||||
}
|
||||
|
||||
const access_token = await this.jwtService.signAsync({ id: user.id });
|
||||
|
||||
await this.usersService.update({
|
||||
where: { id: user.id },
|
||||
data: { access_token: access_token },
|
||||
});
|
||||
|
||||
return { access_token: access_token };
|
||||
}
|
||||
}
|
||||
6
src/contants.ts
Normal file
6
src/contants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { configDotenv } from "dotenv";
|
||||
configDotenv();
|
||||
|
||||
export const jwtConstants = {
|
||||
secret: process.env.JWT_SECRET!,
|
||||
};
|
||||
22
src/dto/auth.dto.ts
Normal file
22
src/dto/auth.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty, PickType } from "@nestjs/swagger";
|
||||
import { UserDto } from "./user.dto";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class SignInDto extends PickType(UserDto, ["username"]) {
|
||||
@ApiProperty({ description: "Пароль в исходном виде" })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class SignInResultDto extends PickType(UserDto, [
|
||||
"id",
|
||||
"access_token",
|
||||
]) {}
|
||||
|
||||
export class SignUpDto extends SignInDto {}
|
||||
|
||||
export class SignUpResultDto extends SignInResultDto {}
|
||||
|
||||
export class UpdateTokenDto extends PickType(UserDto, ["access_token"]) {}
|
||||
|
||||
export class UpdateTokenResultDto extends UpdateTokenDto {}
|
||||
223
src/dto/schedule.dto.ts
Normal file
223
src/dto/schedule.dto.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsDate,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class LessonTimeDto {
|
||||
@ApiProperty({
|
||||
example: 0,
|
||||
description: "Начало занятия в минутах относительно начала суток",
|
||||
})
|
||||
@IsNumber()
|
||||
start: number;
|
||||
@ApiProperty({
|
||||
example: 60,
|
||||
description: "Конец занятия в минутах относительно начала суток",
|
||||
})
|
||||
@IsNumber()
|
||||
end: number;
|
||||
|
||||
constructor(start: number, end: number) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
|
||||
static fromString(time: string): LessonTimeDto {
|
||||
time = time.replaceAll(".", ":");
|
||||
|
||||
const regex = /(\d+:\d+)-(\d+:\d+)/g;
|
||||
|
||||
const parseResult = regex.exec(time);
|
||||
if (!parseResult) return new LessonTimeDto(0, 0);
|
||||
|
||||
const start = parseResult[1].split(":");
|
||||
const end = parseResult[2].split(":");
|
||||
|
||||
return new LessonTimeDto(
|
||||
Number.parseInt(start[0]) * 60 + Number.parseInt(start[1]),
|
||||
Number.parseInt(end[0]) * 60 + Number.parseInt(end[1]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export enum LessonTypeDto {
|
||||
NONE = 0,
|
||||
DEFAULT,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
export class LessonDto {
|
||||
@ApiProperty({
|
||||
example: LessonTypeDto.DEFAULT,
|
||||
description: "Тип занятия.",
|
||||
})
|
||||
@IsEnum(LessonTypeDto)
|
||||
type: LessonTypeDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: "Элементы высшей математики",
|
||||
description: "Название занятия",
|
||||
})
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: new LessonTimeDto(0, 60),
|
||||
description:
|
||||
"Начало и конец занятия в минутах относительно начала суток",
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => LessonTimeDto)
|
||||
time: LessonTimeDto | null;
|
||||
|
||||
@ApiProperty({ example: ["42", "с\\з"], description: "Кабинеты" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => String)
|
||||
cabinets: Array<string>;
|
||||
|
||||
@ApiProperty({
|
||||
example: ["Хомченко Н.Е."],
|
||||
description: "ФИО преподавателей",
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => String)
|
||||
teacherNames: Array<string>;
|
||||
|
||||
constructor(
|
||||
type: LessonTypeDto,
|
||||
time: LessonTimeDto,
|
||||
name: string,
|
||||
cabinets: Array<string>,
|
||||
teacherNames: Array<string>,
|
||||
) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
this.time = time;
|
||||
this.cabinets = cabinets;
|
||||
this.teacherNames = teacherNames;
|
||||
}
|
||||
}
|
||||
|
||||
export class DayDto {
|
||||
@ApiProperty({
|
||||
example: "Понедельник",
|
||||
description: "День недели",
|
||||
})
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: [0, 1, 3], description: "Индексы занятий" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Number)
|
||||
nonNullIndices: Array<number>;
|
||||
|
||||
@ApiProperty({ example: [1, 3], description: "Индексы полных пар" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Number)
|
||||
defaultIndices: Array<number>;
|
||||
|
||||
@ApiProperty({ example: [0], description: "Индексы доп. занятий" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Number)
|
||||
customIndices: Array<number>;
|
||||
|
||||
@ApiProperty({ example: [], description: "Занятия" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LessonDto)
|
||||
lessons: Array<LessonDto>;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
|
||||
this.nonNullIndices = [];
|
||||
this.defaultIndices = [];
|
||||
this.customIndices = [];
|
||||
|
||||
this.lessons = [];
|
||||
}
|
||||
|
||||
public fillIndices(): void {
|
||||
this.nonNullIndices = [];
|
||||
this.defaultIndices = [];
|
||||
this.customIndices = [];
|
||||
|
||||
for (const lessonRawIdx in this.lessons) {
|
||||
const lessonIdx = Number.parseInt(lessonRawIdx);
|
||||
|
||||
const lesson = this.lessons[lessonIdx];
|
||||
if (lesson.type === LessonTypeDto.NONE) continue;
|
||||
|
||||
this.nonNullIndices.push(lessonIdx);
|
||||
|
||||
(lesson.type === LessonTypeDto.DEFAULT
|
||||
? this.defaultIndices
|
||||
: this.customIndices
|
||||
).push(lessonIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupDto {
|
||||
@ApiProperty({
|
||||
example: "ИС-214/23",
|
||||
description: "Название группы",
|
||||
})
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: [], description: "Дни недели" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DayDto)
|
||||
days: Array<DayDto>;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
this.days = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class ScheduleDto {
|
||||
@ApiProperty({
|
||||
example: new Date(),
|
||||
description:
|
||||
"Дата когда последний раз расписание было скачано с сервера политехникума",
|
||||
})
|
||||
@IsDate()
|
||||
updatedAt: Date;
|
||||
|
||||
@ApiProperty({
|
||||
example: '"66d88751-1b800"',
|
||||
description: "ETag файла с расписанием на сервере политехникума",
|
||||
})
|
||||
@IsString()
|
||||
etag: string;
|
||||
|
||||
@ApiProperty({ description: "Расписание группы" })
|
||||
@IsObject()
|
||||
data: GroupDto;
|
||||
|
||||
@ApiProperty({
|
||||
example: [5, 6],
|
||||
description: "Обновлённые дни с последнего изменения расписания",
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Number)
|
||||
lastChangedDays: Array<number>;
|
||||
}
|
||||
34
src/dto/user.dto.ts
Normal file
34
src/dto/user.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
||||
import {
|
||||
IsJWT,
|
||||
IsMongoId,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class UserDto {
|
||||
@ApiProperty({ description: "Идентификатор (ObjectId)" })
|
||||
@IsMongoId()
|
||||
id: string;
|
||||
@ApiProperty({ example: "n08i40k", description: "Имя" })
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
@MaxLength(10)
|
||||
username: string;
|
||||
@ApiProperty({ description: "Соль пароля" })
|
||||
@IsString()
|
||||
salt: string;
|
||||
@ApiProperty({ description: "Хеш пароля" })
|
||||
@IsString()
|
||||
password: string;
|
||||
@ApiProperty({ description: "Последний токен доступа" })
|
||||
@IsJWT()
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export class ClientUserDto extends OmitType(UserDto, [
|
||||
"password",
|
||||
"salt",
|
||||
"access_token",
|
||||
]) {}
|
||||
27
src/main.ts
27
src/main.ts
@@ -1,8 +1,35 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module";
|
||||
import { ValidatorOptions } from "class-validator";
|
||||
import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe";
|
||||
import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const validatorOptions: ValidatorOptions = {
|
||||
enableDebugMessages: true,
|
||||
forbidNonWhitelisted: true,
|
||||
whitelist: true,
|
||||
};
|
||||
app.useGlobalPipes(new PartialValidationPipe(validatorOptions));
|
||||
app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions));
|
||||
app.enableCors();
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle("Schedule Parser")
|
||||
.setDescription("Парсер расписания")
|
||||
.setVersion("1.0")
|
||||
.build();
|
||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
swaggerDocument.servers = [
|
||||
{
|
||||
url: "http://localhost:3000",
|
||||
description: "Локальный сервер для разработки",
|
||||
},
|
||||
];
|
||||
SwaggerModule.setup("api-docs", app, swaggerDocument);
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
|
||||
|
||||
16
src/prisma/prisma.service.ts
Normal file
16
src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
295
src/schedule/internal/schedule-parser/schedule-parser.ts
Normal file
295
src/schedule/internal/schedule-parser/schedule-parser.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
XlsDownloaderBase,
|
||||
XlsDownloaderCacheMode,
|
||||
XlsDownloaderResult,
|
||||
} from "../xls-downloader/xls-downloader.base";
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
DayDto,
|
||||
GroupDto,
|
||||
LessonDto,
|
||||
LessonTimeDto,
|
||||
LessonTypeDto,
|
||||
} from "../../../dto/schedule.dto";
|
||||
import { trimAll } from "../../../utility/string.util";
|
||||
|
||||
type InternalId = { row: number; column: number; name: string };
|
||||
type InternalDay = InternalId & { lessons: Array<InternalId> };
|
||||
|
||||
export type ScheduleParseResult = {
|
||||
etag: string;
|
||||
group: GroupDto;
|
||||
affectedDays: Array<number>;
|
||||
};
|
||||
|
||||
export class ScheduleParser {
|
||||
private lastResult: ScheduleParseResult | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly xlsDownloader: XlsDownloaderBase,
|
||||
private readonly group: string,
|
||||
) {}
|
||||
|
||||
private static getCellName(
|
||||
worksheet: XLSX.Sheet,
|
||||
row: number,
|
||||
column: number,
|
||||
): any | null {
|
||||
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: column })];
|
||||
return cell ? cell.v : null;
|
||||
}
|
||||
|
||||
private parseTeacherFullNames(lessonName: string): {
|
||||
name: string;
|
||||
teacherFullNames: Array<string>;
|
||||
} {
|
||||
const firstRegex =
|
||||
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.[А-ЯЁ]\.(?:\s\([0-9] подгруппа\))?(?:,\s)?)+$/gm;
|
||||
const secondRegex =
|
||||
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.[А-ЯЁ]\.(?:\s\([0-9] подгруппа\))?)+/gm;
|
||||
|
||||
const fm = firstRegex.exec(lessonName);
|
||||
if (fm === null) return { name: lessonName, teacherFullNames: [] };
|
||||
|
||||
const teacherFullNames: Array<string> = [];
|
||||
|
||||
let teacherFullNameMatch: RegExpExecArray;
|
||||
while ((teacherFullNameMatch = secondRegex.exec(fm[0])) !== null) {
|
||||
if (teacherFullNameMatch.index === secondRegex.lastIndex)
|
||||
secondRegex.lastIndex++;
|
||||
|
||||
teacherFullNames.push(teacherFullNameMatch[0].trim());
|
||||
}
|
||||
|
||||
if (teacherFullNames.length === 0)
|
||||
return { name: lessonName, teacherFullNames: [] };
|
||||
|
||||
return {
|
||||
name: lessonName.substring(0, fm.index).trim(),
|
||||
teacherFullNames: teacherFullNames,
|
||||
};
|
||||
}
|
||||
|
||||
parseSkeleton(worksheet: XLSX.Sheet): {
|
||||
groupSkeleton: InternalId;
|
||||
daySkeletons: Array<InternalDay>;
|
||||
} {
|
||||
const range = XLSX.utils.decode_range(worksheet["!ref"] || "");
|
||||
let isHeaderParsed: boolean = false;
|
||||
|
||||
let group: InternalId = null;
|
||||
const days: Array<InternalDay> = [];
|
||||
|
||||
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
|
||||
const dayName = ScheduleParser.getCellName(worksheet, row, 0);
|
||||
if (!dayName) continue;
|
||||
|
||||
if (!isHeaderParsed) {
|
||||
isHeaderParsed = true;
|
||||
|
||||
--row;
|
||||
for (
|
||||
let column = range.s.c + 2;
|
||||
column <= range.e.c;
|
||||
++column
|
||||
) {
|
||||
const groupName = ScheduleParser.getCellName(
|
||||
worksheet,
|
||||
row,
|
||||
column,
|
||||
);
|
||||
if (!groupName || this.group !== groupName) continue;
|
||||
|
||||
group = { row: row, column: column, name: groupName };
|
||||
break;
|
||||
}
|
||||
++row;
|
||||
}
|
||||
|
||||
days.push({ row: row, column: 0, name: dayName, lessons: [] });
|
||||
|
||||
if (
|
||||
days.length > 2 &&
|
||||
days[days.length - 2].name.startsWith("Суббота")
|
||||
)
|
||||
break;
|
||||
}
|
||||
|
||||
return { daySkeletons: days, groupSkeleton: group };
|
||||
}
|
||||
|
||||
async getSchedule(
|
||||
forceCached: boolean = false,
|
||||
): Promise<ScheduleParseResult> {
|
||||
let downloadData: XlsDownloaderResult;
|
||||
|
||||
if (
|
||||
!forceCached ||
|
||||
(downloadData = await this.xlsDownloader.getCachedXLS()) === null
|
||||
) {
|
||||
console.debug("Обновление кеша...");
|
||||
downloadData = await this.xlsDownloader.downloadXLS();
|
||||
|
||||
if (
|
||||
!downloadData.new &&
|
||||
this.lastResult &&
|
||||
this.xlsDownloader.getCacheMode() != XlsDownloaderCacheMode.NONE
|
||||
) {
|
||||
console.debug(
|
||||
"Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...",
|
||||
);
|
||||
console.debug("будет возвращён предыдущий результат.");
|
||||
|
||||
return this.lastResult;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug("Чтение кешированного XLS документа...");
|
||||
|
||||
const workBook = XLSX.read(downloadData.fileData);
|
||||
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
|
||||
|
||||
const { groupSkeleton, daySkeletons } = this.parseSkeleton(workSheet);
|
||||
|
||||
const group = new GroupDto(groupSkeleton.name);
|
||||
|
||||
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
|
||||
const daySkeleton = daySkeletons[dayIdx];
|
||||
const day = new DayDto(daySkeleton.name);
|
||||
|
||||
const lessonTimeColumn = daySkeletons[0].column + 1;
|
||||
const rowDistance = daySkeletons[dayIdx + 1].row - daySkeleton.row;
|
||||
|
||||
for (
|
||||
let row = daySkeleton.row;
|
||||
row < daySkeleton.row + rowDistance;
|
||||
++row
|
||||
) {
|
||||
const time = ScheduleParser.getCellName(
|
||||
workSheet,
|
||||
row,
|
||||
lessonTimeColumn,
|
||||
)?.replaceAll(" ", "");
|
||||
if (!time || typeof time !== "string") continue;
|
||||
|
||||
const rawName = ScheduleParser.getCellName(
|
||||
workSheet,
|
||||
row,
|
||||
groupSkeleton.column,
|
||||
);
|
||||
const cabinets: Array<string> = [];
|
||||
|
||||
const rawCabinets = String(
|
||||
ScheduleParser.getCellName(
|
||||
workSheet,
|
||||
row,
|
||||
groupSkeleton.column + 1,
|
||||
),
|
||||
);
|
||||
if (rawCabinets !== "null") {
|
||||
const rawLessonCabinetParts = rawCabinets.split(/(\n|\s)/g);
|
||||
|
||||
for (const cabinet of rawLessonCabinetParts) {
|
||||
if (
|
||||
cabinet.length === 0 ||
|
||||
cabinet === " " ||
|
||||
cabinet === "\n"
|
||||
)
|
||||
continue;
|
||||
|
||||
cabinets.push(cabinet);
|
||||
}
|
||||
}
|
||||
|
||||
const type =
|
||||
!rawName || rawName.length === 0
|
||||
? LessonTypeDto.NONE
|
||||
: time?.includes("пара")
|
||||
? LessonTypeDto.DEFAULT
|
||||
: LessonTypeDto.CUSTOM;
|
||||
|
||||
const { name, teacherFullNames } = this.parseTeacherFullNames(
|
||||
trimAll(rawName?.replace("\n", "") ?? ""),
|
||||
);
|
||||
|
||||
day.lessons.push(
|
||||
new LessonDto(
|
||||
type,
|
||||
LessonTimeDto.fromString(
|
||||
type === LessonTypeDto.DEFAULT
|
||||
? time.substring(5)
|
||||
: time,
|
||||
),
|
||||
name,
|
||||
cabinets,
|
||||
teacherFullNames,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
day.fillIndices();
|
||||
group.days.push(day);
|
||||
}
|
||||
|
||||
return (this.lastResult = {
|
||||
etag: downloadData.etag,
|
||||
group: group,
|
||||
affectedDays: this.getAffectedDays(this.lastResult?.group, group),
|
||||
});
|
||||
}
|
||||
|
||||
public getLastResult(): ScheduleParseResult | null {
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
private getAffectedDays(
|
||||
cachedGroup: GroupDto | null,
|
||||
group: GroupDto,
|
||||
): Array<number> {
|
||||
const affectedDays: Array<number> = [];
|
||||
|
||||
if (!cachedGroup) return affectedDays;
|
||||
|
||||
// noinspection SpellCheckingInspection
|
||||
const dayEquals = (lday: DayDto | null, rday: DayDto): boolean => {
|
||||
if (
|
||||
rday === undefined ||
|
||||
rday.lessons.length != lday.lessons.length
|
||||
)
|
||||
return false;
|
||||
|
||||
for (const lessonIdx in lday.lessons) {
|
||||
// noinspection SpellCheckingInspection
|
||||
const llesson = lday.lessons[lessonIdx];
|
||||
// noinspection SpellCheckingInspection
|
||||
const rlesson = rday.lessons[lessonIdx];
|
||||
if (
|
||||
llesson.name.length > 0 &&
|
||||
(llesson.name !== rlesson.name ||
|
||||
llesson.time.start !== rlesson.time.start ||
|
||||
llesson.time.end !== rlesson.time.end ||
|
||||
llesson.cabinets.toString() !==
|
||||
rlesson.cabinets.toString() ||
|
||||
llesson.teacherNames.toString() !==
|
||||
rlesson.teacherNames.toString())
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const dayIdx in group.days) {
|
||||
// noinspection SpellCheckingInspection
|
||||
const lday = group.days[dayIdx];
|
||||
// noinspection SpellCheckingInspection
|
||||
const rday = cachedGroup.days[dayIdx];
|
||||
|
||||
if (!dayEquals(lday, rday))
|
||||
affectedDays.push(Number.parseInt(dayIdx));
|
||||
}
|
||||
|
||||
return affectedDays;
|
||||
}
|
||||
}
|
||||
92
src/schedule/internal/xls-downloader/basic-xls-downloader.ts
Normal file
92
src/schedule/internal/xls-downloader/basic-xls-downloader.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
XlsDownloaderBase,
|
||||
XlsDownloaderCacheMode,
|
||||
XlsDownloaderResult,
|
||||
} from "./xls-downloader.base";
|
||||
import axios from "axios";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
export class BasicXlsDownloader extends XlsDownloaderBase {
|
||||
cache: XlsDownloaderResult | null = null;
|
||||
|
||||
private async getDOM(): Promise<JSDOM> {
|
||||
const response = await axios.get(this.url);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Не удалось получить данные с основной страницы!
|
||||
Статус код: ${response.status}
|
||||
${response.statusText}`);
|
||||
}
|
||||
|
||||
return new JSDOM(response.data, {
|
||||
url: this.url,
|
||||
contentType: "text/html",
|
||||
});
|
||||
}
|
||||
|
||||
private parseData(dom: JSDOM): {
|
||||
downloadLink: string;
|
||||
updateDate: string;
|
||||
} {
|
||||
const schedule_block = dom.window.document.getElementById("cont-i");
|
||||
if (schedule_block === null)
|
||||
throw new Error("Не удалось найти блок расписаний!");
|
||||
|
||||
const schedules = schedule_block.getElementsByTagName("div");
|
||||
if (schedules === null || schedules.length === 0)
|
||||
throw new Error("Не удалось найти строку с расписанием!");
|
||||
|
||||
const poltavskaya = schedules[0];
|
||||
const link = poltavskaya.getElementsByTagName("a")[0]!;
|
||||
|
||||
const spans = poltavskaya.getElementsByTagName("span");
|
||||
const update_date = spans[3].textContent!.trimStart();
|
||||
|
||||
return {
|
||||
downloadLink: link.href,
|
||||
updateDate: update_date,
|
||||
};
|
||||
}
|
||||
|
||||
public async getCachedXLS(): Promise<XlsDownloaderResult | null> {
|
||||
if (this.cache === null) return null;
|
||||
|
||||
this.cache.new = this.cacheMode === XlsDownloaderCacheMode.HARD;
|
||||
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
public async downloadXLS(): Promise<XlsDownloaderResult> {
|
||||
if (
|
||||
this.cacheMode === XlsDownloaderCacheMode.HARD &&
|
||||
this.cache !== null
|
||||
)
|
||||
return this.getCachedXLS();
|
||||
|
||||
const dom = await this.getDOM();
|
||||
const parse_data = this.parseData(dom);
|
||||
|
||||
const response = await axios.get(parse_data.downloadLink, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Не удалось получить excel файл!
|
||||
Статус код: ${response.status}
|
||||
${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: XlsDownloaderResult = {
|
||||
fileData: response.data.buffer,
|
||||
updateDate: parse_data.updateDate,
|
||||
etag: response.headers["etag"],
|
||||
new:
|
||||
this.cacheMode === XlsDownloaderCacheMode.NONE
|
||||
? true
|
||||
: this.cache?.etag !== response.headers["etag"],
|
||||
};
|
||||
|
||||
if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
27
src/schedule/internal/xls-downloader/xls-downloader.base.ts
Normal file
27
src/schedule/internal/xls-downloader/xls-downloader.base.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type XlsDownloaderResult = {
|
||||
fileData: ArrayBuffer;
|
||||
updateDate: string;
|
||||
etag: string;
|
||||
new: boolean;
|
||||
};
|
||||
|
||||
export enum XlsDownloaderCacheMode {
|
||||
NONE = 0,
|
||||
SOFT, // читать кеш только если не был изменён etag.
|
||||
HARD, // читать кеш всегда, кроме случаев его отсутствия
|
||||
}
|
||||
|
||||
export abstract class XlsDownloaderBase {
|
||||
public constructor(
|
||||
protected readonly url: string,
|
||||
protected readonly cacheMode = XlsDownloaderCacheMode.NONE,
|
||||
) {}
|
||||
|
||||
public abstract downloadXLS(): Promise<XlsDownloaderResult>;
|
||||
|
||||
public abstract getCachedXLS(): Promise<XlsDownloaderResult | null>;
|
||||
|
||||
public getCacheMode(): XlsDownloaderCacheMode {
|
||||
return this.cacheMode;
|
||||
}
|
||||
}
|
||||
36
src/schedule/schedule.controller.ts
Normal file
36
src/schedule/schedule.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/auth.guard";
|
||||
import { ScheduleService } from "./schedule.service";
|
||||
import { ScheduleDto } from "../dto/schedule.dto";
|
||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||
import {
|
||||
ApiExtraModels,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
refs,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
@Controller("api/v1/schedule")
|
||||
@UseGuards(AuthGuard)
|
||||
export class ScheduleController {
|
||||
constructor(private scheduleService: ScheduleService) {}
|
||||
|
||||
@ApiExtraModels(ScheduleDto)
|
||||
@ApiOperation({ summary: "Получение расписания", tags: ["schedule"] })
|
||||
@ApiOkResponse({
|
||||
description: "Расписание получено успешно",
|
||||
schema: refs(ScheduleDto)[0],
|
||||
})
|
||||
@ResultDto(ScheduleDto)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Get("get")
|
||||
getSchedule(): Promise<ScheduleDto> {
|
||||
return this.scheduleService.getSchedule();
|
||||
}
|
||||
}
|
||||
13
src/schedule/schedule.module.ts
Normal file
13
src/schedule/schedule.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ScheduleService } from "./schedule.service";
|
||||
import { ScheduleController } from "./schedule.controller";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [ScheduleService, UsersService, PrismaService],
|
||||
controllers: [ScheduleController],
|
||||
exports: [ScheduleService],
|
||||
})
|
||||
export class ScheduleModule {}
|
||||
43
src/schedule/schedule.service.ts
Normal file
43
src/schedule/schedule.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
ScheduleParser,
|
||||
ScheduleParseResult,
|
||||
} from "./internal/schedule-parser/schedule-parser";
|
||||
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
|
||||
import { XlsDownloaderCacheMode } from "./internal/xls-downloader/xls-downloader.base";
|
||||
import { ScheduleDto } from "../dto/schedule.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleService {
|
||||
private readonly scheduleParser = new ScheduleParser(
|
||||
new BasicXlsDownloader(
|
||||
"https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409",
|
||||
XlsDownloaderCacheMode.SOFT,
|
||||
),
|
||||
"ИС-214/23",
|
||||
);
|
||||
|
||||
private lastCacheUpdate: Date = new Date(0);
|
||||
private lastChangedDays: Array<number> = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
async getSchedule(): Promise<ScheduleDto> {
|
||||
const now = new Date();
|
||||
const cacheExpired =
|
||||
(this.lastCacheUpdate.valueOf() - now.valueOf()) / 1000 / 60 > 5;
|
||||
|
||||
if (cacheExpired) this.lastCacheUpdate = now;
|
||||
|
||||
const schedule = await this.scheduleParser.getSchedule(!cacheExpired);
|
||||
if (schedule.affectedDays.length !== 0)
|
||||
this.lastChangedDays = schedule.affectedDays;
|
||||
|
||||
return {
|
||||
updatedAt: this.lastCacheUpdate,
|
||||
data: schedule.group,
|
||||
etag: schedule.etag,
|
||||
lastChangedDays: this.lastChangedDays,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/users/users.module.ts
Normal file
9
src/users/users.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
31
src/users/users.service.ts
Normal file
31
src/users/users.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { Prisma, user } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
async findUnique(where: Prisma.userWhereUniqueInput): Promise<user | null> {
|
||||
return this.prismaService.user.findUnique({ where: where });
|
||||
}
|
||||
|
||||
async findOne(where: Prisma.userWhereInput): Promise<user | null> {
|
||||
return this.prismaService.user.findFirst({ where: where });
|
||||
}
|
||||
|
||||
async update(params: {
|
||||
where: Prisma.userWhereUniqueInput;
|
||||
data: Prisma.userUpdateInput;
|
||||
}): Promise<user | null> {
|
||||
return this.prismaService.user.update(params);
|
||||
}
|
||||
|
||||
async create(data: Prisma.userCreateInput): Promise<user> {
|
||||
return this.prismaService.user.create({ data });
|
||||
}
|
||||
|
||||
async has(where: Prisma.userWhereUniqueInput): Promise<boolean> {
|
||||
return (await this.prismaService.user.count({ where })) > 0;
|
||||
}
|
||||
}
|
||||
20
src/utility/parse-pipe/object-id.pipe.ts
Normal file
20
src/utility/parse-pipe/object-id.pipe.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class ObjectIdPipe implements PipeTransform<any, string> {
|
||||
transform(value: any): string {
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value !== "string" ||
|
||||
value.length !== 24
|
||||
)
|
||||
throw new BadRequestException("Invalid ObjectId");
|
||||
|
||||
const return_string = value.toLowerCase();
|
||||
if (!/^[0-9a-f]{24}$/.test(return_string))
|
||||
throw new BadRequestException("Invalid ObjectId");
|
||||
|
||||
return return_string;
|
||||
}
|
||||
}
|
||||
9
src/utility/prisma/convert.helper.ts
Normal file
9
src/utility/prisma/convert.helper.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
type Nullable<T> = {
|
||||
[P in keyof T]: T[P] | null | Array<any>;
|
||||
};
|
||||
|
||||
export function convertToPrismaInput<T>(dto: Nullable<T>): T {
|
||||
return Object.entries(dto)
|
||||
.filter((x) => x[1] !== undefined)
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) as T;
|
||||
}
|
||||
31
src/utility/string.util.ts
Normal file
31
src/utility/string.util.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export function trimAll(str: string): string {
|
||||
return str.replace(/\s\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
const customLessonIdxToTextPresets = [
|
||||
"Первое",
|
||||
"Второе",
|
||||
"Третье",
|
||||
"Четвёртое",
|
||||
"Пятое",
|
||||
"Шестое",
|
||||
"Седьмое",
|
||||
];
|
||||
|
||||
export function customLessonIdxToText(num: number): string {
|
||||
return customLessonIdxToTextPresets[num];
|
||||
}
|
||||
|
||||
const defaultLessonIdxToTextPresets = [
|
||||
"Первая",
|
||||
"Вторая",
|
||||
"Третья",
|
||||
"Четвёртая",
|
||||
"Пятая",
|
||||
"Шестая",
|
||||
"Седьмая",
|
||||
];
|
||||
|
||||
export function defaultLessonIdxToText(num: number): string {
|
||||
return defaultLessonIdxToTextPresets[num];
|
||||
}
|
||||
78
src/utility/validation/class-validator.interceptor.ts
Normal file
78
src/utility/validation/class-validator.interceptor.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import "reflect-metadata";
|
||||
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NestInterceptor,
|
||||
UnprocessableEntityException,
|
||||
} from "@nestjs/common";
|
||||
import { map, Observable } from "rxjs";
|
||||
import { instanceToPlain, plainToInstance } from "class-transformer";
|
||||
import { validate, ValidationOptions } from "class-validator";
|
||||
|
||||
@Injectable()
|
||||
export class ClassValidatorInterceptor implements NestInterceptor {
|
||||
constructor(private readonly validatorOptions: ValidationOptions) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<any>,
|
||||
): Observable<any> | Promise<Observable<any>> {
|
||||
return next.handle().pipe(
|
||||
map(async (returnValue: any) => {
|
||||
const handler = context.getHandler();
|
||||
const cls = context.getClass();
|
||||
|
||||
const classDto = Reflect.getMetadata(
|
||||
"design:result-dto",
|
||||
cls.prototype,
|
||||
handler.name,
|
||||
);
|
||||
|
||||
if (classDto === undefined) {
|
||||
console.warn(
|
||||
`Undefined DTO type for function \"${cls.name}::${handler.name}\"!`,
|
||||
);
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
const returnValueDto = plainToInstance(
|
||||
classDto,
|
||||
instanceToPlain(returnValue),
|
||||
);
|
||||
|
||||
if (!(returnValueDto instanceof Object))
|
||||
throw new InternalServerErrorException(
|
||||
returnValueDto,
|
||||
"Return value is not object!",
|
||||
);
|
||||
|
||||
const validationErrors = await validate(
|
||||
returnValueDto,
|
||||
this.validatorOptions,
|
||||
);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
throw new UnprocessableEntityException({
|
||||
message: validationErrors
|
||||
.map((value) => Object.values(value.constraints))
|
||||
.flat(),
|
||||
object: returnValue,
|
||||
error: "Response Validation Failed",
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
});
|
||||
}
|
||||
return returnValue;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function ResultDto(type: any) {
|
||||
return (target: NonNullable<unknown>, propertyKey: string | symbol) => {
|
||||
Reflect.defineMetadata("design:result-dto", type, target, propertyKey);
|
||||
};
|
||||
}
|
||||
37
src/utility/validation/partial-validation.pipe.ts
Normal file
37
src/utility/validation/partial-validation.pipe.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
PipeTransform,
|
||||
Type,
|
||||
ValidationPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ValidationPipeOptions } from "@nestjs/common/pipes/validation.pipe";
|
||||
|
||||
export class PartialValidationPipe implements PipeTransform {
|
||||
private readonly validationPipe: ValidationPipe;
|
||||
private readonly partialValidationPipe: ValidationPipe;
|
||||
|
||||
constructor(options?: ValidationPipeOptions) {
|
||||
this.validationPipe = new ValidationPipe(options);
|
||||
this.partialValidationPipe = new ValidationPipe({
|
||||
...options,
|
||||
...{
|
||||
skipUndefinedProperties: true,
|
||||
skipNullValues: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
canBePartial(metatype?: Type): boolean {
|
||||
if (metatype === undefined) return false;
|
||||
return (
|
||||
["Update"].find((kw) => metatype.name.includes(kw)) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
transform(value: any, metadata: ArgumentMetadata): any {
|
||||
if (metadata.type == "body" && this.canBePartial(metadata.metatype))
|
||||
return this.partialValidationPipe.transform(value, metadata);
|
||||
|
||||
return this.validationPipe.transform(value, metadata);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user