mirror of
https://github.com/n08i40k/schedule-parser-next.git
synced 2025-12-06 09:47:46 +03:00
1.0.0
This commit is contained in:
752
package-lock.json
generated
752
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "schedule-parser-next",
|
"name": "schedule-parser-next",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -25,14 +25,19 @@
|
|||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
|
"@prisma/client": "^5.19.1",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"js-xlsx": "^0.8.22",
|
"jsdom": "^25.0.0",
|
||||||
|
"mongoose": "^8.6.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
@@ -42,6 +47,7 @@
|
|||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/node": "^20.16.5",
|
"@types/node": "^20.16.5",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|||||||
25
prisma/schema.prisma
Normal file
25
prisma/schema.prisma
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||||
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mongodb"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model user {
|
||||||
|
id String @id @map("_id") @db.ObjectId
|
||||||
|
//
|
||||||
|
username String @unique
|
||||||
|
//
|
||||||
|
salt String
|
||||||
|
password String
|
||||||
|
//
|
||||||
|
access_token String @unique
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { AppController } from "./app.controller";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { AppService } from "./app.service";
|
import { UsersModule } from "./users/users.module";
|
||||||
|
import { ScheduleModule } from "./schedule/schedule.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [AuthModule, UsersModule, ScheduleModule],
|
||||||
controllers: [AppController],
|
controllers: [],
|
||||||
providers: [AppService],
|
providers: [],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { NestFactory } from "@nestjs/core";
|
||||||
import { AppModule } from "./app.module";
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
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);
|
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