This commit is contained in:
2024-09-06 23:13:44 +04:00
parent 2b2018c317
commit 31906fbbd1
29 changed files with 2061 additions and 90 deletions

752
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
}

View File

@@ -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 {}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
]) {}

View File

@@ -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);
} }

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}

View 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 {}

View 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,
};
}
}

View 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 {}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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];
}

View 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);
};
}

View 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);
}
}