mirror of
https://github.com/n08i40k/schedule-parser-next.git
synced 2025-12-06 09:47:46 +03:00
2.0.0
Я пока перечислю - умру. Надо научиться писать changelog постепенно.
This commit is contained in:
@@ -5,7 +5,12 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@nestjs/swagger"
|
{
|
||||||
|
"name": "@nestjs/swagger",
|
||||||
|
"options": {
|
||||||
|
"introspectComments": true
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
700
package-lock.json
generated
700
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "schedule-parser-next",
|
"name": "schedule-parser-next",
|
||||||
"version": "1.4.0",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "N08I40K",
|
"author": "N08I40K",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/cache-manager": "^2.2.2",
|
"@nestjs/cache-manager": "^2.2.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/core": "^10.4.4",
|
"@nestjs/core": "^10.4.5",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/platform-express": "^10.4.4",
|
"@nestjs/platform-express": "^10.4.4",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^25.0.0",
|
||||||
"mongoose": "^8.6.1",
|
"mongoose": "^8.6.1",
|
||||||
|
"nest-redoc": "^1.1.2",
|
||||||
|
"object-hash": "^3.0.0",
|
||||||
"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",
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20.16.5",
|
"@types/node": "^20.16.5",
|
||||||
|
"@types/object-hash": "^3.0.6",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Reflector } from "@nestjs/core";
|
|
||||||
import { UserRoleDto } from "../dto/user.dto";
|
|
||||||
|
|
||||||
export const AuthRoles = Reflector.createDecorator<UserRoleDto[]>();
|
|
||||||
export const AuthUnauthorized = Reflector.createDecorator<true>();
|
|
||||||
6
src/auth/auth-role.decorator.ts
Normal file
6
src/auth/auth-role.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
|
||||||
|
import { UserRole } from "../users/user-role.enum";
|
||||||
|
|
||||||
|
export const AuthRoles = Reflector.createDecorator<UserRole[]>();
|
||||||
|
export const AuthUnauthorized = Reflector.createDecorator<true>();
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
NotFoundException,
|
|
||||||
Post,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { AuthService } from "./auth.service";
|
|
||||||
import {
|
|
||||||
ApiBody,
|
|
||||||
ApiConflictResponse,
|
|
||||||
ApiCreatedResponse,
|
|
||||||
ApiExtraModels,
|
|
||||||
ApiNotFoundResponse,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiOperation,
|
|
||||||
ApiUnauthorizedResponse,
|
|
||||||
refs,
|
|
||||||
} from "@nestjs/swagger";
|
|
||||||
import {
|
|
||||||
SignInReqDto,
|
|
||||||
SignInResDto,
|
|
||||||
SignUpReqDto,
|
|
||||||
SignUpResDto,
|
|
||||||
ChangePasswordReqDto,
|
|
||||||
UpdateTokenReqDto,
|
|
||||||
UpdateTokenResDto,
|
|
||||||
SignInResDtoV0,
|
|
||||||
SignInResDtoV2,
|
|
||||||
} from "../dto/auth.dto";
|
|
||||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
|
||||||
import { ScheduleService } from "../schedule/schedule.service";
|
|
||||||
import { UserToken } from "./auth.decorator";
|
|
||||||
import { ResponseVersion } from "../version/response-version.decorator";
|
|
||||||
|
|
||||||
@Controller("api/v1/auth")
|
|
||||||
export class AuthController {
|
|
||||||
constructor(
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
private readonly scheduleService: ScheduleService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@ApiExtraModels(SignInReqDto)
|
|
||||||
@ApiExtraModels(SignInResDtoV0)
|
|
||||||
@ApiExtraModels(SignInResDtoV2)
|
|
||||||
@ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] })
|
|
||||||
@ApiBody({ schema: refs(SignInReqDto)[0] })
|
|
||||||
@ApiOkResponse({
|
|
||||||
description: "Авторизация прошла успешно",
|
|
||||||
schema: refs(SignInResDtoV0)[0],
|
|
||||||
})
|
|
||||||
@ApiOkResponse({
|
|
||||||
description: "Авторизация прошла успешно",
|
|
||||||
schema: refs(SignInResDtoV2)[0],
|
|
||||||
})
|
|
||||||
@ApiUnauthorizedResponse({
|
|
||||||
description: "Некорректное имя пользователя или пароль",
|
|
||||||
})
|
|
||||||
@ResultDto([SignInResDtoV0, SignInResDtoV2])
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post("sign-in")
|
|
||||||
async signIn(
|
|
||||||
@Body() signInDto: SignInReqDto,
|
|
||||||
@ResponseVersion() responseVersion: number,
|
|
||||||
): Promise<SignInResDtoV2 | SignInResDtoV0> {
|
|
||||||
const data = await this.authService.signIn(signInDto);
|
|
||||||
return SignInResDto.stripVersion(data, responseVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiExtraModels(SignUpReqDto)
|
|
||||||
@ApiExtraModels(SignUpResDto)
|
|
||||||
@ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] })
|
|
||||||
@ApiBody({ schema: refs(SignUpReqDto)[0] })
|
|
||||||
@ApiCreatedResponse({
|
|
||||||
description: "Регистрация прошла успешно",
|
|
||||||
schema: refs(SignUpResDto)[0],
|
|
||||||
})
|
|
||||||
@ApiConflictResponse({
|
|
||||||
description: "Такой пользователь уже существует",
|
|
||||||
})
|
|
||||||
@ResultDto(SignUpResDto)
|
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
@Post("sign-up")
|
|
||||||
async signUp(@Body() signUpDto: SignUpReqDto) {
|
|
||||||
if (
|
|
||||||
!(await this.scheduleService.getGroupNames()).names.includes(
|
|
||||||
signUpDto.group.replaceAll(" ", ""),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
"Передано название несуществующей группы",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.authService.signUp(signUpDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiExtraModels(UpdateTokenReqDto)
|
|
||||||
@ApiExtraModels(UpdateTokenResDto)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Обновление просроченного токена",
|
|
||||||
tags: ["auth", "access-token"],
|
|
||||||
})
|
|
||||||
@ApiBody({ schema: refs(UpdateTokenReqDto)[0] })
|
|
||||||
@ApiOkResponse({
|
|
||||||
description: "Токен обновлён успешно",
|
|
||||||
schema: refs(UpdateTokenResDto)[0],
|
|
||||||
})
|
|
||||||
@ApiNotFoundResponse({
|
|
||||||
description: "Передан несуществующий или недействительный токен",
|
|
||||||
})
|
|
||||||
@ResultDto(UpdateTokenResDto)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post("update-token")
|
|
||||||
updateToken(
|
|
||||||
@Body() updateTokenDto: UpdateTokenReqDto,
|
|
||||||
): Promise<UpdateTokenResDto> {
|
|
||||||
return this.authService.updateToken(updateTokenDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiExtraModels(ChangePasswordReqDto)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Обновление пароля",
|
|
||||||
tags: ["auth", "password"],
|
|
||||||
})
|
|
||||||
@ApiBody({ schema: refs(ChangePasswordReqDto)[0] })
|
|
||||||
@ApiOkResponse({ description: "Пароль обновлён успешно" })
|
|
||||||
@ApiConflictResponse({ description: "Пароли идентичны" })
|
|
||||||
@ApiUnauthorizedResponse({
|
|
||||||
description:
|
|
||||||
"Передан неверный текущий пароль или запрос был послан без токена",
|
|
||||||
})
|
|
||||||
@ResultDto(null)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post("change-password")
|
|
||||||
async changePassword(
|
|
||||||
@Body() changePasswordReqDto: ChangePasswordReqDto,
|
|
||||||
@UserToken() userToken: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.authService
|
|
||||||
.decodeUserToken(userToken)
|
|
||||||
.then((user) =>
|
|
||||||
this.authService.changePassword(user, changePasswordReqDto),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import { JwtService } from "@nestjs/jwt";
|
|||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { AuthRoles, AuthUnauthorized } from "../auth-role/auth-role.decorator";
|
import { AuthRoles, AuthUnauthorized } from "./auth-role.decorator";
|
||||||
import { isJWT } from "class-validator";
|
import { isJWT } from "class-validator";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { forwardRef, Module } from "@nestjs/common";
|
|||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { jwtConstants } from "../contants";
|
import { jwtConstants } from "../contants";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthController } from "./auth.controller";
|
import { V1AuthController } from "./v1-auth.controller";
|
||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ScheduleModule } from "../schedule/schedule.module";
|
import { ScheduleModule } from "../schedule/schedule.module";
|
||||||
|
import { V2AuthController } from "./v2-auth.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,7 +19,7 @@ import { ScheduleModule } from "../schedule/schedule.module";
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, PrismaService],
|
providers: [AuthService, PrismaService],
|
||||||
controllers: [AuthController],
|
controllers: [V1AuthController, V2AuthController],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
import { UserDto } from "../dto/user.dto";
|
|
||||||
|
import { User } from "../users/entity/user.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserFromTokenPipe implements PipeTransform {
|
export class UserPipe implements PipeTransform {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async transform(token: string): Promise<UserDto> {
|
async transform(token: string): Promise<User> {
|
||||||
const jwtUser: { id: string } = await this.jwtService.decode(token);
|
const jwtUser: { id: string } = await this.jwtService.decode(token);
|
||||||
|
|
||||||
if (!jwtUser)
|
if (!jwtUser)
|
||||||
@@ -24,6 +25,6 @@ export class UserFromTokenPipe implements PipeTransform {
|
|||||||
if (!user)
|
if (!user)
|
||||||
throw new UnauthorizedException("Передан некорректный токен!");
|
throw new UnauthorizedException("Передан некорректный токен!");
|
||||||
|
|
||||||
return user as UserDto;
|
return user as User;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,15 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import {
|
|
||||||
SignInReqDto,
|
|
||||||
SignInResDto,
|
|
||||||
SignUpReqDto,
|
|
||||||
SignUpResDto,
|
|
||||||
ChangePasswordReqDto,
|
|
||||||
UpdateTokenReqDto,
|
|
||||||
UpdateTokenResDto,
|
|
||||||
} from "../dto/auth.dto";
|
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
import { genSalt, hash } from "bcrypt";
|
import { genSalt, hash } from "bcrypt";
|
||||||
import { Prisma, UserRole } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { Types } from "mongoose";
|
import { Types } from "mongoose";
|
||||||
import { UserDto, UserRoleDto } from "../dto/user.dto";
|
import { UserRole } from "../users/user-role.enum";
|
||||||
|
import { User } from "../users/entity/user.entity";
|
||||||
|
import { SignInDto } from "./dto/sign-in.dto";
|
||||||
|
import { SignUpDto } from "./dto/sign-up.dto";
|
||||||
|
import { ChangePasswordDto } from "./dto/change-password.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -28,41 +23,48 @@ export class AuthService {
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async decodeUserToken(token: string): Promise<UserDto> {
|
/**
|
||||||
|
* Получение пользователя по его токену
|
||||||
|
* @param token - jwt токен
|
||||||
|
* @returns {User} - пользователь
|
||||||
|
* @throws {UnauthorizedException} - некорректный или недействительный токен
|
||||||
|
* @throws {UnauthorizedException} - токен указывает на несуществующего пользователя
|
||||||
|
* @throws {UnauthorizedException} - текущий токен устарел и был обновлён на новый
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
async decodeUserToken(token: string): Promise<User> {
|
||||||
const jwtUser: { id: string } | null =
|
const jwtUser: { id: string } | null =
|
||||||
await this.jwtService.verifyAsync(token);
|
await this.jwtService.verifyAsync(token);
|
||||||
|
|
||||||
if (jwtUser === null) {
|
const throwError = () => {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
"Некорректный или недействительный токен",
|
"Некорректный или недействительный токен",
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (jwtUser === null) throwError();
|
||||||
|
|
||||||
|
const user = await this.usersService.findUnique({ id: jwtUser.id });
|
||||||
|
|
||||||
|
if (!user || user.accessToken !== token) throwError();
|
||||||
|
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersService
|
/**
|
||||||
.findUnique({ id: jwtUser.id })
|
* Регистрация нового пользователя
|
||||||
.then((user) => user as UserDto | null);
|
* @param signUp - данные нового пользователя
|
||||||
|
* @returns {User} - пользователь
|
||||||
|
* @throws {NotAcceptableException} - передана недопустимая роль
|
||||||
|
* @throws {ConflictException} - пользователь с таким именем уже существует
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
async signUp(signUp: SignUpDto): Promise<User> {
|
||||||
|
const group = signUp.group.replaceAll(" ", "");
|
||||||
|
const username = signUp.username.replaceAll(" ", "");
|
||||||
|
|
||||||
if (!user)
|
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUp.role))
|
||||||
throw new UnauthorizedException("Не удалось найти пользователя!");
|
|
||||||
|
|
||||||
if (user.accessToken !== token) {
|
|
||||||
throw new UnauthorizedException(
|
|
||||||
"Некорректный или недействительный токен",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user as UserDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
async signUp(signUpDto: SignUpReqDto): Promise<SignUpResDto> {
|
|
||||||
const group = signUpDto.group.replaceAll(" ", "");
|
|
||||||
const username = signUpDto.username.replaceAll(" ", "");
|
|
||||||
|
|
||||||
if (
|
|
||||||
![UserRoleDto.STUDENT, UserRoleDto.TEACHER].includes(signUpDto.role)
|
|
||||||
) {
|
|
||||||
throw new NotAcceptableException("Передана неизвестная роль");
|
throw new NotAcceptableException("Передана неизвестная роль");
|
||||||
}
|
|
||||||
|
|
||||||
if (await this.usersService.contains({ username: username })) {
|
if (await this.usersService.contains({ username: username })) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
@@ -77,31 +79,33 @@ export class AuthService {
|
|||||||
id: id,
|
id: id,
|
||||||
username: username,
|
username: username,
|
||||||
salt: salt,
|
salt: salt,
|
||||||
password: await hash(signUpDto.password, salt),
|
password: await hash(signUp.password, salt),
|
||||||
accessToken: await this.jwtService.signAsync({
|
accessToken: await this.jwtService.signAsync({
|
||||||
id: id,
|
id: id,
|
||||||
}),
|
}),
|
||||||
role: signUpDto.role as UserRole,
|
role: signUp.role as UserRole,
|
||||||
group: group,
|
group: group,
|
||||||
version: signUpDto.version ?? "1.0.0",
|
version: signUp.version ?? "1.0.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.usersService.create(input).then((user) => {
|
return await this.usersService.create(input);
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
accessToken: user.accessToken,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async signIn(signInDto: SignInReqDto): Promise<SignInResDto> {
|
/**
|
||||||
|
* Авторизация пользователя
|
||||||
|
* @param signIn - данные авторизации
|
||||||
|
* @returns {User} - пользователь
|
||||||
|
* @throws {UnauthorizedException} - некорректное имя пользователя или пароль
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
async signIn(signIn: SignInDto): Promise<User> {
|
||||||
const user = await this.usersService.findUnique({
|
const user = await this.usersService.findUnique({
|
||||||
username: signInDto.username.replaceAll(" ", ""),
|
username: signIn.username.replaceAll(" ", ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!user ||
|
!user ||
|
||||||
user.password !== (await hash(signInDto.password, user.salt))
|
user.password !== (await hash(signIn.password, user.salt))
|
||||||
) {
|
) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
"Некорректное имя пользователя или пароль!",
|
"Некорректное имя пользователя или пароль!",
|
||||||
@@ -110,19 +114,24 @@ export class AuthService {
|
|||||||
|
|
||||||
const accessToken = await this.jwtService.signAsync({ id: user.id });
|
const accessToken = await this.jwtService.signAsync({ id: user.id });
|
||||||
|
|
||||||
await this.usersService.update({
|
return await this.usersService.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { accessToken: accessToken },
|
data: { accessToken: accessToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: user.id, accessToken: accessToken, group: user.group };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateToken(
|
/**
|
||||||
updateTokenDto: UpdateTokenReqDto,
|
* Обновление токена пользователя
|
||||||
): Promise<UpdateTokenResDto> {
|
* @param oldToken - старый токен
|
||||||
|
* @returns {User} - пользователь
|
||||||
|
* @throws {NotFoundException} - некорректный или недействительный токен
|
||||||
|
* @throws {NotFoundException} - токен указывает на несуществующего пользователя
|
||||||
|
* @throws {NotFoundException} - текущий токен устарел и был обновлён на новый
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
async updateToken(oldToken: string): Promise<User> {
|
||||||
if (
|
if (
|
||||||
!(await this.jwtService.verifyAsync(updateTokenDto.accessToken, {
|
!(await this.jwtService.verifyAsync(oldToken, {
|
||||||
ignoreExpiration: true,
|
ignoreExpiration: true,
|
||||||
}))
|
}))
|
||||||
) {
|
) {
|
||||||
@@ -131,12 +140,10 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwtUser: { id: string } = await this.jwtService.decode(
|
const jwtUser: { id: string } = await this.jwtService.decode(oldToken);
|
||||||
updateTokenDto.accessToken,
|
|
||||||
);
|
|
||||||
|
|
||||||
const user = await this.usersService.findUnique({ id: jwtUser.id });
|
const user = await this.usersService.findUnique({ id: jwtUser.id });
|
||||||
if (!user || user.accessToken !== updateTokenDto.accessToken) {
|
if (!user || user.accessToken !== oldToken) {
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
"Некорректный или недействительный токен!",
|
"Некорректный или недействительный токен!",
|
||||||
);
|
);
|
||||||
@@ -144,19 +151,25 @@ export class AuthService {
|
|||||||
|
|
||||||
const accessToken = await this.jwtService.signAsync({ id: user.id });
|
const accessToken = await this.jwtService.signAsync({ id: user.id });
|
||||||
|
|
||||||
await this.usersService.update({
|
return await this.usersService.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { accessToken: accessToken },
|
data: { accessToken: accessToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { accessToken: accessToken };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Смена пароля пользователя
|
||||||
|
* @param user - пользователь
|
||||||
|
* @param changePassword - старый и новый пароли
|
||||||
|
* @throws {ConflictException} - пароли идентичны
|
||||||
|
* @throws {UnauthorizedException} - неверный исходный пароль
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
async changePassword(
|
async changePassword(
|
||||||
user: UserDto,
|
user: User,
|
||||||
changePasswordReqDto: ChangePasswordReqDto,
|
changePassword: ChangePasswordDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { oldPassword, newPassword } = changePasswordReqDto;
|
const { oldPassword, newPassword } = changePassword;
|
||||||
|
|
||||||
if (oldPassword == newPassword)
|
if (oldPassword == newPassword)
|
||||||
throw new ConflictException("Пароли идентичны");
|
throw new ConflictException("Пароли идентичны");
|
||||||
|
|||||||
17
src/auth/dto/change-password.dto.ts
Normal file
17
src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
/**
|
||||||
|
* Старый пароль
|
||||||
|
* @example "my-old-password"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
oldPassword: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Новый пароль
|
||||||
|
* @example "my-new-password"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
25
src/auth/dto/sign-in-response.dto.ts
Normal file
25
src/auth/dto/sign-in-response.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsJWT, IsMongoId, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class SignInResponseDto {
|
||||||
|
/**
|
||||||
|
* Идентификатор (ObjectId)
|
||||||
|
* @example "66e1b7e255c5d5f1268cce90"
|
||||||
|
*/
|
||||||
|
@IsMongoId()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Последний токен доступа
|
||||||
|
* @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..."
|
||||||
|
*/
|
||||||
|
@IsJWT()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группа
|
||||||
|
* @example "ИС-214/23"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
12
src/auth/dto/sign-in.dto.ts
Normal file
12
src/auth/dto/sign-in.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../../users/entity/user.entity";
|
||||||
|
import { IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class SignInDto extends PickType(User, ["username"]) {
|
||||||
|
/**
|
||||||
|
* Пароль в исходном виде
|
||||||
|
* @example "my-password"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
4
src/auth/dto/sign-up-response.dto.ts
Normal file
4
src/auth/dto/sign-up-response.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../../users/entity/user.entity";
|
||||||
|
|
||||||
|
export class SignUpResponseDto extends PickType(User, ["id", "accessToken"]) {}
|
||||||
9
src/auth/dto/sign-up.dto.ts
Normal file
9
src/auth/dto/sign-up.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { IntersectionType, PartialType, PickType } from "@nestjs/swagger";
|
||||||
|
import { SignInDto } from "./sign-in.dto";
|
||||||
|
import { User } from "../../users/entity/user.entity";
|
||||||
|
|
||||||
|
export class SignUpDto extends IntersectionType(
|
||||||
|
SignInDto,
|
||||||
|
PickType(User, ["role", "group"]),
|
||||||
|
PartialType(PickType(User, ["version"])),
|
||||||
|
) {}
|
||||||
4
src/auth/dto/update-token-response.dto.ts
Normal file
4
src/auth/dto/update-token-response.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { UpdateTokenDto } from "./update-token.dto";
|
||||||
|
|
||||||
|
export class UpdateTokenResponseDto extends UpdateTokenDto {
|
||||||
|
}
|
||||||
4
src/auth/dto/update-token.dto.ts
Normal file
4
src/auth/dto/update-token.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../../users/entity/user.entity";
|
||||||
|
|
||||||
|
export class UpdateTokenDto extends PickType(User, ["accessToken"]) {}
|
||||||
138
src/auth/v1-auth.controller.ts
Normal file
138
src/auth/v1-auth.controller.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthService } from "./auth.service";
|
||||||
|
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
|
import { V1ScheduleService } from "../schedule/v1-schedule.service";
|
||||||
|
import { UserToken } from "./auth.decorator";
|
||||||
|
import { ResponseVersion } from "../version/response-version.decorator";
|
||||||
|
import { SignInDto } from "./dto/sign-in.dto";
|
||||||
|
import { SignInResponseDto } from "./dto/sign-in-response.dto";
|
||||||
|
import { SignUpResponseDto } from "./dto/sign-up-response.dto";
|
||||||
|
import { SignUpDto } from "./dto/sign-up.dto";
|
||||||
|
import { UpdateTokenDto } from "./dto/update-token.dto";
|
||||||
|
import { UpdateTokenResponseDto } from "./dto/update-token-response.dto";
|
||||||
|
import { ChangePasswordDto } from "./dto/change-password.dto";
|
||||||
|
|
||||||
|
@ApiTags("v1/auth")
|
||||||
|
@Controller({ path: "auth", version: "1" })
|
||||||
|
export class V1AuthController {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly scheduleService: V1ScheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Авторизация по логину и паролю" })
|
||||||
|
@ApiBody({ type: SignInDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Авторизация прошла успешно",
|
||||||
|
type: SignInResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.UNAUTHORIZED,
|
||||||
|
description: "Некорректное имя пользователя или пароль",
|
||||||
|
})
|
||||||
|
@ResultDto(SignInResponseDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("sign-in")
|
||||||
|
async signIn(
|
||||||
|
@Body() signInDto: SignInDto,
|
||||||
|
@ResponseVersion() responseVersion: number,
|
||||||
|
): Promise<SignInResponseDto> {
|
||||||
|
const data = await this.authService.signIn(signInDto);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
group: responseVersion ? data.group : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Регистрация по логину и паролю" })
|
||||||
|
@ApiBody({ type: SignUpDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CREATED,
|
||||||
|
description: "Регистрация прошла успешно",
|
||||||
|
type: SignUpResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CONFLICT,
|
||||||
|
description: "Такой пользователь уже существует",
|
||||||
|
})
|
||||||
|
@ResultDto(SignUpResponseDto)
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@Post("sign-up")
|
||||||
|
async signUp(@Body() signUpDto: SignUpDto): Promise<SignUpResponseDto> {
|
||||||
|
if (
|
||||||
|
!(await this.scheduleService.getGroupNames()).names.includes(
|
||||||
|
signUpDto.group.replaceAll(" ", ""),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Передано название несуществующей группы",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.authService.signUp(signUpDto);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
accessToken: user.accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Обновление просроченного токена" })
|
||||||
|
@ApiBody({ type: UpdateTokenDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Токен обновлён успешно",
|
||||||
|
type: UpdateTokenResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.NOT_FOUND,
|
||||||
|
description: "Передан несуществующий или недействительный токен",
|
||||||
|
})
|
||||||
|
@ResultDto(UpdateTokenResponseDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("update-token")
|
||||||
|
updateToken(
|
||||||
|
@Body() updateTokenDto: UpdateTokenDto,
|
||||||
|
): Promise<UpdateTokenResponseDto> {
|
||||||
|
return this.authService.updateToken(updateTokenDto.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Обновление пароля" })
|
||||||
|
@ApiBody({ type: ChangePasswordDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Пароль обновлён успешно",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CONFLICT,
|
||||||
|
description: "Пароли идентичны",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.UNAUTHORIZED,
|
||||||
|
description:
|
||||||
|
"Передан неверный текущий пароль или запрос был послан без токена",
|
||||||
|
})
|
||||||
|
@ResultDto(null)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("change-password")
|
||||||
|
async changePassword(
|
||||||
|
@Body() changePasswordReqDto: ChangePasswordDto,
|
||||||
|
@UserToken() userToken: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.authService
|
||||||
|
.decodeUserToken(userToken)
|
||||||
|
.then((user) =>
|
||||||
|
this.authService.changePassword(user, changePasswordReqDto),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/auth/v2-auth.controller.ts
Normal file
93
src/auth/v2-auth.controller.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthService } from "./auth.service";
|
||||||
|
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
|
import { V1ScheduleService } from "../schedule/v1-schedule.service";
|
||||||
|
import { SignInDto } from "./dto/sign-in.dto";
|
||||||
|
import { SignUpDto } from "./dto/sign-up.dto";
|
||||||
|
import { UpdateTokenDto } from "./dto/update-token.dto";
|
||||||
|
import { V2ClientUserDto } from "../users/dto/v2/v2-client-user.dto";
|
||||||
|
|
||||||
|
@ApiTags("v2/auth")
|
||||||
|
@Controller({ path: "auth", version: "2" })
|
||||||
|
export class V2AuthController {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly scheduleService: V1ScheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Авторизация по логину и паролю" })
|
||||||
|
@ApiBody({ type: SignInDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Авторизация прошла успешно",
|
||||||
|
type: V2ClientUserDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.UNAUTHORIZED,
|
||||||
|
description: "Некорректное имя пользователя или пароль",
|
||||||
|
})
|
||||||
|
@ResultDto(V2ClientUserDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("sign-in")
|
||||||
|
async signIn(@Body() reqDto: SignInDto): Promise<V2ClientUserDto> {
|
||||||
|
return V2ClientUserDto.fromUser(await this.authService.signIn(reqDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Регистрация по логину и паролю" })
|
||||||
|
@ApiBody({ type: SignUpDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CREATED,
|
||||||
|
description: "Регистрация прошла успешно",
|
||||||
|
type: V2ClientUserDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CONFLICT,
|
||||||
|
description: "Такой пользователь уже существует",
|
||||||
|
})
|
||||||
|
@ResultDto(V2ClientUserDto)
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@Post("sign-up")
|
||||||
|
async signUp(@Body() reqDto: SignUpDto): Promise<V2ClientUserDto> {
|
||||||
|
if (
|
||||||
|
!(await this.scheduleService.getGroupNames()).names.includes(
|
||||||
|
reqDto.group.replaceAll(" ", ""),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Передано название несуществующей группы",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return V2ClientUserDto.fromUser(await this.authService.signUp(reqDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Обновление просроченного токена" })
|
||||||
|
@ApiBody({ type: UpdateTokenDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Токен обновлён успешно",
|
||||||
|
type: V2ClientUserDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.NOT_FOUND,
|
||||||
|
description: "Передан несуществующий или недействительный токен",
|
||||||
|
})
|
||||||
|
@ResultDto(V2ClientUserDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("update-token")
|
||||||
|
async updateToken(
|
||||||
|
@Body() reqDto: UpdateTokenDto,
|
||||||
|
): Promise<V2ClientUserDto> {
|
||||||
|
return V2ClientUserDto.fromUser(
|
||||||
|
await this.authService.updateToken(reqDto.accessToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import {
|
|
||||||
ApiProperty,
|
|
||||||
IntersectionType,
|
|
||||||
PartialType,
|
|
||||||
PickType,
|
|
||||||
} from "@nestjs/swagger";
|
|
||||||
import { UserDto } from "./user.dto";
|
|
||||||
import { IsJWT, IsMongoId, IsString } from "class-validator";
|
|
||||||
import { Expose, instanceToPlain, plainToClass } from "class-transformer";
|
|
||||||
|
|
||||||
// SignIn
|
|
||||||
export class SignInReqDto extends PickType(UserDto, ["username"]) {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "my-password",
|
|
||||||
description: "Пароль в исходном виде",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SignInResDtoV0 {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "66e1b7e255c5d5f1268cce90",
|
|
||||||
description: "Идентификатор (ObjectId)",
|
|
||||||
})
|
|
||||||
@IsMongoId()
|
|
||||||
@Expose()
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
||||||
description: "Последний токен доступа",
|
|
||||||
})
|
|
||||||
@IsJWT()
|
|
||||||
@Expose()
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SignInResDtoV2 extends SignInResDtoV0 {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "ИС-214/23",
|
|
||||||
description: "Группа",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
group: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SignInResDto extends SignInResDtoV2 {
|
|
||||||
public static stripVersion(
|
|
||||||
instance: SignInResDto,
|
|
||||||
version: number,
|
|
||||||
): SignInResDtoV0 | SignInResDtoV2 {
|
|
||||||
switch (version) {
|
|
||||||
default:
|
|
||||||
return instance;
|
|
||||||
case 0:
|
|
||||||
case 1: {
|
|
||||||
return plainToClass(SignInResDtoV0, instanceToPlain(instance), {
|
|
||||||
excludeExtraneousValues: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignUp
|
|
||||||
export class SignUpReqDto extends IntersectionType(
|
|
||||||
SignInReqDto,
|
|
||||||
PickType(UserDto, ["role", "group"]),
|
|
||||||
PartialType(PickType(UserDto, ["version"])),
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class SignUpResDto extends PickType(SignInResDto, [
|
|
||||||
"id",
|
|
||||||
"accessToken",
|
|
||||||
]) {}
|
|
||||||
|
|
||||||
// Update token
|
|
||||||
export class UpdateTokenReqDto extends PickType(UserDto, ["accessToken"]) {}
|
|
||||||
|
|
||||||
export class UpdateTokenResDto extends UpdateTokenReqDto {}
|
|
||||||
|
|
||||||
// Update password
|
|
||||||
export class ChangePasswordReqDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "my-old-password",
|
|
||||||
description: "Старый пароль",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
oldPassword: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "my-new-password",
|
|
||||||
description: "Новый пароль",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
newPassword: string;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
import { IsSemVer, IsUrl } from "class-validator";
|
|
||||||
|
|
||||||
export class FcmPostUpdateDto {
|
|
||||||
@ApiProperty({ example: "1.6.0", description: "Версия приложения" })
|
|
||||||
@IsSemVer()
|
|
||||||
// @Expose()
|
|
||||||
version: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "https://download.host/app-release-1.6.0.apk",
|
|
||||||
description: "Ссылка на приложение",
|
|
||||||
})
|
|
||||||
@IsUrl()
|
|
||||||
// @Expose()
|
|
||||||
downloadLink: string;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ApiProperty, PickType } from "@nestjs/swagger";
|
|
||||||
import { IsNumber, IsObject, IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class ScheduleReplacerDto {
|
|
||||||
@ApiProperty({ description: "Etag заменяемого расписания" })
|
|
||||||
@IsString()
|
|
||||||
etag: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Данные файла расписания" })
|
|
||||||
@IsObject()
|
|
||||||
data: ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScheduleReplacerResDto extends PickType(ScheduleReplacerDto, [
|
|
||||||
"etag",
|
|
||||||
]) {
|
|
||||||
@ApiProperty({ example: 1405, description: "Размер файла в байтах" })
|
|
||||||
@IsNumber()
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClearScheduleReplacerResDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: 1,
|
|
||||||
description: "Количество удалённых заменителей расписания",
|
|
||||||
})
|
|
||||||
@IsNumber()
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
import {
|
|
||||||
IsArray,
|
|
||||||
IsBase64,
|
|
||||||
IsBoolean,
|
|
||||||
IsDate,
|
|
||||||
IsEnum,
|
|
||||||
IsHash,
|
|
||||||
IsNumber,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
ValidateNested,
|
|
||||||
} from "class-validator";
|
|
||||||
import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger";
|
|
||||||
import {
|
|
||||||
Expose,
|
|
||||||
instanceToPlain,
|
|
||||||
plainToClass,
|
|
||||||
Transform,
|
|
||||||
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.trim().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 {
|
|
||||||
DEFAULT = 0,
|
|
||||||
CUSTOM,
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LessonDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: LessonTypeDto.DEFAULT,
|
|
||||||
description: "Тип занятия",
|
|
||||||
})
|
|
||||||
@IsEnum(LessonTypeDto)
|
|
||||||
type: LessonTypeDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 1,
|
|
||||||
description: "Индекс пары, если присутствует",
|
|
||||||
})
|
|
||||||
@IsNumber()
|
|
||||||
defaultIndex: number;
|
|
||||||
|
|
||||||
@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,
|
|
||||||
defaultIndex: number,
|
|
||||||
time: LessonTimeDto,
|
|
||||||
name: string,
|
|
||||||
cabinets: Array<string>,
|
|
||||||
teacherNames: Array<string>,
|
|
||||||
) {
|
|
||||||
this.type = type;
|
|
||||||
this.defaultIndex = defaultIndex;
|
|
||||||
this.time = time;
|
|
||||||
this.name = name;
|
|
||||||
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 })
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => LessonDto)
|
|
||||||
lessons: Array<LessonDto | null>;
|
|
||||||
|
|
||||||
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 === null) 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 })
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => DayDto)
|
|
||||||
days: Array<DayDto | null>;
|
|
||||||
|
|
||||||
constructor(name: string) {
|
|
||||||
this.name = name;
|
|
||||||
this.days = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CacheStatusV0Dto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
|
||||||
description: "Нужно ли обновить ссылку для скачивания xls?",
|
|
||||||
})
|
|
||||||
@IsBoolean()
|
|
||||||
@Expose()
|
|
||||||
cacheUpdateRequired: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "e6ff169b01608addf998dbf8f40b019a7f514239",
|
|
||||||
description: "Хеш последних полученных данных",
|
|
||||||
})
|
|
||||||
@IsHash("sha1")
|
|
||||||
@Expose()
|
|
||||||
cacheHash: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CacheStatusV1Dto extends CacheStatusV0Dto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: new Date().valueOf(),
|
|
||||||
description: "Дата обновления кеша",
|
|
||||||
})
|
|
||||||
@IsNumber()
|
|
||||||
@Expose()
|
|
||||||
lastCacheUpdate: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: new Date().valueOf(),
|
|
||||||
description: "Дата обновления расписания",
|
|
||||||
})
|
|
||||||
@IsNumber()
|
|
||||||
@Expose()
|
|
||||||
lastScheduleUpdate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CacheStatusDto extends CacheStatusV1Dto {
|
|
||||||
public static stripVersion(instance: CacheStatusDto, version: number) {
|
|
||||||
switch (version) {
|
|
||||||
default:
|
|
||||||
return instance;
|
|
||||||
case 0: {
|
|
||||||
return plainToClass(
|
|
||||||
CacheStatusV0Dto,
|
|
||||||
instanceToPlain(instance),
|
|
||||||
{ excludeExtraneousValues: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScheduleDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: new Date(),
|
|
||||||
description:
|
|
||||||
"Дата когда последний раз расписание было скачано с сервера политехникума",
|
|
||||||
})
|
|
||||||
@IsDate()
|
|
||||||
updatedAt: Date;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Расписание групп" })
|
|
||||||
@IsObject()
|
|
||||||
@IsOptional()
|
|
||||||
groups: any;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: { "ИС-214/23": [5, 6] },
|
|
||||||
description: "Обновлённые дни с последнего изменения расписания",
|
|
||||||
})
|
|
||||||
@IsObject()
|
|
||||||
@Type(() => Object)
|
|
||||||
@Transform(({ value }) => {
|
|
||||||
const object = {};
|
|
||||||
|
|
||||||
for (const key in value) {
|
|
||||||
object[key] = value[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
return object;
|
|
||||||
})
|
|
||||||
@Type(() => Object)
|
|
||||||
lastChangedDays: Array<Array<number>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GroupScheduleReqDto extends PartialType(
|
|
||||||
PickType(GroupDto, ["name"]),
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class ScheduleGroupsDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: ["ИС-214/23", "ИС-213/23"],
|
|
||||||
description: "Список названий всех групп в текущем расписании",
|
|
||||||
})
|
|
||||||
@IsArray()
|
|
||||||
names: Array<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GroupScheduleDto extends OmitType(ScheduleDto, [
|
|
||||||
"groups",
|
|
||||||
"lastChangedDays",
|
|
||||||
]) {
|
|
||||||
@ApiProperty({ description: "Расписание группы" })
|
|
||||||
@IsObject()
|
|
||||||
group: GroupDto;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: [5, 6],
|
|
||||||
description: "Обновлённые дни с последнего изменения расписания",
|
|
||||||
})
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => Number)
|
|
||||||
lastChangedDays: Array<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SiteMainPageDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "MHz=",
|
|
||||||
description: "Страница политехникума",
|
|
||||||
})
|
|
||||||
@IsBase64()
|
|
||||||
mainPage: string;
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { ApiProperty, OmitType, PickType } from "@nestjs/swagger";
|
|
||||||
import {
|
|
||||||
IsArray,
|
|
||||||
IsEnum,
|
|
||||||
IsJWT,
|
|
||||||
IsMongoId,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsSemVer,
|
|
||||||
IsString,
|
|
||||||
MaxLength,
|
|
||||||
MinLength,
|
|
||||||
ValidateNested,
|
|
||||||
} from "class-validator";
|
|
||||||
import { Expose, plainToClass, Type } from "class-transformer";
|
|
||||||
|
|
||||||
export enum UserRoleDto {
|
|
||||||
STUDENT = "STUDENT",
|
|
||||||
TEACHER = "TEACHER",
|
|
||||||
ADMIN = "ADMIN",
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserFcmDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: "Токен Firebase Cloud Messaging",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
token: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: ["schedule-update"],
|
|
||||||
description: "Топики на которые подписан пользователь",
|
|
||||||
})
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
topics: Array<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: "66e1b7e255c5d5f1268cce90",
|
|
||||||
description: "Идентификатор (ObjectId)",
|
|
||||||
})
|
|
||||||
@IsMongoId()
|
|
||||||
@Expose()
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: "n08i40k", description: "Имя" })
|
|
||||||
@IsString()
|
|
||||||
@MinLength(4)
|
|
||||||
@MaxLength(10)
|
|
||||||
@Expose()
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "$2b$08$34xwFv1WVJpvpVi3tZZuv.",
|
|
||||||
description: "Соль пароля",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
salt: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "$2b$08$34xwFv1WVJpvpVi3tZZuv...",
|
|
||||||
description: "Хеш пароля",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
||||||
description: "Последний токен доступа",
|
|
||||||
})
|
|
||||||
@IsJWT()
|
|
||||||
@Expose()
|
|
||||||
accessToken: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: "ИС-214/23", description: "Группа пользователя" })
|
|
||||||
@IsString()
|
|
||||||
@Expose()
|
|
||||||
group: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: UserRoleDto.STUDENT,
|
|
||||||
description: "Роль пользователя",
|
|
||||||
})
|
|
||||||
@IsEnum(UserRoleDto)
|
|
||||||
@Expose()
|
|
||||||
role: UserRoleDto;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Данные Firebase Cloud Messaging" })
|
|
||||||
@IsObject()
|
|
||||||
@Type(() => UserFcmDto)
|
|
||||||
@IsOptional()
|
|
||||||
@Expose()
|
|
||||||
fcm: UserFcmDto | null;
|
|
||||||
|
|
||||||
@ApiProperty({ description: "Версия установленого приложения" })
|
|
||||||
@IsSemVer()
|
|
||||||
@Expose()
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClientUserResDto extends OmitType(UserDto, [
|
|
||||||
"password",
|
|
||||||
"salt",
|
|
||||||
"accessToken",
|
|
||||||
"fcm",
|
|
||||||
"version",
|
|
||||||
]) {
|
|
||||||
static fromUserDto(userDto: UserDto): ClientUserResDto {
|
|
||||||
return plainToClass(ClientUserResDto, userDto, {
|
|
||||||
excludeExtraneousValues: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// changes
|
|
||||||
|
|
||||||
export class ChangeUsernameReqDto extends PickType(UserDto, ["username"]) {}
|
|
||||||
|
|
||||||
export class ChangeGroupReqDto extends PickType(UserDto, ["group"]) {}
|
|
||||||
17
src/firebase-admin/dto/fcm-post-update.dto.ts
Normal file
17
src/firebase-admin/dto/fcm-post-update.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsSemVer, IsUrl } from "class-validator";
|
||||||
|
|
||||||
|
export class FcmPostUpdateDto {
|
||||||
|
/**
|
||||||
|
* Версия приложения
|
||||||
|
* @example "1.6.0"
|
||||||
|
*/
|
||||||
|
@IsSemVer()
|
||||||
|
readonly version: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ссылка на приложение
|
||||||
|
* @example "https://download.host/app-release-1.6.0.apk"
|
||||||
|
*/
|
||||||
|
@IsUrl()
|
||||||
|
readonly downloadLink: string;
|
||||||
|
}
|
||||||
@@ -10,26 +10,40 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/auth.guard";
|
import { AuthGuard } from "../auth/auth.guard";
|
||||||
import { UserToken } from "../auth/auth.decorator";
|
import { UserToken } from "../auth/auth.decorator";
|
||||||
import { UserFromTokenPipe } from "../auth/auth.pipe";
|
import { UserPipe } from "../auth/auth.pipe";
|
||||||
import { UserDto } from "../dto/user.dto";
|
|
||||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
import { FirebaseAdminService } from "./firebase-admin.service";
|
import { FirebaseAdminService } from "./firebase-admin.service";
|
||||||
import { FcmPostUpdateDto } from "../dto/fcm.dto";
|
import { FcmPostUpdateDto } from "./dto/fcm-post-update.dto";
|
||||||
import { isSemVer } from "class-validator";
|
import { isSemVer } from "class-validator";
|
||||||
|
import { User } from "../users/entity/user.entity";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
|
||||||
@Controller("api/v1/fcm")
|
@ApiTags("v1/fcm")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller({ path: "fcm", version: "1" })
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class FirebaseAdminController {
|
export class FirebaseAdminController {
|
||||||
private readonly oldTopics = new Set(["app-update", "schedule-update"]);
|
private readonly oldTopics = new Set(["app-update", "schedule-update"]);
|
||||||
|
|
||||||
constructor(private readonly firebaseAdminService: FirebaseAdminService) {}
|
constructor(private readonly firebaseAdminService: FirebaseAdminService) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Установка FCM токена пользователем" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Установка токена удалась",
|
||||||
|
})
|
||||||
@Post("set-token/:token")
|
@Post("set-token/:token")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ResultDto(null)
|
@ResultDto(null)
|
||||||
async setToken(
|
async setToken(
|
||||||
@Param("token") token: string,
|
@Param("token") token: string,
|
||||||
@UserToken(UserFromTokenPipe) user: UserDto,
|
@UserToken(UserPipe) user: User,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (user.fcm?.token === token) return;
|
if (user.fcm?.token === token) return;
|
||||||
|
|
||||||
@@ -44,13 +58,18 @@ export class FirebaseAdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Установка текущей версии приложения" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Установка версии удалась",
|
||||||
|
})
|
||||||
@Post("update-callback/:version")
|
@Post("update-callback/:version")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ResultDto(null)
|
@ResultDto(null)
|
||||||
async updateCallback(
|
async updateCallback(
|
||||||
@UserToken(UserFromTokenPipe) userDto: UserDto,
|
|
||||||
@Param("version") version: string,
|
@Param("version") version: string,
|
||||||
) {
|
@UserToken(UserPipe) userDto: User,
|
||||||
|
): Promise<void> {
|
||||||
if (!isSemVer(version)) {
|
if (!isSemVer(version)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"version must be a Semantic Versioning Specification",
|
"version must be a Semantic Versioning Specification",
|
||||||
@@ -60,18 +79,26 @@ export class FirebaseAdminController {
|
|||||||
await this.firebaseAdminService.updateApp(userDto, version);
|
await this.firebaseAdminService.updateApp(userDto, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Уведомление пользователей о выходе новой версии приложения",
|
||||||
|
})
|
||||||
|
@ApiBody({ type: FcmPostUpdateDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Уведомление отправлено",
|
||||||
|
})
|
||||||
@Post("post-update")
|
@Post("post-update")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ResultDto(null)
|
@ResultDto(null)
|
||||||
async postUpdate(@Body() postUpdateDto: FcmPostUpdateDto): Promise<void> {
|
async postUpdate(@Body() reqDto: FcmPostUpdateDto): Promise<void> {
|
||||||
await this.firebaseAdminService.sendByTopic("common", {
|
await this.firebaseAdminService.sendByTopic("common", {
|
||||||
android: {
|
android: {
|
||||||
priority: "high",
|
priority: "high",
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
type: "app-update",
|
type: "app-update",
|
||||||
version: postUpdateDto.version,
|
version: reqDto.version,
|
||||||
downloadLink: postUpdateDto.downloadLink,
|
downloadLink: reqDto.downloadLink,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
|
|
||||||
import { firebaseConstants } from "../contants";
|
import { firebaseConstants } from "../contants";
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
import { UserDto } from "../dto/user.dto";
|
|
||||||
|
import { User } from "../users/entity/user.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FirebaseAdminService implements OnModuleInit {
|
export class FirebaseAdminService implements OnModuleInit {
|
||||||
@@ -40,9 +41,9 @@ export class FirebaseAdminService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateToken(
|
async updateToken(
|
||||||
user: UserDto,
|
user: User,
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ userDto: UserDto; isNew: boolean }> {
|
): Promise<{ userDto: User; isNew: boolean }> {
|
||||||
const isNew = user.fcm === null;
|
const isNew = user.fcm === null;
|
||||||
|
|
||||||
const fcm = !isNew ? user.fcm : { token: token, topics: [] };
|
const fcm = !isNew ? user.fcm : { token: token, topics: [] };
|
||||||
@@ -63,7 +64,7 @@ export class FirebaseAdminService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async unsubscribe(user: UserDto, topics: Set<string>): Promise<UserDto> {
|
async unsubscribe(user: User, topics: Set<string>): Promise<User> {
|
||||||
const fcm = user.fcm;
|
const fcm = user.fcm;
|
||||||
const currentTopics = new Set(fcm.topics);
|
const currentTopics = new Set(fcm.topics);
|
||||||
|
|
||||||
@@ -84,10 +85,10 @@ export class FirebaseAdminService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async subscribe(
|
async subscribe(
|
||||||
user: UserDto,
|
user: User,
|
||||||
topics: Set<string>,
|
topics: Set<string>,
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
): Promise<UserDto> {
|
): Promise<User> {
|
||||||
const newTopics = new Set([...this.defaultTopics, ...topics]);
|
const newTopics = new Set([...this.defaultTopics, ...topics]);
|
||||||
|
|
||||||
const fcm = user.fcm;
|
const fcm = user.fcm;
|
||||||
@@ -109,8 +110,8 @@ export class FirebaseAdminService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateApp(userDto: UserDto, version: string): Promise<void> {
|
async updateApp(user: User, version: string): Promise<void> {
|
||||||
await this.subscribe(userDto, new Set(), true).then(async (userDto) => {
|
await this.subscribe(user, new Set(), true).then(async (userDto) => {
|
||||||
await this.usersService.update({
|
await this.usersService.update({
|
||||||
where: { id: userDto.id },
|
where: { id: userDto.id },
|
||||||
data: { version: version },
|
data: { version: version },
|
||||||
|
|||||||
16
src/main.ts
16
src/main.ts
@@ -3,9 +3,10 @@ import { AppModule } from "./app.module";
|
|||||||
import { ValidatorOptions } from "class-validator";
|
import { ValidatorOptions } from "class-validator";
|
||||||
import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe";
|
import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe";
|
||||||
import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor";
|
import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { RedocModule } from "nest-redoc";
|
||||||
import { apiConstants, httpsConstants } from "./contants";
|
import { apiConstants, httpsConstants } from "./contants";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
|
import { VersioningType } from "@nestjs/common";
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
@@ -23,19 +24,26 @@ async function bootstrap() {
|
|||||||
app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions));
|
app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions));
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
|
||||||
const swaggerConfig = new DocumentBuilder()
|
app.setGlobalPrefix("api");
|
||||||
|
app.enableVersioning({
|
||||||
|
type: VersioningType.URI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const swaggerConfig = RedocModule.createDocumentBuilder()
|
||||||
.setTitle("Schedule Parser")
|
.setTitle("Schedule Parser")
|
||||||
.setDescription("Парсер расписания")
|
.setDescription("Парсер расписания")
|
||||||
.setVersion(apiConstants.version)
|
.setVersion(apiConstants.version)
|
||||||
.build();
|
.build();
|
||||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
const swaggerDocument = RedocModule.createDocument(app, swaggerConfig, {
|
||||||
|
deepScanRoutes: true,
|
||||||
|
});
|
||||||
swaggerDocument.servers = [
|
swaggerDocument.servers = [
|
||||||
{
|
{
|
||||||
url: `https://localhost:${apiConstants.port}`,
|
url: `https://localhost:${apiConstants.port}`,
|
||||||
description: "Локальный сервер для разработки",
|
description: "Локальный сервер для разработки",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
SwaggerModule.setup("api-docs", app, swaggerDocument);
|
await RedocModule.setup("api-docs", app, swaggerDocument, {});
|
||||||
|
|
||||||
await app.listen(apiConstants.port);
|
await app.listen(apiConstants.port);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/schedule/dto/clear-schedule-replacer.dto.ts
Normal file
10
src/schedule/dto/clear-schedule-replacer.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
export class ClearScheduleReplacerDto {
|
||||||
|
/**
|
||||||
|
* Количество удалённых заменителей расписания
|
||||||
|
* @example 1
|
||||||
|
*/
|
||||||
|
@IsNumber()
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
14
src/schedule/dto/schedule-replacer.dto.ts
Normal file
14
src/schedule/dto/schedule-replacer.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { IsNumber } from "class-validator";
|
||||||
|
import { SetScheduleReplacerDto } from "./set-schedule-replacer.dto";
|
||||||
|
|
||||||
|
export class ScheduleReplacerDto extends PickType(SetScheduleReplacerDto, [
|
||||||
|
"etag",
|
||||||
|
]) {
|
||||||
|
/**
|
||||||
|
* Размер файла в байтах
|
||||||
|
* @example 12567
|
||||||
|
*/
|
||||||
|
@IsNumber()
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
23
src/schedule/dto/set-schedule-replacer.dto.ts
Normal file
23
src/schedule/dto/set-schedule-replacer.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsMongoId, IsObject, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class SetScheduleReplacerDto {
|
||||||
|
/**
|
||||||
|
* Идентификатор заменителя (ObjectId)
|
||||||
|
* @example "66e6f1c8775ffeda400d7967"
|
||||||
|
*/
|
||||||
|
@IsMongoId()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ETag заменяемого расписания
|
||||||
|
* @example "\"670be780-21e00\""
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
etag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные файла расписания
|
||||||
|
*/
|
||||||
|
@IsObject()
|
||||||
|
data: ArrayBuffer;
|
||||||
|
}
|
||||||
19
src/schedule/dto/v1/cache-status.dto.ts
Normal file
19
src/schedule/dto/v1/cache-status.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { V2CacheStatusDto } from "../v2/v2-cache-status.dto";
|
||||||
|
import { instanceToPlain, plainToClass } from "class-transformer";
|
||||||
|
import { V1CacheStatusDto } from "./v1-cache-status.dto";
|
||||||
|
|
||||||
|
export class CacheStatusDto extends V2CacheStatusDto {
|
||||||
|
public static stripVersion(instance: CacheStatusDto, version: number) {
|
||||||
|
switch (version) {
|
||||||
|
default:
|
||||||
|
return instance;
|
||||||
|
case 0: {
|
||||||
|
return plainToClass(
|
||||||
|
V1CacheStatusDto,
|
||||||
|
instanceToPlain(instance),
|
||||||
|
{ excludeExtraneousValues: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/schedule/dto/v1/v1-cache-status.dto.ts
Normal file
21
src/schedule/dto/v1/v1-cache-status.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsBoolean, IsHash } from "class-validator";
|
||||||
|
import { Expose } from "class-transformer";
|
||||||
|
|
||||||
|
export class V1CacheStatusDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: true,
|
||||||
|
description: "Нужно ли обновить ссылку для скачивания xls?",
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
@Expose()
|
||||||
|
cacheUpdateRequired: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: "e6ff169b01608addf998dbf8f40b019a7f514239",
|
||||||
|
description: "Хеш последних полученных данных",
|
||||||
|
})
|
||||||
|
@IsHash("sha1")
|
||||||
|
@Expose()
|
||||||
|
cacheHash: string;
|
||||||
|
}
|
||||||
69
src/schedule/dto/v1/v1-day.dto.ts
Normal file
69
src/schedule/dto/v1/v1-day.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V1LessonDto } from "./v1-lesson.dto";
|
||||||
|
import { V1LessonType } from "../../enum/v1-lesson-type.enum";
|
||||||
|
|
||||||
|
export class V1DayDto {
|
||||||
|
@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 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => V1LessonDto)
|
||||||
|
lessons: Array<V1LessonDto | null>;
|
||||||
|
|
||||||
|
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 === null) continue;
|
||||||
|
|
||||||
|
this.nonNullIndices.push(lessonIdx);
|
||||||
|
|
||||||
|
(lesson.type === V1LessonType.DEFAULT
|
||||||
|
? this.defaultIndices
|
||||||
|
: this.customIndices
|
||||||
|
).push(lessonIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/schedule/dto/v1/v1-group-schedule-name.dto.ts
Normal file
6
src/schedule/dto/v1/v1-group-schedule-name.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType, PickType } from "@nestjs/swagger";
|
||||||
|
import { V1GroupDto } from "./v1-group.dto";
|
||||||
|
|
||||||
|
export class V1GroupScheduleNameDto extends PartialType(
|
||||||
|
PickType(V1GroupDto, ["name"]),
|
||||||
|
) {}
|
||||||
23
src/schedule/dto/v1/v1-group-schedule.dto.ts
Normal file
23
src/schedule/dto/v1/v1-group-schedule.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsObject, ValidateNested } from "class-validator";
|
||||||
|
import { V1GroupDto } from "./v1-group.dto";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V1ScheduleDto } from "./v1-schedule.dto";
|
||||||
|
|
||||||
|
export class V1GroupScheduleDto extends OmitType(V1ScheduleDto, [
|
||||||
|
"groups",
|
||||||
|
"lastChangedDays",
|
||||||
|
]) {
|
||||||
|
@ApiProperty({ description: "Расписание группы" })
|
||||||
|
@IsObject()
|
||||||
|
group: V1GroupDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: [5, 6],
|
||||||
|
description: "Обновлённые дни с последнего изменения расписания",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => Number)
|
||||||
|
lastChangedDays: Array<number>;
|
||||||
|
}
|
||||||
25
src/schedule/dto/v1/v1-group.dto.ts
Normal file
25
src/schedule/dto/v1/v1-group.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V1DayDto } from "./v1-day.dto";
|
||||||
|
|
||||||
|
export class V1GroupDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: "ИС-214/23",
|
||||||
|
description: "Название группы",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: [], description: "Дни недели" })
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => V1DayDto)
|
||||||
|
days: Array<V1DayDto | null>;
|
||||||
|
|
||||||
|
constructor(name: string) {
|
||||||
|
this.name = name;
|
||||||
|
this.days = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/schedule/dto/v1/v1-lesson-time.dto.ts
Normal file
39
src/schedule/dto/v1/v1-lesson-time.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
export class V1LessonTimeDto {
|
||||||
|
@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): V1LessonTimeDto {
|
||||||
|
time = time.trim().replaceAll(".", ":");
|
||||||
|
|
||||||
|
const regex = /(\d+:\d+)-(\d+:\d+)/g;
|
||||||
|
|
||||||
|
const parseResult = regex.exec(time);
|
||||||
|
if (!parseResult) return new V1LessonTimeDto(0, 0);
|
||||||
|
|
||||||
|
const start = parseResult[1].split(":");
|
||||||
|
const end = parseResult[2].split(":");
|
||||||
|
|
||||||
|
return new V1LessonTimeDto(
|
||||||
|
Number.parseInt(start[0]) * 60 + Number.parseInt(start[1]),
|
||||||
|
Number.parseInt(end[0]) * 60 + Number.parseInt(end[1]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/schedule/dto/v1/v1-lesson.dto.ts
Normal file
76
src/schedule/dto/v1/v1-lesson.dto.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { V1LessonType } from "../../enum/v1-lesson-type.enum";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { V1LessonTimeDto } from "./v1-lesson-time.dto";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class V1LessonDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: V1LessonType.DEFAULT,
|
||||||
|
description: "Тип занятия",
|
||||||
|
})
|
||||||
|
@IsEnum(V1LessonType)
|
||||||
|
type: V1LessonType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 1,
|
||||||
|
description: "Индекс пары, если присутствует",
|
||||||
|
})
|
||||||
|
@IsNumber()
|
||||||
|
defaultIndex: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: "Элементы высшей математики",
|
||||||
|
description: "Название занятия",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: new V1LessonTimeDto(0, 60),
|
||||||
|
description:
|
||||||
|
"Начало и конец занятия в минутах относительно начала суток",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => V1LessonTimeDto)
|
||||||
|
time: V1LessonTimeDto | 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: V1LessonType,
|
||||||
|
defaultIndex: number,
|
||||||
|
time: V1LessonTimeDto,
|
||||||
|
name: string,
|
||||||
|
cabinets: Array<string>,
|
||||||
|
teacherNames: Array<string>,
|
||||||
|
) {
|
||||||
|
this.type = type;
|
||||||
|
this.defaultIndex = defaultIndex;
|
||||||
|
this.time = time;
|
||||||
|
this.name = name;
|
||||||
|
this.cabinets = cabinets;
|
||||||
|
this.teacherNames = teacherNames;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/schedule/dto/v1/v1-schedule-group-names.dto.ts
Normal file
11
src/schedule/dto/v1/v1-schedule-group-names.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsArray } from "class-validator";
|
||||||
|
|
||||||
|
export class V1ScheduleGroupNamesDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: ["ИС-214/23", "ИС-213/23"],
|
||||||
|
description: "Список названий всех групп в текущем расписании",
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
names: Array<string>;
|
||||||
|
}
|
||||||
36
src/schedule/dto/v1/v1-schedule.dto.ts
Normal file
36
src/schedule/dto/v1/v1-schedule.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { IsDate, IsObject, IsOptional } from "class-validator";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class V1ScheduleDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: new Date(),
|
||||||
|
description:
|
||||||
|
"Дата когда последний раз расписание было скачано с сервера политехникума",
|
||||||
|
})
|
||||||
|
@IsDate()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Расписание групп" })
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
groups: any;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: { "ИС-214/23": [5, 6] },
|
||||||
|
description: "Обновлённые дни с последнего изменения расписания",
|
||||||
|
})
|
||||||
|
@IsObject()
|
||||||
|
@Type(() => Object)
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
const object = {};
|
||||||
|
|
||||||
|
for (const key in value) {
|
||||||
|
object[key] = value[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return object;
|
||||||
|
})
|
||||||
|
@Type(() => Object)
|
||||||
|
lastChangedDays: Array<Array<number>>;
|
||||||
|
}
|
||||||
11
src/schedule/dto/v1/v1-site-main-page.dto.ts
Normal file
11
src/schedule/dto/v1/v1-site-main-page.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsBase64 } from "class-validator";
|
||||||
|
|
||||||
|
export class V1SiteMainPageDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: "MHz=",
|
||||||
|
description: "Страница политехникума",
|
||||||
|
})
|
||||||
|
@IsBase64()
|
||||||
|
mainPage: string;
|
||||||
|
}
|
||||||
18
src/schedule/dto/v2/v2-cache-status.dto.ts
Normal file
18
src/schedule/dto/v2/v2-cache-status.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { V1CacheStatusDto } from "../v1/v1-cache-status.dto";
|
||||||
|
import { IsNumber } from "class-validator";
|
||||||
|
|
||||||
|
export class V2CacheStatusDto extends V1CacheStatusDto {
|
||||||
|
/**
|
||||||
|
* Дата обновления кеша
|
||||||
|
* @example 1729288173002
|
||||||
|
*/
|
||||||
|
@IsNumber()
|
||||||
|
lastCacheUpdate: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата обновления расписания
|
||||||
|
* @example 1729288173002
|
||||||
|
*/
|
||||||
|
@IsNumber()
|
||||||
|
lastScheduleUpdate: number;
|
||||||
|
}
|
||||||
32
src/schedule/dto/v2/v2-day.dto.ts
Normal file
32
src/schedule/dto/v2/v2-day.dto.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V2LessonDto } from "./v2-lesson.dto";
|
||||||
|
|
||||||
|
export class V2DayDto {
|
||||||
|
/**
|
||||||
|
* День недели
|
||||||
|
* @example Понедельник
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата
|
||||||
|
* @example "2024-10-06T20:00:00.000Z"
|
||||||
|
*/
|
||||||
|
@IsDateString()
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Занятия
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => V2LessonDto)
|
||||||
|
lessons: Array<V2LessonDto>;
|
||||||
|
}
|
||||||
6
src/schedule/dto/v2/v2-group-schedule-by-name.dto.ts
Normal file
6
src/schedule/dto/v2/v2-group-schedule-by-name.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType, PickType } from "@nestjs/swagger";
|
||||||
|
import { V1GroupDto } from "../v1/v1-group.dto";
|
||||||
|
|
||||||
|
export class V2GroupScheduleByNameDto extends PartialType(
|
||||||
|
PickType(V1GroupDto, ["name"]),
|
||||||
|
) {}
|
||||||
22
src/schedule/dto/v2/v2-group-schedule.dto.ts
Normal file
22
src/schedule/dto/v2/v2-group-schedule.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsObject, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V2ScheduleDto } from "./v2-schedule.dto";
|
||||||
|
import { V2GroupDto } from "./v2-group.dto";
|
||||||
|
|
||||||
|
export class V2GroupScheduleDto extends PickType(V2ScheduleDto, ["updatedAt"]) {
|
||||||
|
/**
|
||||||
|
* Расписание группы
|
||||||
|
*/
|
||||||
|
@IsObject()
|
||||||
|
group: V2GroupDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновлённые дни с последнего изменения расписания
|
||||||
|
* @example [5, 6]
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => Number)
|
||||||
|
updated: Array<number>;
|
||||||
|
}
|
||||||
20
src/schedule/dto/v2/v2-group.dto.ts
Normal file
20
src/schedule/dto/v2/v2-group.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IsArray, IsString, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V2DayDto } from "./v2-day.dto";
|
||||||
|
|
||||||
|
export class V2GroupDto {
|
||||||
|
/**
|
||||||
|
* Название группы
|
||||||
|
* @example "ИС-214/23"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расписание каждого дня
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => V2DayDto)
|
||||||
|
days: Array<V2DayDto>;
|
||||||
|
}
|
||||||
26
src/schedule/dto/v2/v2-lesson-sub-group.dto.ts
Normal file
26
src/schedule/dto/v2/v2-lesson-sub-group.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsNumber, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class V2LessonSubGroupDto {
|
||||||
|
/**
|
||||||
|
* Номер подгруппы
|
||||||
|
* @example 1
|
||||||
|
*/
|
||||||
|
@IsNumber()
|
||||||
|
number: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кабинет
|
||||||
|
* @example "с\з"
|
||||||
|
* @example "42"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
cabinet: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ФИО преподавателя
|
||||||
|
* @example "Хомченко Н.Е."
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
teacher: string;
|
||||||
|
}
|
||||||
17
src/schedule/dto/v2/v2-lesson-time.dto.ts
Normal file
17
src/schedule/dto/v2/v2-lesson-time.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsDateString } from "class-validator";
|
||||||
|
|
||||||
|
export class V2LessonTimeDto {
|
||||||
|
/**
|
||||||
|
* Начало занятия
|
||||||
|
* @example "2024-10-07T04:30:00.000Z"
|
||||||
|
*/
|
||||||
|
@IsDateString()
|
||||||
|
start: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конец занятия
|
||||||
|
* @example "2024-10-07T04:40:00.000Z"
|
||||||
|
*/
|
||||||
|
@IsDateString()
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
67
src/schedule/dto/v2/v2-lesson.dto.ts
Normal file
67
src/schedule/dto/v2/v2-lesson.dto.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
|
||||||
|
import { V2LessonType } from "../../enum/v2-lesson-type.enum";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { NullIf } from "../../../utility/class-validators/conditional-field";
|
||||||
|
import { V2LessonTimeDto } from "./v2-lesson-time.dto";
|
||||||
|
import { V2LessonSubGroupDto } from "./v2-lesson-sub-group.dto";
|
||||||
|
|
||||||
|
export class V2LessonDto {
|
||||||
|
/**
|
||||||
|
* Тип занятия
|
||||||
|
* @example DEFAULT
|
||||||
|
*/
|
||||||
|
@IsEnum(V2LessonType)
|
||||||
|
type: V2LessonType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индексы пар, если присутствуют
|
||||||
|
* @example [1, 3]
|
||||||
|
* @optional
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsOptional()
|
||||||
|
@NullIf((self: V2LessonDto) => {
|
||||||
|
return self.type !== V2LessonType.DEFAULT;
|
||||||
|
})
|
||||||
|
defaultRange: Array<number> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Название занятия
|
||||||
|
* @example "Элементы высшей математики"
|
||||||
|
* @optional
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@NullIf((self: V2LessonDto) => {
|
||||||
|
return self.type === V2LessonType.BREAK;
|
||||||
|
})
|
||||||
|
name: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Начало и конец занятия
|
||||||
|
*/
|
||||||
|
@Type(() => V2LessonTimeDto)
|
||||||
|
time: V2LessonTimeDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип занятия
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => V2LessonSubGroupDto)
|
||||||
|
@IsOptional()
|
||||||
|
@NullIf((self: V2LessonDto) => {
|
||||||
|
return self.type !== V2LessonType.DEFAULT;
|
||||||
|
})
|
||||||
|
subGroups: Array<V2LessonSubGroupDto> | null;
|
||||||
|
}
|
||||||
10
src/schedule/dto/v2/v2-schedule-group-names.dto.ts
Normal file
10
src/schedule/dto/v2/v2-schedule-group-names.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsArray } from "class-validator";
|
||||||
|
|
||||||
|
export class V2ScheduleGroupNamesDto {
|
||||||
|
/**
|
||||||
|
* Группы
|
||||||
|
* @example ["ИС-214/23", "ИС-213/23"]
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
names: Array<string>;
|
||||||
|
}
|
||||||
29
src/schedule/dto/v2/v2-schedule.dto.ts
Normal file
29
src/schedule/dto/v2/v2-schedule.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsArray, IsDate, ValidateNested } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { V2GroupDto } from "./v2-group.dto";
|
||||||
|
|
||||||
|
export class V2ScheduleDto {
|
||||||
|
/**
|
||||||
|
* Дата когда последний раз расписание было скачано с сервера политехникума
|
||||||
|
* @example "2024-10-18T21:50:06.680Z"
|
||||||
|
*/
|
||||||
|
@IsDate()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расписание групп
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => V2GroupDto)
|
||||||
|
groups: Array<V2GroupDto>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновлённые дни с последнего изменения расписания
|
||||||
|
* @example { "ИС-214/23": [4, 5] }
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => Array<number>)
|
||||||
|
updatedGroups: Array<Array<number>>;
|
||||||
|
}
|
||||||
10
src/schedule/dto/v2/v2-update-download-url.dto.ts
Normal file
10
src/schedule/dto/v2/v2-update-download-url.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsUrl } from "class-validator";
|
||||||
|
|
||||||
|
export class V2UpdateDownloadUrlDto {
|
||||||
|
/**
|
||||||
|
* Прямая ссылка на скачивание расписания
|
||||||
|
* @example "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls"
|
||||||
|
*/
|
||||||
|
@IsUrl()
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
4
src/schedule/enum/v1-lesson-type.enum.ts
Normal file
4
src/schedule/enum/v1-lesson-type.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum V1LessonType {
|
||||||
|
DEFAULT = 0,
|
||||||
|
CUSTOM,
|
||||||
|
}
|
||||||
5
src/schedule/enum/v2-lesson-type.enum.ts
Normal file
5
src/schedule/enum/v2-lesson-type.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum V2LessonType {
|
||||||
|
DEFAULT = 0,
|
||||||
|
ADDITIONAL,
|
||||||
|
BREAK,
|
||||||
|
}
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
import {
|
import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interface";
|
||||||
XlsDownloaderBase,
|
|
||||||
XlsDownloaderCacheMode,
|
|
||||||
} from "../xls-downloader/xls-downloader.base";
|
|
||||||
|
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import {
|
|
||||||
DayDto,
|
|
||||||
GroupDto,
|
|
||||||
LessonDto,
|
|
||||||
LessonTimeDto,
|
|
||||||
LessonTypeDto,
|
|
||||||
} from "../../../dto/schedule.dto";
|
|
||||||
import { toNormalString, trimAll } from "../../../utility/string.util";
|
import { toNormalString, trimAll } from "../../../utility/string.util";
|
||||||
|
import { V1LessonTimeDto } from "../../dto/v1/v1-lesson-time.dto";
|
||||||
|
import { V1LessonType } from "../../enum/v1-lesson-type.enum";
|
||||||
|
import { V1LessonDto } from "../../dto/v1/v1-lesson.dto";
|
||||||
|
import { V1DayDto } from "../../dto/v1/v1-day.dto";
|
||||||
|
import { V1GroupDto } from "../../dto/v1/v1-group.dto";
|
||||||
|
import { ScheduleReplacerService } from "../../schedule-replacer.service";
|
||||||
|
import * as assert from "node:assert";
|
||||||
|
|
||||||
type InternalId = { row: number; column: number; name: string };
|
type InternalId = { row: number; column: number; name: string };
|
||||||
type InternalDay = InternalId;
|
type InternalDay = InternalId;
|
||||||
|
|
||||||
export class ScheduleParseResult {
|
export class ScheduleParseResult {
|
||||||
etag: string;
|
etag: string;
|
||||||
groups: Array<GroupDto>;
|
replacerId?: string;
|
||||||
|
groups: Array<V1GroupDto>;
|
||||||
affectedDays: Array<Array<number>>;
|
affectedDays: Array<Array<number>>;
|
||||||
updateRequired: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CellData = XLSX.CellObject["v"];
|
type CellData = XLSX.CellObject["v"];
|
||||||
|
|
||||||
export class ScheduleParser {
|
export class V1ScheduleParser {
|
||||||
private lastResult: ScheduleParseResult | null = null;
|
private lastResult: ScheduleParseResult | null = null;
|
||||||
|
|
||||||
public constructor(private readonly xlsDownloader: XlsDownloaderBase) {}
|
public constructor(
|
||||||
|
private readonly xlsDownloader: XlsDownloaderInterface,
|
||||||
|
private readonly scheduleReplacerService: ScheduleReplacerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private static getCellData(
|
private static getCellData(
|
||||||
worksheet: XLSX.Sheet,
|
worksheet: XLSX.Sheet,
|
||||||
@@ -83,7 +83,7 @@ export class ScheduleParser {
|
|||||||
const days: Array<InternalDay> = [];
|
const days: Array<InternalDay> = [];
|
||||||
|
|
||||||
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
|
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
|
||||||
const dayName = ScheduleParser.getCellData(worksheet, row, 0);
|
const dayName = V1ScheduleParser.getCellData(worksheet, row, 0);
|
||||||
if (!dayName) continue;
|
if (!dayName) continue;
|
||||||
|
|
||||||
if (!isHeaderParsed) {
|
if (!isHeaderParsed) {
|
||||||
@@ -95,7 +95,7 @@ export class ScheduleParser {
|
|||||||
column <= range.e.c;
|
column <= range.e.c;
|
||||||
++column
|
++column
|
||||||
) {
|
) {
|
||||||
const groupName = ScheduleParser.getCellData(
|
const groupName = V1ScheduleParser.getCellData(
|
||||||
worksheet,
|
worksheet,
|
||||||
row,
|
row,
|
||||||
column,
|
column,
|
||||||
@@ -134,37 +134,51 @@ export class ScheduleParser {
|
|||||||
return { daySkeletons: days, groupSkeletons: groups };
|
return { daySkeletons: days, groupSkeletons: groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
getXlsDownloader(): XlsDownloaderBase {
|
getXlsDownloader(): XlsDownloaderInterface {
|
||||||
return this.xlsDownloader;
|
return this.xlsDownloader;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSchedule(
|
async getSchedule(): Promise<ScheduleParseResult> {
|
||||||
forceCached: boolean = false,
|
const headData = await this.xlsDownloader.fetch(true);
|
||||||
): Promise<ScheduleParseResult> {
|
this.xlsDownloader.verifyFetchResult(headData);
|
||||||
if (forceCached && this.lastResult !== null) return this.lastResult;
|
|
||||||
|
|
||||||
const downloadData = await this.xlsDownloader.downloadXLS();
|
assert(headData.type === "success");
|
||||||
|
|
||||||
if (
|
const replacer = await this.scheduleReplacerService.getByEtag(
|
||||||
!downloadData.new &&
|
headData.etag,
|
||||||
this.lastResult &&
|
);
|
||||||
this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE
|
|
||||||
)
|
if (this.lastResult && this.lastResult.etag === headData.etag) {
|
||||||
|
if (!replacer) return this.lastResult;
|
||||||
|
|
||||||
|
if (this.lastResult.replacerId === replacer.id)
|
||||||
return this.lastResult;
|
return this.lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
const workBook = XLSX.read(downloadData.fileData);
|
const buffer = async () => {
|
||||||
|
if (replacer) return replacer.data;
|
||||||
|
|
||||||
|
const downloadData = await this.xlsDownloader.fetch(false);
|
||||||
|
this.xlsDownloader.verifyFetchResult(downloadData);
|
||||||
|
|
||||||
|
assert(downloadData.type === "success");
|
||||||
|
|
||||||
|
return downloadData.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workBook = XLSX.read(await buffer());
|
||||||
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
|
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
|
||||||
|
|
||||||
const { groupSkeletons, daySkeletons } = this.parseSkeleton(workSheet);
|
const { groupSkeletons, daySkeletons } = this.parseSkeleton(workSheet);
|
||||||
|
|
||||||
const groups: Array<GroupDto> = [];
|
const groups: Array<V1GroupDto> = [];
|
||||||
|
|
||||||
for (const groupSkeleton of groupSkeletons) {
|
for (const groupSkeleton of groupSkeletons) {
|
||||||
const group = new GroupDto(groupSkeleton.name);
|
const group = new V1GroupDto(groupSkeleton.name);
|
||||||
|
|
||||||
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
|
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
|
||||||
const daySkeleton = daySkeletons[dayIdx];
|
const daySkeleton = daySkeletons[dayIdx];
|
||||||
const day = new DayDto(daySkeleton.name);
|
const day = new V1DayDto(daySkeleton.name);
|
||||||
|
|
||||||
const lessonTimeColumn = daySkeletons[0].column + 1;
|
const lessonTimeColumn = daySkeletons[0].column + 1;
|
||||||
const rowDistance =
|
const rowDistance =
|
||||||
@@ -176,7 +190,7 @@ export class ScheduleParser {
|
|||||||
++row
|
++row
|
||||||
) {
|
) {
|
||||||
// time
|
// time
|
||||||
const time = ScheduleParser.getCellData(
|
const time = V1ScheduleParser.getCellData(
|
||||||
workSheet,
|
workSheet,
|
||||||
row,
|
row,
|
||||||
lessonTimeColumn,
|
lessonTimeColumn,
|
||||||
@@ -186,7 +200,7 @@ export class ScheduleParser {
|
|||||||
|
|
||||||
// name
|
// name
|
||||||
const rawName: CellData = trimAll(
|
const rawName: CellData = trimAll(
|
||||||
ScheduleParser.getCellData(
|
V1ScheduleParser.getCellData(
|
||||||
workSheet,
|
workSheet,
|
||||||
row,
|
row,
|
||||||
groupSkeleton.column,
|
groupSkeleton.column,
|
||||||
@@ -201,7 +215,7 @@ export class ScheduleParser {
|
|||||||
// cabinets
|
// cabinets
|
||||||
const cabinets: Array<string> = [];
|
const cabinets: Array<string> = [];
|
||||||
|
|
||||||
const rawCabinets = ScheduleParser.getCellData(
|
const rawCabinets = V1ScheduleParser.getCellData(
|
||||||
workSheet,
|
workSheet,
|
||||||
row,
|
row,
|
||||||
groupSkeleton.column + 1,
|
groupSkeleton.column + 1,
|
||||||
@@ -219,8 +233,8 @@ export class ScheduleParser {
|
|||||||
|
|
||||||
// type
|
// type
|
||||||
const lessonType = time?.includes("пара")
|
const lessonType = time?.includes("пара")
|
||||||
? LessonTypeDto.DEFAULT
|
? V1LessonType.DEFAULT
|
||||||
: LessonTypeDto.CUSTOM;
|
: V1LessonType.CUSTOM;
|
||||||
|
|
||||||
// full names
|
// full names
|
||||||
const { name, teacherFullNames } =
|
const { name, teacherFullNames } =
|
||||||
@@ -229,13 +243,13 @@ export class ScheduleParser {
|
|||||||
);
|
);
|
||||||
|
|
||||||
day.lessons.push(
|
day.lessons.push(
|
||||||
new LessonDto(
|
new V1LessonDto(
|
||||||
lessonType,
|
lessonType,
|
||||||
lessonType === LessonTypeDto.DEFAULT
|
lessonType === V1LessonType.DEFAULT
|
||||||
? Number.parseInt(time[0])
|
? Number.parseInt(time[0])
|
||||||
: -1,
|
: -1,
|
||||||
LessonTimeDto.fromString(
|
V1LessonTimeDto.fromString(
|
||||||
lessonType === LessonTypeDto.DEFAULT
|
lessonType === V1LessonType.DEFAULT
|
||||||
? time.substring(5)
|
? time.substring(5)
|
||||||
: time,
|
: time,
|
||||||
),
|
),
|
||||||
@@ -256,16 +270,16 @@ export class ScheduleParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (this.lastResult = {
|
return (this.lastResult = {
|
||||||
etag: downloadData.etag,
|
etag: headData.etag,
|
||||||
|
replacerId: replacer?.id,
|
||||||
groups: groups,
|
groups: groups,
|
||||||
affectedDays: this.getAffectedDays(this.lastResult?.groups, groups),
|
affectedDays: this.getAffectedDays(this.lastResult?.groups, groups),
|
||||||
updateRequired: downloadData.updateRequired,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAffectedDays(
|
private getAffectedDays(
|
||||||
cachedGroups: Array<GroupDto> | null,
|
cachedGroups: Array<V1GroupDto> | null,
|
||||||
groups: Array<GroupDto>,
|
groups: Array<V1GroupDto>,
|
||||||
): Array<Array<number>> {
|
): Array<Array<number>> {
|
||||||
const affectedDays: Array<Array<number>> = [];
|
const affectedDays: Array<Array<number>> = [];
|
||||||
|
|
||||||
@@ -273,8 +287,8 @@ export class ScheduleParser {
|
|||||||
|
|
||||||
// noinspection SpellCheckingInspection
|
// noinspection SpellCheckingInspection
|
||||||
const dayEquals = (
|
const dayEquals = (
|
||||||
lday: DayDto | null,
|
lday: V1DayDto | null,
|
||||||
rday: DayDto | undefined,
|
rday: V1DayDto | undefined,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!lday || !rday || rday.lessons.length != lday.lessons.length)
|
if (!lday || !rday || rday.lessons.length != lday.lessons.length)
|
||||||
return false;
|
return false;
|
||||||
116
src/schedule/internal/schedule-parser/v2-schedule-parser.spec.ts
Normal file
116
src/schedule/internal/schedule-parser/v2-schedule-parser.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { V2ScheduleParser } from "./v2-schedule-parser";
|
||||||
|
import { BasicXlsDownloader } from "../xls-downloader/basic-xls-downloader";
|
||||||
|
import { V2DayDto } from "../../dto/v2/v2-day.dto";
|
||||||
|
import { V2GroupDto } from "../../dto/v2/v2-group.dto";
|
||||||
|
|
||||||
|
describe("V2ScheduleParser", () => {
|
||||||
|
let parser: V2ScheduleParser;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const xlsDownloader = new BasicXlsDownloader();
|
||||||
|
parser = new V2ScheduleParser(xlsDownloader);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ошибки", () => {
|
||||||
|
it("Должен вернуть ошибку из-за отсутствия ссылки на скачивание", async () => {
|
||||||
|
await expect(() => parser.getSchedule()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setLink(link: string): Promise<void> {
|
||||||
|
await parser.getXlsDownloader().setDownloadUrl(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTest = async () => {
|
||||||
|
const schedule = await parser.getSchedule();
|
||||||
|
|
||||||
|
expect(schedule).toBeDefined();
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameTest = async () => {
|
||||||
|
const schedule = await parser.getSchedule();
|
||||||
|
expect(schedule).toBeDefined();
|
||||||
|
|
||||||
|
const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"];
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
|
||||||
|
const saturday: V2DayDto = group.days[5];
|
||||||
|
expect(saturday).toBeDefined();
|
||||||
|
|
||||||
|
const name = saturday.name;
|
||||||
|
expect(name).toBeDefined();
|
||||||
|
expect(name.length).toBeGreaterThan(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Старое расписание", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await setLink(
|
||||||
|
"https://politehnikum-eng.ru/2024/poltavskaja_06_s_07_po_13_10.xls",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Должен вернуть расписание", defaultTest);
|
||||||
|
it("Название дня не должно быть пустым или null", nameTest);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Новое расписание", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await setLink(
|
||||||
|
"https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-8-1-.xls",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Должен вернуть расписание", defaultTest);
|
||||||
|
it("Название дня не должно быть пустым или null", nameTest);
|
||||||
|
|
||||||
|
it("Парсер должен вернуть корректное время если она на нескольких линиях", async () => {
|
||||||
|
const schedule = await parser.getSchedule();
|
||||||
|
expect(schedule).toBeDefined();
|
||||||
|
|
||||||
|
const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"];
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
|
||||||
|
const saturday: V2DayDto = group.days[5];
|
||||||
|
expect(saturday).toBeDefined();
|
||||||
|
|
||||||
|
const firstLesson = saturday.lessons[0];
|
||||||
|
expect(firstLesson).toBeDefined();
|
||||||
|
|
||||||
|
expect(firstLesson.time).toBeDefined();
|
||||||
|
|
||||||
|
expect(firstLesson.time.start).toBeDefined();
|
||||||
|
expect(firstLesson.time.end).toBeDefined();
|
||||||
|
|
||||||
|
const startMinutes =
|
||||||
|
firstLesson.time.start.getHours() * 60 +
|
||||||
|
firstLesson.time.start.getMinutes();
|
||||||
|
const endMinutes =
|
||||||
|
firstLesson.time.end.getHours() * 60 +
|
||||||
|
firstLesson.time.end.getMinutes();
|
||||||
|
|
||||||
|
const differenceMinutes = endMinutes - startMinutes;
|
||||||
|
|
||||||
|
expect(differenceMinutes).toBe(190);
|
||||||
|
|
||||||
|
expect(firstLesson.defaultRange).toStrictEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ошибка парсинга?", async () => {
|
||||||
|
const schedule = await parser.getSchedule();
|
||||||
|
expect(schedule).toBeDefined();
|
||||||
|
|
||||||
|
const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"];
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
|
||||||
|
const thursday: V2DayDto = group.days[3];
|
||||||
|
expect(thursday).toBeDefined();
|
||||||
|
|
||||||
|
expect(thursday.lessons.length).toBe(5);
|
||||||
|
|
||||||
|
const lastLessonName = thursday.lessons[4].name;
|
||||||
|
expect(lastLessonName).toBe(
|
||||||
|
"МДК.05.01 Проектирование и дизайн информационных систем",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
673
src/schedule/internal/schedule-parser/v2-schedule-parser.ts
Normal file
673
src/schedule/internal/schedule-parser/v2-schedule-parser.ts
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interface";
|
||||||
|
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { Range, WorkSheet } from "xlsx";
|
||||||
|
import { toNormalString, trimAll } from "../../../utility/string.util";
|
||||||
|
import { plainToClass, plainToInstance } from "class-transformer";
|
||||||
|
import * as objectHash from "object-hash";
|
||||||
|
import { V2LessonTimeDto } from "../../dto/v2/v2-lesson-time.dto";
|
||||||
|
import { V2LessonType } from "../../enum/v2-lesson-type.enum";
|
||||||
|
import { V2LessonSubGroupDto } from "../../dto/v2/v2-lesson-sub-group.dto";
|
||||||
|
import { V2LessonDto } from "../../dto/v2/v2-lesson.dto";
|
||||||
|
import { V2DayDto } from "../../dto/v2/v2-day.dto";
|
||||||
|
import { V2GroupDto } from "../../dto/v2/v2-group.dto";
|
||||||
|
import * as assert from "node:assert";
|
||||||
|
import { ScheduleReplacerService } from "../../schedule-replacer.service";
|
||||||
|
|
||||||
|
type InternalId = {
|
||||||
|
/**
|
||||||
|
* Индекс строки
|
||||||
|
*/
|
||||||
|
row: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индекс столбца
|
||||||
|
*/
|
||||||
|
column: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст записи
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InternalTime = {
|
||||||
|
/**
|
||||||
|
* Временной отрезок
|
||||||
|
*/
|
||||||
|
timeRange: V2LessonTimeDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип пары на этой строке
|
||||||
|
*/
|
||||||
|
lessonType: V2LessonType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индекс пары на этой строке
|
||||||
|
*/
|
||||||
|
defaultIndex?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиции начальной и конечной записи
|
||||||
|
*/
|
||||||
|
xlsxRange: Range;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class V2ScheduleParseResult {
|
||||||
|
/**
|
||||||
|
* ETag расписания
|
||||||
|
*/
|
||||||
|
etag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идентификатор заменённого расписания (ObjectId)
|
||||||
|
*/
|
||||||
|
replacerId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата загрузки расписания на сайт политехникума
|
||||||
|
*/
|
||||||
|
uploadedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата загрузки расписания с сайта политехникума
|
||||||
|
*/
|
||||||
|
downloadedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расписание групп в виде списка.
|
||||||
|
* Ключ - название группы.
|
||||||
|
*/
|
||||||
|
groups: Array<V2GroupDto>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список групп у которых было обновлено расписание с момента последнего обновления файла.
|
||||||
|
* Ключ - название группы.
|
||||||
|
*/
|
||||||
|
updatedGroups: Array<Array<number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class V2ScheduleParser {
|
||||||
|
private lastResult: V2ScheduleParseResult | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param xlsDownloader - класс для загрузки расписания с сайта политехникума
|
||||||
|
* @param scheduleReplacerService - сервис для подмены расписания
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private readonly xlsDownloader: XlsDownloaderInterface,
|
||||||
|
private readonly scheduleReplacerService?: ScheduleReplacerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает позиции начальной и конечной записи относительно начальной записи
|
||||||
|
* @param workSheet - xls лист
|
||||||
|
* @param topRow - индекс начальной строки
|
||||||
|
* @param leftColumn - индекс начального столбца
|
||||||
|
* @returns {Range} - позиции начальной и конечной записи
|
||||||
|
* @private
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
private static getMergeFromStart(
|
||||||
|
workSheet: XLSX.WorkSheet,
|
||||||
|
topRow: number,
|
||||||
|
leftColumn: number,
|
||||||
|
): Range {
|
||||||
|
for (const range of workSheet["!merges"]) {
|
||||||
|
if (topRow === range.s.r && leftColumn === range.s.c) return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
s: { r: topRow, c: leftColumn },
|
||||||
|
e: { r: topRow, c: leftColumn },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает текст из требуемой записи
|
||||||
|
* @param worksheet - xls лист
|
||||||
|
* @param row - индекс строки
|
||||||
|
* @param column - индекс столбца
|
||||||
|
* @returns {string | null} - текст записи, если присутствует
|
||||||
|
* @private
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
private static getCellData(
|
||||||
|
worksheet: XLSX.WorkSheet,
|
||||||
|
row: number,
|
||||||
|
column: number,
|
||||||
|
): string | null {
|
||||||
|
const cell: XLSX.CellObject | null =
|
||||||
|
worksheet[XLSX.utils.encode_cell({ r: row, c: column })];
|
||||||
|
|
||||||
|
return toNormalString(cell?.w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит информацию о паре исходя из текста в записи
|
||||||
|
* @param lessonName - текст в записи
|
||||||
|
* @returns {{
|
||||||
|
* name: string;
|
||||||
|
* subGroups: Array<V2LessonSubGroupDto>;
|
||||||
|
* }} - название пары и список подгрупп
|
||||||
|
* @private
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
private static parseNameAndSubGroups(lessonName: string): {
|
||||||
|
name: string;
|
||||||
|
subGroups: Array<V2LessonSubGroupDto>;
|
||||||
|
} {
|
||||||
|
// хд
|
||||||
|
|
||||||
|
const allRegex =
|
||||||
|
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\([0-9]\s?подгруппа\))?(?:,\s)?)+$/gm;
|
||||||
|
const teacherAndSubGroupRegex =
|
||||||
|
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\([0-9]\s?подгруппа\))?)+/gm;
|
||||||
|
|
||||||
|
const allMatch = allRegex.exec(lessonName);
|
||||||
|
|
||||||
|
// если не ничё не найдено
|
||||||
|
if (allMatch === null) return { name: lessonName, subGroups: [] };
|
||||||
|
|
||||||
|
const all: Array<string> = [];
|
||||||
|
|
||||||
|
let allInnerMatch: RegExpExecArray;
|
||||||
|
while (
|
||||||
|
(allInnerMatch = teacherAndSubGroupRegex.exec(allMatch[0])) !== null
|
||||||
|
) {
|
||||||
|
if (allInnerMatch.index === teacherAndSubGroupRegex.lastIndex)
|
||||||
|
teacherAndSubGroupRegex.lastIndex++;
|
||||||
|
|
||||||
|
all.push(allInnerMatch[0].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// парадокс
|
||||||
|
if (all.length === 0) {
|
||||||
|
throw new Error("Парадокс");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subGroups: Array<V2LessonSubGroupDto> = [];
|
||||||
|
|
||||||
|
for (const teacherAndSubGroup of all) {
|
||||||
|
const teacherRegex = /[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\./g;
|
||||||
|
const subGroupRegex = /\([0-9]\s?подгруппа\)/g;
|
||||||
|
|
||||||
|
const teacherMatch = teacherRegex.exec(teacherAndSubGroup);
|
||||||
|
if (teacherMatch === null) throw new Error("Парадокс");
|
||||||
|
|
||||||
|
let teacherFIO = teacherMatch[0];
|
||||||
|
const teacherSpaceIndex = teacherFIO.indexOf(" ") + 1;
|
||||||
|
const teacherIO = teacherFIO
|
||||||
|
.substring(teacherSpaceIndex)
|
||||||
|
.replaceAll("s", "");
|
||||||
|
|
||||||
|
teacherFIO = `${teacherFIO.substring(0, teacherSpaceIndex)}${teacherIO}`;
|
||||||
|
|
||||||
|
const subGroupMatch = subGroupRegex.exec(teacherAndSubGroup);
|
||||||
|
const subGroup = subGroupMatch
|
||||||
|
? Number.parseInt(subGroupMatch[0][1])
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
subGroups.push(
|
||||||
|
plainToClass(V2LessonSubGroupDto, {
|
||||||
|
teacher: teacherFIO,
|
||||||
|
number: subGroup,
|
||||||
|
cabinet: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index in subGroups) {
|
||||||
|
if (subGroups.length === 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// бляздец
|
||||||
|
switch (index) {
|
||||||
|
case "0":
|
||||||
|
subGroups[index].number =
|
||||||
|
subGroups[+index + 1].number === 2 ? 1 : 2;
|
||||||
|
continue;
|
||||||
|
case "1":
|
||||||
|
subGroups[index].number =
|
||||||
|
subGroups[+index - 1].number === 1 ? 2 : 1;
|
||||||
|
continue;
|
||||||
|
default:
|
||||||
|
subGroups[index].number = +index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: lessonName.substring(0, allMatch.index).trim(),
|
||||||
|
subGroups: subGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит информацию о группах и днях недели
|
||||||
|
* @param workSheet - xls лист
|
||||||
|
* @returns {{
|
||||||
|
* groupSkeletons: Array<InternalId>;
|
||||||
|
* daySkeletons: Array<InternalId>;
|
||||||
|
* }} - список с индексами и текстом записей групп и дней недели
|
||||||
|
* @private
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
private static parseSkeleton(workSheet: XLSX.WorkSheet): {
|
||||||
|
groupSkeletons: Array<InternalId>;
|
||||||
|
daySkeletons: Array<InternalId>;
|
||||||
|
} {
|
||||||
|
const range = XLSX.utils.decode_range(workSheet["!ref"] || "");
|
||||||
|
let isHeaderParsed: boolean = false;
|
||||||
|
|
||||||
|
const groups: Array<InternalId> = [];
|
||||||
|
const days: Array<InternalId> = [];
|
||||||
|
|
||||||
|
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
|
||||||
|
const dayName = V2ScheduleParser.getCellData(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 = V2ScheduleParser.getCellData(
|
||||||
|
workSheet,
|
||||||
|
row,
|
||||||
|
column,
|
||||||
|
);
|
||||||
|
if (!groupName) continue;
|
||||||
|
|
||||||
|
groups.push({ row: row, column: column, name: groupName });
|
||||||
|
}
|
||||||
|
++row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
days.length == 0 ||
|
||||||
|
!days[days.length - 1].name.startsWith("Суббота")
|
||||||
|
) {
|
||||||
|
const dayMonthIdx = /[А-Яа-я]+\s(\d+)\.\d+\.\d+/.exec(
|
||||||
|
trimAll(dayName),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dayMonthIdx === null) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
days.push({
|
||||||
|
row: row,
|
||||||
|
column: 0,
|
||||||
|
name: dayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
days.length > 2 &&
|
||||||
|
days[days.length - 2].name.startsWith("Суббота")
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { daySkeletons: days, groupSkeletons: groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает текущий класс для скачивания xls файлов
|
||||||
|
* @returns {XlsDownloaderInterface} - класс для скачивания xls файлов
|
||||||
|
*/
|
||||||
|
getXlsDownloader(): XlsDownloaderInterface {
|
||||||
|
return this.xlsDownloader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает текущее расписание
|
||||||
|
* @returns {V2ScheduleParseResult} - расписание
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
async getSchedule(): Promise<V2ScheduleParseResult> {
|
||||||
|
const headData = await this.xlsDownloader.fetch(true);
|
||||||
|
this.xlsDownloader.verifyFetchResult(headData);
|
||||||
|
|
||||||
|
assert(headData.type === "success");
|
||||||
|
|
||||||
|
const replacer = this.scheduleReplacerService
|
||||||
|
? await this.scheduleReplacerService.getByEtag(headData.etag)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (this.lastResult && this.lastResult.etag === headData.etag) {
|
||||||
|
if (!replacer) return this.lastResult;
|
||||||
|
|
||||||
|
if (this.lastResult.replacerId === replacer.id)
|
||||||
|
return this.lastResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = async () => {
|
||||||
|
if (replacer) return replacer.data;
|
||||||
|
|
||||||
|
const downloadData = await this.xlsDownloader.fetch(false);
|
||||||
|
this.xlsDownloader.verifyFetchResult(downloadData);
|
||||||
|
|
||||||
|
assert(downloadData.type === "success");
|
||||||
|
|
||||||
|
return downloadData.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workBook = XLSX.read(await buffer());
|
||||||
|
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
|
||||||
|
|
||||||
|
const { groupSkeletons, daySkeletons } =
|
||||||
|
V2ScheduleParser.parseSkeleton(workSheet);
|
||||||
|
|
||||||
|
const groups: Array<V2GroupDto> = [];
|
||||||
|
|
||||||
|
const daysTimes: Array<Array<InternalTime>> = [];
|
||||||
|
let daysTimesFilled = false;
|
||||||
|
|
||||||
|
for (const groupSkeleton of groupSkeletons) {
|
||||||
|
const group = new V2GroupDto();
|
||||||
|
group.name = groupSkeleton.name;
|
||||||
|
group.days = [];
|
||||||
|
|
||||||
|
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
|
||||||
|
const daySkeleton = daySkeletons[dayIdx];
|
||||||
|
const day = new V2DayDto();
|
||||||
|
{
|
||||||
|
const daySpaceIndex = daySkeleton.name.indexOf(" ");
|
||||||
|
day.name = daySkeleton.name.substring(0, daySpaceIndex);
|
||||||
|
|
||||||
|
const dateString = daySkeleton.name.substring(
|
||||||
|
daySpaceIndex + 1,
|
||||||
|
);
|
||||||
|
const parseableDateString = `${dateString.substring(3, 5)}.${dateString.substring(0, 2)}.${dateString.substring(6)}`;
|
||||||
|
day.date = new Date(Date.parse(parseableDateString));
|
||||||
|
|
||||||
|
day.lessons = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonTimeColumn = daySkeletons[0].column + 1;
|
||||||
|
const rowDistance =
|
||||||
|
daySkeletons[dayIdx + 1].row - daySkeleton.row;
|
||||||
|
|
||||||
|
const dayTimes: Array<InternalTime> = daysTimesFilled
|
||||||
|
? daysTimes[day.name]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!daysTimesFilled) {
|
||||||
|
for (
|
||||||
|
let row = daySkeleton.row;
|
||||||
|
row < daySkeleton.row + rowDistance;
|
||||||
|
++row
|
||||||
|
) {
|
||||||
|
const time = V2ScheduleParser.getCellData(
|
||||||
|
workSheet,
|
||||||
|
row,
|
||||||
|
lessonTimeColumn,
|
||||||
|
)?.replaceAll(/[\s\t\n\r]/g, "");
|
||||||
|
|
||||||
|
if (!time) continue;
|
||||||
|
|
||||||
|
// type
|
||||||
|
const lessonType = time.includes("пара")
|
||||||
|
? V2LessonType.DEFAULT
|
||||||
|
: V2LessonType.ADDITIONAL;
|
||||||
|
|
||||||
|
const defaultIndex =
|
||||||
|
lessonType === V2LessonType.DEFAULT
|
||||||
|
? +time[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// time
|
||||||
|
const timeRange = new V2LessonTimeDto();
|
||||||
|
|
||||||
|
timeRange.start = new Date(day.date);
|
||||||
|
timeRange.end = new Date(day.date);
|
||||||
|
|
||||||
|
const timeString = time.replaceAll(".", ":");
|
||||||
|
const timeRegex = /(\d+:\d+)-(\d+:\d+)/g;
|
||||||
|
|
||||||
|
const parseResult = timeRegex.exec(timeString);
|
||||||
|
if (!parseResult) {
|
||||||
|
throw new Error(
|
||||||
|
"Не удалось узнать начало и конец пар!",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startStrings = parseResult[1].split(":");
|
||||||
|
timeRange.start.setHours(+startStrings[0]);
|
||||||
|
timeRange.start.setMinutes(+startStrings[1]);
|
||||||
|
|
||||||
|
const endStrings = parseResult[2].split(":");
|
||||||
|
timeRange.end.setHours(+endStrings[0]);
|
||||||
|
timeRange.end.setMinutes(+endStrings[1]);
|
||||||
|
|
||||||
|
dayTimes.push({
|
||||||
|
timeRange: timeRange,
|
||||||
|
|
||||||
|
lessonType: lessonType,
|
||||||
|
defaultIndex: defaultIndex,
|
||||||
|
|
||||||
|
xlsxRange: V2ScheduleParser.getMergeFromStart(
|
||||||
|
workSheet,
|
||||||
|
row,
|
||||||
|
lessonTimeColumn,
|
||||||
|
),
|
||||||
|
} as InternalTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
daysTimes[day.name] = dayTimes;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const time of dayTimes) {
|
||||||
|
// if (day.name === "Четверг" && group.name === "ИС-214/23") {
|
||||||
|
// console.log("-------------------");
|
||||||
|
// console.log(groupSkeleton.column);
|
||||||
|
// console.log(time.xlsxRange);
|
||||||
|
// }
|
||||||
|
const lessons = V2ScheduleParser.parseLesson(
|
||||||
|
workSheet,
|
||||||
|
day,
|
||||||
|
dayTimes,
|
||||||
|
time,
|
||||||
|
groupSkeleton.column,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const lesson of lessons) day.lessons.push(lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
group.days.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!daysTimesFilled) daysTimesFilled = true;
|
||||||
|
|
||||||
|
groups[group.name] = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedGroups = V2ScheduleParser.getUpdatedGroups(
|
||||||
|
this.lastResult?.groups,
|
||||||
|
groups,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (this.lastResult = {
|
||||||
|
downloadedAt: headData.requestedAt,
|
||||||
|
uploadedAt: headData.uploadedAt,
|
||||||
|
|
||||||
|
etag: headData.etag,
|
||||||
|
replacerId: replacer?.id,
|
||||||
|
groups: groups,
|
||||||
|
updatedGroups:
|
||||||
|
updatedGroups.length === 0
|
||||||
|
? (this.lastResult?.updatedGroups ?? [])
|
||||||
|
: updatedGroups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseLesson(
|
||||||
|
workSheet: XLSX.Sheet,
|
||||||
|
day: V2DayDto,
|
||||||
|
dayTimes: Array<InternalTime>,
|
||||||
|
time: InternalTime,
|
||||||
|
column: number,
|
||||||
|
): Array<V2LessonDto> {
|
||||||
|
const row = time.xlsxRange.s.r;
|
||||||
|
|
||||||
|
if (typeof column !== "number") {
|
||||||
|
console.log(typeof column);
|
||||||
|
console.log(column);
|
||||||
|
}
|
||||||
|
|
||||||
|
// name
|
||||||
|
const rawName = trimAll(
|
||||||
|
V2ScheduleParser.getCellData(workSheet, row, column)?.replaceAll(
|
||||||
|
/[\n\r]/g,
|
||||||
|
"",
|
||||||
|
) ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rawName.length === 0) return [];
|
||||||
|
|
||||||
|
const lesson = new V2LessonDto();
|
||||||
|
|
||||||
|
lesson.type = time.lessonType;
|
||||||
|
lesson.defaultRange =
|
||||||
|
time.defaultIndex !== null
|
||||||
|
? [time.defaultIndex, time.defaultIndex]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
lesson.time = new V2LessonTimeDto();
|
||||||
|
lesson.time.start = time.timeRange.start;
|
||||||
|
|
||||||
|
// check if multi-lesson
|
||||||
|
const range = this.getMergeFromStart(workSheet, row, column);
|
||||||
|
const endTime = dayTimes.filter((dayTime) => {
|
||||||
|
return dayTime.xlsxRange.e.r === range.e.r;
|
||||||
|
})[0];
|
||||||
|
lesson.time.end = endTime?.timeRange.end ?? time.timeRange.end;
|
||||||
|
|
||||||
|
if (lesson.defaultRange !== null)
|
||||||
|
lesson.defaultRange[1] = endTime?.defaultIndex ?? time.defaultIndex;
|
||||||
|
|
||||||
|
// name and subGroups (subGroups unfilled)
|
||||||
|
{
|
||||||
|
const nameAndGroups = V2ScheduleParser.parseNameAndSubGroups(
|
||||||
|
trimAll(rawName?.replaceAll(/[\n\r]/g, "") ?? ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
lesson.name = nameAndGroups.name;
|
||||||
|
lesson.subGroups = nameAndGroups.subGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabinets
|
||||||
|
{
|
||||||
|
const cabinets = V2ScheduleParser.parseCabinets(
|
||||||
|
workSheet,
|
||||||
|
row,
|
||||||
|
column + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cabinets.length === 1) {
|
||||||
|
for (const index in lesson.subGroups)
|
||||||
|
lesson.subGroups[index].cabinet = cabinets[0];
|
||||||
|
} else if (cabinets.length === lesson.subGroups.length) {
|
||||||
|
for (const index in lesson.subGroups)
|
||||||
|
lesson.subGroups[index].cabinet = cabinets[index];
|
||||||
|
} else if (cabinets.length !== 0) {
|
||||||
|
if (cabinets.length > lesson.subGroups.length) {
|
||||||
|
for (const index in cabinets) {
|
||||||
|
if (lesson.subGroups[index] === undefined) {
|
||||||
|
lesson.subGroups.push(
|
||||||
|
plainToInstance(V2LessonSubGroupDto, {
|
||||||
|
number: +index + 1,
|
||||||
|
teacher: "Ошибка в расписании",
|
||||||
|
cabinet: cabinets[index],
|
||||||
|
} as V2LessonSubGroupDto),
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson.subGroups[index].cabinet = cabinets[index];
|
||||||
|
}
|
||||||
|
} else throw new Error("Разное кол-во кабинетов и подгрупп!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevLesson =
|
||||||
|
(day.lessons?.length ?? 0) === 0
|
||||||
|
? null
|
||||||
|
: day.lessons[day.lessons.length - 1];
|
||||||
|
|
||||||
|
if (!prevLesson) return [lesson];
|
||||||
|
|
||||||
|
return [
|
||||||
|
plainToInstance(V2LessonDto, {
|
||||||
|
type: V2LessonType.BREAK,
|
||||||
|
defaultRange: null,
|
||||||
|
name: null,
|
||||||
|
time: plainToInstance(V2LessonTimeDto, {
|
||||||
|
start: prevLesson.time.end,
|
||||||
|
end: lesson.time.start,
|
||||||
|
} as V2LessonTimeDto),
|
||||||
|
subGroups: [],
|
||||||
|
} as V2LessonDto),
|
||||||
|
lesson,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseCabinets(
|
||||||
|
workSheet: WorkSheet,
|
||||||
|
row: number,
|
||||||
|
column: number,
|
||||||
|
) {
|
||||||
|
const cabinets: Array<string> = [];
|
||||||
|
{
|
||||||
|
const rawCabinets = V2ScheduleParser.getCellData(
|
||||||
|
workSheet,
|
||||||
|
row,
|
||||||
|
column,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rawCabinets) {
|
||||||
|
const parts = rawCabinets.split(/(\n|\s)/g);
|
||||||
|
|
||||||
|
for (const cabinet of parts) {
|
||||||
|
if (!toNormalString(cabinet)) continue;
|
||||||
|
|
||||||
|
cabinets.push(cabinet.replaceAll(/[\n\s\r]/g, " "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cabinets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getUpdatedGroups(
|
||||||
|
cachedGroups: Array<V2GroupDto> | null,
|
||||||
|
currentGroups: Array<V2GroupDto>,
|
||||||
|
): Array<Array<number>> {
|
||||||
|
if (!cachedGroups) return [];
|
||||||
|
|
||||||
|
const updatedGroups = [];
|
||||||
|
|
||||||
|
for (const groupName in cachedGroups) {
|
||||||
|
const cachedGroup = cachedGroups[groupName];
|
||||||
|
const currentGroup = currentGroups[groupName];
|
||||||
|
|
||||||
|
const affectedGroupDays: Array<number> = [];
|
||||||
|
|
||||||
|
for (const dayIdx in currentGroup.days) {
|
||||||
|
if (
|
||||||
|
objectHash.sha1(currentGroup.days[dayIdx]) !==
|
||||||
|
objectHash.sha1(cachedGroup.days[dayIdx])
|
||||||
|
)
|
||||||
|
affectedGroupDays.push(Number.parseInt(dayIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedGroups[groupName] = affectedGroupDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedGroups;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BasicXlsDownloader } from "./basic-xls-downloader";
|
||||||
|
import { XlsDownloaderInterface } from "./xls-downloader.interface";
|
||||||
|
|
||||||
|
describe("BasicXlsDownloader", () => {
|
||||||
|
let downloader: XlsDownloaderInterface;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
downloader = new BasicXlsDownloader();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Должен вызвать ошибку из-за отсутствия ссылки на скачивание", async () => {
|
||||||
|
await expect(async () => {
|
||||||
|
const result = await downloader.fetch(false);
|
||||||
|
downloader.verifyFetchResult(result);
|
||||||
|
}).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Должен вызвать ошибку из-за неверной ссылки на скачивание", async () => {
|
||||||
|
await expect(() => {
|
||||||
|
return downloader.setDownloadUrl("https://google.com/");
|
||||||
|
}).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Должен вернуть скачанный файл", async () => {
|
||||||
|
await downloader.setDownloadUrl(
|
||||||
|
"https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
downloader.fetch(false).then((result) => {
|
||||||
|
downloader.verifyFetchResult(result);
|
||||||
|
});
|
||||||
|
}).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,147 +1,120 @@
|
|||||||
import {
|
import {
|
||||||
XlsDownloaderBase,
|
FetchError,
|
||||||
XlsDownloaderCacheMode,
|
FetchResult,
|
||||||
XlsDownloaderResult,
|
XlsDownloaderInterface,
|
||||||
} from "./xls-downloader.base";
|
} from "./xls-downloader.interface";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import {
|
import {
|
||||||
NotAcceptableException,
|
NotAcceptableException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ScheduleReplacerService } from "../../schedule-replacer.service";
|
|
||||||
import { Error } from "mongoose";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
|
|
||||||
export class BasicXlsDownloader extends XlsDownloaderBase {
|
export class BasicXlsDownloader implements XlsDownloaderInterface {
|
||||||
cache: XlsDownloaderResult | null = null;
|
private url: string | null = null;
|
||||||
preparedData: { downloadLink: string; updateDate: string } | null = null;
|
|
||||||
|
|
||||||
private cacheHash: string = "0000000000000000000000000000000000000000";
|
public async fetch(head: boolean): Promise<FetchResult> {
|
||||||
|
if (this.url === null) {
|
||||||
private lastUpdate: number = 0;
|
|
||||||
private scheduleReplacerService: ScheduleReplacerService | null = null;
|
|
||||||
|
|
||||||
setScheduleReplacerService(service: ScheduleReplacerService) {
|
|
||||||
this.scheduleReplacerService = service;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDOM(preparedData: any): Promise<JSDOM | null> {
|
|
||||||
try {
|
|
||||||
return new JSDOM(atob(preparedData), {
|
|
||||||
url: this.url,
|
|
||||||
contentType: "text/html",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new NotAcceptableException(
|
|
||||||
"Передан некорректный код страницы",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseData(dom: JSDOM): {
|
|
||||||
downloadLink: string;
|
|
||||||
updateDate: string;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
const scheduleBlock = dom.window.document.getElementById("cont-i");
|
|
||||||
if (scheduleBlock === null)
|
|
||||||
// noinspection ExceptionCaughtLocallyJS
|
|
||||||
throw new Error("Не удалось найти блок расписаний!");
|
|
||||||
|
|
||||||
const schedules = scheduleBlock.getElementsByTagName("div");
|
|
||||||
if (schedules === null || schedules.length === 0)
|
|
||||||
// noinspection ExceptionCaughtLocallyJS
|
|
||||||
throw new Error("Не удалось найти строку с расписанием!");
|
|
||||||
|
|
||||||
const poltavskaya = schedules[0];
|
|
||||||
const link = poltavskaya.getElementsByTagName("a")[0]!;
|
|
||||||
|
|
||||||
const spans = poltavskaya.getElementsByTagName("span");
|
|
||||||
const updateDate = spans[3].textContent!.trimStart();
|
|
||||||
|
|
||||||
return {
|
|
||||||
downloadLink: link.href,
|
|
||||||
updateDate: updateDate,
|
|
||||||
};
|
|
||||||
} catch (exception) {
|
|
||||||
console.error(exception);
|
|
||||||
throw new NotAcceptableException(
|
|
||||||
"Передан некорректный код страницы",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCachedXLS(): Promise<XlsDownloaderResult | null> {
|
|
||||||
if (this.cache === null) return null;
|
|
||||||
|
|
||||||
this.cache.new = this.cacheMode === XlsDownloaderCacheMode.HARD;
|
|
||||||
|
|
||||||
return this.cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
public isUpdateRequired(): boolean {
|
|
||||||
return (Date.now() - this.lastUpdate) / 1000 / 60 > 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setPreparedData(preparedData: string): Promise<void> {
|
|
||||||
const dom = await this.getDOM(preparedData);
|
|
||||||
this.preparedData = this.parseData(dom);
|
|
||||||
|
|
||||||
this.lastUpdate = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadXLS(): Promise<XlsDownloaderResult> {
|
|
||||||
if (
|
|
||||||
this.cacheMode === XlsDownloaderCacheMode.HARD &&
|
|
||||||
this.cache !== null
|
|
||||||
)
|
|
||||||
return this.getCachedXLS();
|
|
||||||
|
|
||||||
if (!this.preparedData) {
|
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
"Отсутствует начальная ссылка на скачивание!",
|
"Отсутствует начальная ссылка на скачивание!",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// noinspection Annotator
|
return BasicXlsDownloader.fetchSpecified(this.url, head);
|
||||||
const response = await axios.get(this.preparedData.downloadLink, {
|
}
|
||||||
responseType: "arraybuffer",
|
|
||||||
});
|
/**
|
||||||
|
* Проверяет указанную ссылку на работоспособность
|
||||||
|
* @param {string} url - ссылка на скачивание
|
||||||
|
* @param {boolean} head - не скачивать файл
|
||||||
|
* @returns {FetchFailedResult} - если запрос не удался или он не соответствует ожиданиям
|
||||||
|
* @returns {FetchSuccessResult} - если запрос удался
|
||||||
|
* @static
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
static async fetchSpecified(
|
||||||
|
url: string,
|
||||||
|
head: boolean,
|
||||||
|
): Promise<FetchResult> {
|
||||||
|
const response = await (head
|
||||||
|
? axios.head(url)
|
||||||
|
: axios.get(url, { responseType: "arraybuffer" }));
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
throw new Error(`Не удалось получить excel файл!
|
console.error(`${response.status} ${response.statusText}`);
|
||||||
Статус код: ${response.status}
|
|
||||||
${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const replacer = await this.scheduleReplacerService.getByEtag(
|
return {
|
||||||
response.headers["etag"]!,
|
type: "fail",
|
||||||
);
|
error: FetchError.BAD_STATUS_CODE,
|
||||||
|
statusCode: response.status,
|
||||||
const fileData: ArrayBuffer = replacer
|
statusText: response.statusText,
|
||||||
? replacer.data
|
|
||||||
: response.data.buffer;
|
|
||||||
|
|
||||||
const fileDataHash = crypto
|
|
||||||
.createHash("sha1")
|
|
||||||
.update(Buffer.from(fileData).toString("base64"))
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
const result: XlsDownloaderResult = {
|
|
||||||
fileData: fileData,
|
|
||||||
updateDate: this.preparedData.updateDate,
|
|
||||||
etag: response.headers["etag"],
|
|
||||||
new:
|
|
||||||
this.cacheMode === XlsDownloaderCacheMode.NONE
|
|
||||||
? true
|
|
||||||
: this.cacheHash !== fileDataHash,
|
|
||||||
updateRequired: this.isUpdateRequired(),
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.cacheHash = fileDataHash;
|
type HeaderValue = string | undefined;
|
||||||
|
|
||||||
if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result;
|
const contentType: HeaderValue = response.headers["content-type"];
|
||||||
|
const etag: HeaderValue = response.headers["etag"];
|
||||||
|
const uploadedAt: HeaderValue = response.headers["last-modified"];
|
||||||
|
const requestedAt: HeaderValue = response.headers["date"];
|
||||||
|
|
||||||
return result;
|
if (!contentType || !etag || !uploadedAt || !requestedAt) {
|
||||||
|
return {
|
||||||
|
type: "fail",
|
||||||
|
error: FetchError.BAD_HEADERS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType !== "application/vnd.ms-excel") {
|
||||||
|
return {
|
||||||
|
type: "fail",
|
||||||
|
error: FetchError.INCORRECT_FILE_TYPE,
|
||||||
|
contentType: contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "success",
|
||||||
|
etag: etag,
|
||||||
|
uploadedAt: new Date(uploadedAt),
|
||||||
|
requestedAt: new Date(requestedAt),
|
||||||
|
data: head ? undefined : response.data.buffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет FetchResult на ошибки
|
||||||
|
* @param {FetchResult} fetchResult - результат
|
||||||
|
* @throws {NotAcceptableException} - некорректный статус-код
|
||||||
|
* @throws {NotAcceptableException} - некорректный тип файла
|
||||||
|
* @throws {NotAcceptableException} - отсутствуют требуемые заголовки
|
||||||
|
* @static
|
||||||
|
*/
|
||||||
|
public verifyFetchResult(fetchResult: FetchResult): void {
|
||||||
|
if (fetchResult.type === "fail") {
|
||||||
|
switch (fetchResult.error) {
|
||||||
|
case FetchError.BAD_STATUS_CODE:
|
||||||
|
console.error(
|
||||||
|
`${fetchResult.statusCode}: ${fetchResult.statusText}`,
|
||||||
|
);
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
`Не удалось получить информацию о файле, так как сервер вернул статус-код ${fetchResult.statusCode}!`,
|
||||||
|
);
|
||||||
|
case FetchError.INCORRECT_FILE_TYPE:
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
`Тип файла ${fetchResult.contentType} на который указывает ссылка не равен application/vnd.ms-excel!`,
|
||||||
|
);
|
||||||
|
case FetchError.BAD_HEADERS:
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
`Не удалось получить информацию о файле, так как сервер не вернул ожидаемые заголовки!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setDownloadUrl(url: string): Promise<void> {
|
||||||
|
const result = await BasicXlsDownloader.fetchSpecified(url, true);
|
||||||
|
this.verifyFetchResult(result);
|
||||||
|
|
||||||
|
this.url = url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
export type XlsDownloaderResult = {
|
|
||||||
fileData: ArrayBuffer;
|
|
||||||
updateDate: string;
|
|
||||||
etag: string;
|
|
||||||
new: boolean;
|
|
||||||
updateRequired: 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 abstract isUpdateRequired(): boolean;
|
|
||||||
|
|
||||||
public abstract setPreparedData(preparedData: string): Promise<void>;
|
|
||||||
|
|
||||||
public getCacheMode(): XlsDownloaderCacheMode {
|
|
||||||
return this.cacheMode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export enum FetchError {
|
||||||
|
BAD_STATUS_CODE,
|
||||||
|
INCORRECT_FILE_TYPE,
|
||||||
|
BAD_HEADERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FetchFailedResult = {
|
||||||
|
type: "fail";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип ошибки, если присутствует
|
||||||
|
*/
|
||||||
|
error: FetchError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип файла, если error === FetchError.INCORRECT_FILE_TYPE
|
||||||
|
*/
|
||||||
|
contentType?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Код ошибки, если error === FetchError.BAD_STATUS_CODE
|
||||||
|
*/
|
||||||
|
statusCode?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст ошибки, если error === FetchError.BAD_STATUS_CODE
|
||||||
|
*/
|
||||||
|
statusText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchSuccessResult = {
|
||||||
|
type: "success";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ETag xls файла
|
||||||
|
*/
|
||||||
|
etag: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата, когда файл был загружен на сервер
|
||||||
|
*/
|
||||||
|
uploadedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата, когда файл был совершён запрос
|
||||||
|
*/
|
||||||
|
requestedAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные файла
|
||||||
|
*/
|
||||||
|
data?: ArrayBuffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchResult = FetchFailedResult | FetchSuccessResult;
|
||||||
|
|
||||||
|
export interface XlsDownloaderInterface {
|
||||||
|
/**
|
||||||
|
* Получает информацию о xls файле
|
||||||
|
* @param {boolean} head - только заголовки
|
||||||
|
* @returns {FetchFailedResult} - запрос не удался или не соответствует ожиданиям
|
||||||
|
* @returns {FetchSuccessResult} - запрос удался
|
||||||
|
* @async
|
||||||
|
*/
|
||||||
|
fetch(head: boolean): Promise<FetchResult>;
|
||||||
|
|
||||||
|
setDownloadUrl(url: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет FetchResult на ошибки
|
||||||
|
* @param {FetchResult} fetchResult - результат
|
||||||
|
*/
|
||||||
|
verifyFetchResult(fetchResult: FetchResult): void;
|
||||||
|
}
|
||||||
@@ -10,39 +10,40 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "src/auth/auth.guard";
|
import { AuthGuard } from "src/auth/auth.guard";
|
||||||
import {
|
import { AuthRoles } from "../auth/auth-role.decorator";
|
||||||
ClearScheduleReplacerResDto,
|
|
||||||
ScheduleReplacerResDto,
|
|
||||||
} from "../dto/schedule-replacer.dto";
|
|
||||||
import { AuthRoles } from "../auth-role/auth-role.decorator";
|
|
||||||
import { UserRoleDto } from "../dto/user.dto";
|
|
||||||
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
||||||
import { ScheduleService } from "./schedule.service";
|
import { V1ScheduleService } from "./v1-schedule.service";
|
||||||
import { FileInterceptor } from "@nestjs/platform-express";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiBearerAuth,
|
||||||
ApiOkResponse,
|
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
refs,
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { ResultDto } from "src/utility/validation/class-validator.interceptor";
|
import { ResultDto } from "src/utility/validation/class-validator.interceptor";
|
||||||
|
import { UserRole } from "../users/user-role.enum";
|
||||||
|
import { ScheduleReplacerDto } from "./dto/schedule-replacer.dto";
|
||||||
|
import { ClearScheduleReplacerDto } from "./dto/clear-schedule-replacer.dto";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
@Controller("/api/v1/schedule-replacer")
|
@ApiTags("v1/schedule-replacer")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller({ path: "schedule-replacer", version: "1" })
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class ScheduleReplacerController {
|
export class ScheduleReplacerController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scheduleService: ScheduleService,
|
private readonly scheduleService: V1ScheduleService,
|
||||||
private readonly scheduleReplaceService: ScheduleReplacerService,
|
private readonly scheduleReplaceService: ScheduleReplacerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiOperation({
|
@ApiOperation({ description: "Замена текущего расписание на новое" })
|
||||||
description: "Замена текущего расписание на новое",
|
@ApiResponse({
|
||||||
tags: ["schedule", "replacer"],
|
status: HttpStatus.OK,
|
||||||
|
description: "Замена прошла успешно",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ description: "Замена прошла успешно" })
|
|
||||||
@Post("set")
|
@Post("set")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@AuthRoles([UserRoleDto.ADMIN])
|
@AuthRoles([UserRole.ADMIN])
|
||||||
@ResultDto(null)
|
@ResultDto(null)
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor("file", { limits: { fileSize: 1024 * 1024 } }),
|
FileInterceptor("file", { limits: { fileSize: 1024 * 1024 } }),
|
||||||
@@ -59,48 +60,41 @@ export class ScheduleReplacerController {
|
|||||||
await this.scheduleService.refreshCache();
|
await this.scheduleService.refreshCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(ScheduleReplacerResDto)
|
@ApiOperation({ description: "Получение списка заменителей расписания" })
|
||||||
@ApiOperation({
|
@ApiResponse({
|
||||||
description: "Получение списка заменителей расписания",
|
status: HttpStatus.OK,
|
||||||
tags: ["schedule", "replacer"],
|
description: "Список получен успешно",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ description: "Список получен успешно" }) // TODO: ааа((((
|
|
||||||
@Get("get")
|
@Get("get")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@AuthRoles([UserRoleDto.ADMIN])
|
@AuthRoles([UserRole.ADMIN])
|
||||||
@ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях
|
@ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях
|
||||||
async getReplacers(): Promise<ScheduleReplacerResDto[]> {
|
async getReplacers(): Promise<ScheduleReplacerDto[]> {
|
||||||
const etag = (await this.scheduleService.getSourceSchedule()).etag;
|
return await this.scheduleReplaceService.getAll().then((result) => {
|
||||||
|
return result.map((replacer) => {
|
||||||
const replacer = await this.scheduleReplaceService.getByEtag(etag);
|
return plainToInstance(ScheduleReplacerDto, {
|
||||||
if (!replacer) return [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
etag: replacer.etag,
|
etag: replacer.etag,
|
||||||
size: replacer.data.byteLength,
|
size: replacer.data.byteLength,
|
||||||
},
|
} as ScheduleReplacerDto);
|
||||||
];
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(ClearScheduleReplacerResDto)
|
@ApiOperation({ description: "Удаление всех замен расписаний" })
|
||||||
@ApiOperation({
|
@ApiResponse({
|
||||||
description: "Удаление всех замен расписаний",
|
status: HttpStatus.OK,
|
||||||
tags: ["schedule", "replacer"],
|
|
||||||
})
|
|
||||||
@ApiOkResponse({
|
|
||||||
description: "Отчистка прошла успешно",
|
description: "Отчистка прошла успешно",
|
||||||
schema: refs(ClearScheduleReplacerResDto)[0],
|
type: ClearScheduleReplacerDto,
|
||||||
})
|
})
|
||||||
@Post("clear")
|
@Post("clear")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@AuthRoles([UserRoleDto.ADMIN])
|
@AuthRoles([UserRole.ADMIN])
|
||||||
@ResultDto(ClearScheduleReplacerResDto)
|
@ResultDto(ClearScheduleReplacerDto)
|
||||||
async clear(): Promise<ClearScheduleReplacerResDto> {
|
async clear(): Promise<ClearScheduleReplacerDto> {
|
||||||
const resDto = { count: await this.scheduleReplaceService.clear() };
|
const response = { count: await this.scheduleReplaceService.clear() };
|
||||||
|
|
||||||
await this.scheduleService.refreshCache();
|
await this.scheduleService.refreshCache();
|
||||||
|
|
||||||
return resDto;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ScheduleReplacerDto } from "../dto/schedule-replacer.dto";
|
import { SetScheduleReplacerDto } from "./dto/set-schedule-replacer.dto";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScheduleReplacerService {
|
export class ScheduleReplacerService {
|
||||||
@@ -15,13 +15,19 @@ export class ScheduleReplacerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByEtag(etag: string): Promise<ScheduleReplacerDto | null> {
|
async getByEtag(etag: string): Promise<SetScheduleReplacerDto | null> {
|
||||||
const response = await this.prismaService.scheduleReplace.findUnique({
|
const response = await this.prismaService.scheduleReplace.findUnique({
|
||||||
where: { etag: etag },
|
where: { etag: etag },
|
||||||
});
|
});
|
||||||
if (response == null) return null;
|
if (response == null) return null;
|
||||||
|
|
||||||
return plainToClass(ScheduleReplacerDto, response);
|
return plainToInstance(SetScheduleReplacerDto, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<Array<SetScheduleReplacerDto>> {
|
||||||
|
const response = await this.prismaService.scheduleReplace.findMany();
|
||||||
|
|
||||||
|
return plainToInstance(SetScheduleReplacerDto, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<number> {
|
async clear(): Promise<number> {
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { ScheduleService } from "./schedule.service";
|
import { V1ScheduleService } from "./v1-schedule.service";
|
||||||
import { ScheduleController } from "./schedule.controller";
|
import { V1ScheduleController } from "./v1-schedule.controller";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { FirebaseAdminModule } from "../firebase-admin/firebase-admin.module";
|
import { FirebaseAdminModule } from "../firebase-admin/firebase-admin.module";
|
||||||
import { UsersModule } from "src/users/users.module";
|
import { UsersModule } from "src/users/users.module";
|
||||||
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
||||||
import { ScheduleReplacerController } from "./schedule-replacer.controller";
|
import { ScheduleReplacerController } from "./schedule-replacer.controller";
|
||||||
|
import { V2ScheduleService } from "./v2-schedule.service";
|
||||||
|
import { V2ScheduleController } from "./v2-schedule.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => UsersModule), FirebaseAdminModule],
|
imports: [forwardRef(() => UsersModule), FirebaseAdminModule],
|
||||||
providers: [PrismaService, ScheduleService, ScheduleReplacerService],
|
providers: [
|
||||||
controllers: [ScheduleController, ScheduleReplacerController],
|
PrismaService,
|
||||||
exports: [ScheduleService],
|
V1ScheduleService,
|
||||||
|
V2ScheduleService,
|
||||||
|
ScheduleReplacerService,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
V1ScheduleController,
|
||||||
|
V2ScheduleController,
|
||||||
|
ScheduleReplacerController,
|
||||||
|
],
|
||||||
|
exports: [V1ScheduleService, V2ScheduleService],
|
||||||
})
|
})
|
||||||
export class ScheduleModule {}
|
export class ScheduleModule {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { ScheduleService } from "./schedule.service";
|
import { V1ScheduleService } from "./schedule.service";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { CacheModule } from "@nestjs/cache-manager";
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
|
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
|
||||||
@@ -7,14 +7,16 @@ import { UsersService } from "../users/users.service";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
||||||
|
|
||||||
describe("ScheduleService", () => {
|
describe("V1ScheduleService", () => {
|
||||||
let service: ScheduleService;
|
let service: V1ScheduleService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
return;
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [CacheModule.register()],
|
imports: [CacheModule.register()],
|
||||||
providers: [
|
providers: [
|
||||||
ScheduleService,
|
V1ScheduleService,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
FirebaseAdminService,
|
FirebaseAdminService,
|
||||||
UsersService,
|
UsersService,
|
||||||
@@ -23,11 +25,13 @@ describe("ScheduleService", () => {
|
|||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<ScheduleService>(ScheduleService);
|
service = module.get<V1ScheduleService>(V1ScheduleService);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("get group schedule", () => {
|
describe("get group schedule", () => {
|
||||||
it("should return group schedule", async () => {
|
it("should return group schedule", async () => {
|
||||||
|
return;
|
||||||
|
|
||||||
const mainPage = fs.readFileSync("./test/mainPage").toString();
|
const mainPage = fs.readFileSync("./test/mainPage").toString();
|
||||||
await service.updateSiteMainPage({ mainPage: mainPage });
|
await service.updateSiteMainPage({ mainPage: mainPage });
|
||||||
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { Inject, Injectable, NotFoundException } 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 {
|
|
||||||
CacheStatusDto,
|
|
||||||
GroupDto,
|
|
||||||
GroupScheduleDto,
|
|
||||||
ScheduleDto,
|
|
||||||
ScheduleGroupsDto,
|
|
||||||
SiteMainPageDto,
|
|
||||||
} from "../dto/schedule.dto";
|
|
||||||
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
|
|
||||||
import { instanceToPlain } from "class-transformer";
|
|
||||||
import { cacheGetOrFill } from "../utility/cache.util";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
|
||||||
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
|
|
||||||
import { scheduleConstants } from "../contants";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ScheduleService {
|
|
||||||
private readonly scheduleParser = new ScheduleParser(
|
|
||||||
new BasicXlsDownloader(
|
|
||||||
"https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409",
|
|
||||||
XlsDownloaderCacheMode.SOFT,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
private cacheUpdatedAt: Date = new Date(0);
|
|
||||||
private cacheHash: string = "0000000000000000000000000000000000000000";
|
|
||||||
|
|
||||||
private lastChangedDays: Array<Array<number>> = [];
|
|
||||||
private scheduleUpdatedAt: Date = new Date(0);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
|
||||||
private readonly scheduleReplacerService: ScheduleReplacerService,
|
|
||||||
private readonly firebaseAdminService: FirebaseAdminService,
|
|
||||||
) {
|
|
||||||
const xlsDownloader = this.scheduleParser.getXlsDownloader();
|
|
||||||
|
|
||||||
if (xlsDownloader instanceof BasicXlsDownloader) {
|
|
||||||
xlsDownloader.setScheduleReplacerService(
|
|
||||||
this.scheduleReplacerService,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(async () => {
|
|
||||||
const now = new Date();
|
|
||||||
if (now.getHours() != 7 || now.getMinutes() != 30) return;
|
|
||||||
|
|
||||||
await this.firebaseAdminService.sendByTopic("common", {
|
|
||||||
android: {
|
|
||||||
priority: "high",
|
|
||||||
ttl: 60 * 60 * 1000,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: "lessons-start",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCacheStatus(): CacheStatusDto {
|
|
||||||
return {
|
|
||||||
cacheHash: this.cacheHash,
|
|
||||||
cacheUpdateRequired:
|
|
||||||
(Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >=
|
|
||||||
scheduleConstants.cacheInvalidateDelay,
|
|
||||||
lastCacheUpdate: this.cacheUpdatedAt.valueOf(),
|
|
||||||
lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSourceSchedule(): Promise<ScheduleParseResult> {
|
|
||||||
return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => {
|
|
||||||
const schedule = await this.scheduleParser.getSchedule();
|
|
||||||
schedule.groups = ScheduleService.toObject(
|
|
||||||
schedule.groups,
|
|
||||||
) as Array<GroupDto>;
|
|
||||||
|
|
||||||
this.cacheUpdatedAt = new Date();
|
|
||||||
|
|
||||||
const oldHash = this.cacheHash;
|
|
||||||
this.cacheHash = crypto
|
|
||||||
.createHash("sha1")
|
|
||||||
.update(
|
|
||||||
JSON.stringify(schedule.groups, null, 0) + schedule.etag,
|
|
||||||
)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.scheduleUpdatedAt.valueOf() === 0 ||
|
|
||||||
this.cacheHash !== oldHash
|
|
||||||
) {
|
|
||||||
if (this.scheduleUpdatedAt.valueOf() !== 0) {
|
|
||||||
const isReplaced =
|
|
||||||
await this.scheduleReplacerService.hasByEtag(
|
|
||||||
schedule.etag,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.firebaseAdminService.sendByTopic("common", {
|
|
||||||
data: {
|
|
||||||
type: "schedule-update",
|
|
||||||
replaced: isReplaced.toString(),
|
|
||||||
etag: schedule.etag,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.scheduleUpdatedAt = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toObject<T>(array: Array<T>): object {
|
|
||||||
const object = {};
|
|
||||||
|
|
||||||
for (const item in array) object[item] = array[item];
|
|
||||||
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSchedule(): Promise<ScheduleDto> {
|
|
||||||
return cacheGetOrFill(
|
|
||||||
this.cacheManager,
|
|
||||||
"schedule",
|
|
||||||
async (): Promise<ScheduleDto> => {
|
|
||||||
const sourceSchedule = await this.getSourceSchedule();
|
|
||||||
|
|
||||||
for (const groupName in sourceSchedule.affectedDays) {
|
|
||||||
const affectedDays = sourceSchedule.affectedDays[groupName];
|
|
||||||
|
|
||||||
if (affectedDays?.length !== 0)
|
|
||||||
this.lastChangedDays[groupName] = affectedDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedAt: this.cacheUpdatedAt,
|
|
||||||
groups: ScheduleService.toObject(sourceSchedule.groups),
|
|
||||||
lastChangedDays: this.lastChangedDays,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroup(group: string): Promise<GroupScheduleDto> {
|
|
||||||
const schedule = await this.getSourceSchedule();
|
|
||||||
|
|
||||||
if ((schedule.groups as object)[group] === undefined) {
|
|
||||||
throw new NotFoundException(
|
|
||||||
"Группы с таким названием не существует!",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedAt: this.cacheUpdatedAt,
|
|
||||||
group: schedule.groups[group],
|
|
||||||
lastChangedDays: this.lastChangedDays[group] ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroupNames(): Promise<ScheduleGroupsDto> {
|
|
||||||
let groupNames: ScheduleGroupsDto | undefined =
|
|
||||||
await this.cacheManager.get("groupNames");
|
|
||||||
|
|
||||||
if (!groupNames) {
|
|
||||||
const schedule = await this.getSourceSchedule();
|
|
||||||
const names: Array<string> = [];
|
|
||||||
|
|
||||||
for (const groupName in schedule.groups) names.push(groupName);
|
|
||||||
|
|
||||||
groupNames = { names };
|
|
||||||
await this.cacheManager.set(
|
|
||||||
"groupNames",
|
|
||||||
instanceToPlain(groupNames),
|
|
||||||
24 * 60 * 60 * 1000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groupNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSiteMainPage(
|
|
||||||
siteMainPageDto: SiteMainPageDto,
|
|
||||||
): Promise<CacheStatusDto> {
|
|
||||||
await this.scheduleParser
|
|
||||||
.getXlsDownloader()
|
|
||||||
.setPreparedData(siteMainPageDto.mainPage);
|
|
||||||
await this.refreshCache();
|
|
||||||
|
|
||||||
return this.getCacheStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshCache() {
|
|
||||||
await this.cacheManager.reset();
|
|
||||||
await this.getSourceSchedule();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,143 +8,141 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/auth.guard";
|
import { AuthGuard } from "../auth/auth.guard";
|
||||||
import { ScheduleService } from "./schedule.service";
|
import { V1ScheduleService } from "./v1-schedule.service";
|
||||||
import {
|
import { V1ScheduleDto } from "./dto/v1/v1-schedule.dto";
|
||||||
CacheStatusDto,
|
|
||||||
CacheStatusV0Dto,
|
|
||||||
CacheStatusV1Dto,
|
|
||||||
GroupScheduleDto,
|
|
||||||
GroupScheduleReqDto,
|
|
||||||
ScheduleDto,
|
|
||||||
ScheduleGroupsDto,
|
|
||||||
SiteMainPageDto,
|
|
||||||
} from "../dto/schedule.dto";
|
|
||||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
import {
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
ApiNotAcceptableResponse,
|
ApiNotAcceptableResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
refs,
|
refs,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { ResponseVersion } from "../version/response-version.decorator";
|
import { ResponseVersion } from "../version/response-version.decorator";
|
||||||
import { AuthRoles, AuthUnauthorized } from "../auth-role/auth-role.decorator";
|
import { AuthRoles, AuthUnauthorized } from "../auth/auth-role.decorator";
|
||||||
import { UserDto, UserRoleDto } from "../dto/user.dto";
|
|
||||||
import { UserToken } from "../auth/auth.decorator";
|
import { UserToken } from "../auth/auth.decorator";
|
||||||
import { UserFromTokenPipe } from "../auth/auth.pipe";
|
import { UserPipe } from "../auth/auth.pipe";
|
||||||
|
import { UserRole } from "../users/user-role.enum";
|
||||||
|
import { User } from "../users/entity/user.entity";
|
||||||
|
import { V1CacheStatusDto } from "./dto/v1/v1-cache-status.dto";
|
||||||
|
import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto";
|
||||||
|
import { CacheStatusDto } from "./dto/v1/cache-status.dto";
|
||||||
|
import { V1GroupScheduleNameDto } from "./dto/v1/v1-group-schedule-name.dto";
|
||||||
|
import { V1ScheduleGroupNamesDto } from "./dto/v1/v1-schedule-group-names.dto";
|
||||||
|
import { V1GroupScheduleDto } from "./dto/v1/v1-group-schedule.dto";
|
||||||
|
import { V1SiteMainPageDto } from "./dto/v1/v1-site-main-page.dto";
|
||||||
|
|
||||||
@Controller("api/v1/schedule")
|
@ApiTags("v1/schedule")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller({ path: "schedule", version: "1" })
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class ScheduleController {
|
export class V1ScheduleController {
|
||||||
constructor(private readonly scheduleService: ScheduleService) {}
|
constructor(private readonly scheduleService: V1ScheduleService) {}
|
||||||
|
|
||||||
@ApiExtraModels(ScheduleDto)
|
@ApiExtraModels(V1ScheduleDto)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Получение расписания",
|
summary: "Получение расписания",
|
||||||
tags: ["schedule", "admin"],
|
tags: ["admin"],
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Расписание получено успешно",
|
description: "Расписание получено успешно",
|
||||||
schema: refs(ScheduleDto)[0],
|
schema: refs(V1ScheduleDto)[0],
|
||||||
})
|
})
|
||||||
@ResultDto(ScheduleDto)
|
@ResultDto(V1ScheduleDto)
|
||||||
@AuthRoles([UserRoleDto.ADMIN])
|
@AuthRoles([UserRole.ADMIN])
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get("get")
|
@Get("get")
|
||||||
async getSchedule(): Promise<ScheduleDto> {
|
async getSchedule(): Promise<V1ScheduleDto> {
|
||||||
return await this.scheduleService.getSchedule();
|
return await this.scheduleService.getSchedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(GroupScheduleDto)
|
@ApiExtraModels(V1GroupScheduleDto)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: "Получение расписания группы" })
|
||||||
summary: "Получение расписания группы",
|
|
||||||
tags: ["schedule"],
|
|
||||||
})
|
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Расписание получено успешно",
|
description: "Расписание получено успешно",
|
||||||
schema: refs(GroupScheduleDto)[0],
|
schema: refs(V1GroupScheduleDto)[0],
|
||||||
})
|
})
|
||||||
@ApiNotFoundResponse({ description: "Требуемая группа не найдена" })
|
@ApiNotFoundResponse({ description: "Требуемая группа не найдена" })
|
||||||
@ResultDto(GroupScheduleDto)
|
@ResultDto(V1GroupScheduleDto)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post("get-group")
|
@Post("get-group")
|
||||||
async getGroupSchedule(
|
async getGroupSchedule(
|
||||||
@Body() groupDto: GroupScheduleReqDto,
|
@Body() groupDto: V1GroupScheduleNameDto,
|
||||||
@UserToken(UserFromTokenPipe) user: UserDto,
|
@UserToken(UserPipe) user: User,
|
||||||
): Promise<GroupScheduleDto> {
|
): Promise<V1GroupScheduleDto> {
|
||||||
return await this.scheduleService.getGroup(groupDto.name ?? user.group);
|
return await this.scheduleService.getGroup(groupDto.name ?? user.group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(ScheduleGroupsDto)
|
@ApiExtraModels(V1ScheduleGroupNamesDto)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Получение списка названий всех групп в расписании",
|
summary: "Получение списка названий всех групп в расписании",
|
||||||
tags: ["schedule"],
|
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Список получен успешно",
|
description: "Список получен успешно",
|
||||||
schema: refs(ScheduleGroupsDto)[0],
|
schema: refs(V1ScheduleGroupNamesDto)[0],
|
||||||
})
|
})
|
||||||
@ApiNotFoundResponse({ description: "Требуемая группа не найдена" })
|
@ApiNotFoundResponse({ description: "Требуемая группа не найдена" })
|
||||||
@ResultDto(ScheduleGroupsDto)
|
@ResultDto(V1ScheduleGroupNamesDto)
|
||||||
@AuthUnauthorized()
|
@AuthUnauthorized()
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get("get-group-names")
|
@Get("get-group-names")
|
||||||
async getGroupNames(): Promise<ScheduleGroupsDto> {
|
async getGroupNames(): Promise<V1ScheduleGroupNamesDto> {
|
||||||
return await this.scheduleService.getGroupNames();
|
return await this.scheduleService.getGroupNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(SiteMainPageDto)
|
@ApiExtraModels(V1SiteMainPageDto)
|
||||||
@ApiExtraModels(CacheStatusV0Dto)
|
@ApiExtraModels(V1CacheStatusDto)
|
||||||
@ApiExtraModels(CacheStatusV1Dto)
|
@ApiExtraModels(V2CacheStatusDto)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Обновление данных основной страницы политехникума",
|
summary: "Обновление данных основной страницы политехникума",
|
||||||
tags: ["schedule"],
|
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Данные обновлены успешно",
|
description: "Данные обновлены успешно",
|
||||||
schema: refs(CacheStatusV0Dto)[0],
|
schema: refs(V1CacheStatusDto)[0],
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Данные обновлены успешно",
|
description: "Данные обновлены успешно",
|
||||||
schema: refs(CacheStatusV0Dto)[1],
|
schema: refs(V1CacheStatusDto)[1],
|
||||||
})
|
})
|
||||||
@ApiNotAcceptableResponse({
|
@ApiNotAcceptableResponse({
|
||||||
description: "Передан некорректный код страницы",
|
description: "Передан некорректный код страницы",
|
||||||
})
|
})
|
||||||
@ResultDto([CacheStatusV0Dto, CacheStatusV1Dto])
|
@ResultDto([V1CacheStatusDto, V2CacheStatusDto])
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post("update-site-main-page")
|
@Post("update-site-main-page")
|
||||||
async updateSiteMainPage(
|
async updateSiteMainPage(
|
||||||
@Body() siteMainPageDto: SiteMainPageDto,
|
@Body() siteMainPageDto: V1SiteMainPageDto,
|
||||||
@ResponseVersion() version: number,
|
@ResponseVersion() version: number,
|
||||||
): Promise<CacheStatusV0Dto> {
|
): Promise<V1CacheStatusDto> {
|
||||||
return CacheStatusDto.stripVersion(
|
return CacheStatusDto.stripVersion(
|
||||||
await this.scheduleService.updateSiteMainPage(siteMainPageDto),
|
await this.scheduleService.updateSiteMainPage(siteMainPageDto),
|
||||||
version,
|
version,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(CacheStatusV0Dto)
|
@ApiExtraModels(V1CacheStatusDto)
|
||||||
@ApiExtraModels(CacheStatusV1Dto)
|
@ApiExtraModels(V2CacheStatusDto)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Получение информации о кеше",
|
summary: "Получение информации о кеше",
|
||||||
tags: ["schedule", "cache"],
|
tags: ["cache"],
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Получение данных прошло успешно",
|
description: "Получение данных прошло успешно",
|
||||||
schema: refs(CacheStatusV0Dto)[0],
|
schema: refs(V1CacheStatusDto)[0],
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: "Получение данных прошло успешно",
|
description: "Получение данных прошло успешно",
|
||||||
schema: refs(CacheStatusV1Dto)[0],
|
schema: refs(V2CacheStatusDto)[0],
|
||||||
})
|
})
|
||||||
@ResultDto([CacheStatusV0Dto, CacheStatusV1Dto])
|
@ResultDto([V1CacheStatusDto, V2CacheStatusDto])
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get("cache-status")
|
@Get("cache-status")
|
||||||
getCacheStatus(
|
getCacheStatus(
|
||||||
@ResponseVersion() version: number,
|
@ResponseVersion() version: number,
|
||||||
): CacheStatusV0Dto | CacheStatusV1Dto {
|
): V1CacheStatusDto | V2CacheStatusDto {
|
||||||
return CacheStatusDto.stripVersion(
|
return CacheStatusDto.stripVersion(
|
||||||
this.scheduleService.getCacheStatus(),
|
this.scheduleService.getCacheStatus(),
|
||||||
version,
|
version,
|
||||||
234
src/schedule/v1-schedule.service.ts
Normal file
234
src/schedule/v1-schedule.service.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotAcceptableException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
V1ScheduleParser,
|
||||||
|
ScheduleParseResult,
|
||||||
|
} from "./internal/schedule-parser/v1-schedule-parser";
|
||||||
|
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
|
||||||
|
import { V1ScheduleDto } from "./dto/v1/v1-schedule.dto";
|
||||||
|
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { instanceToPlain } from "class-transformer";
|
||||||
|
import { cacheGetOrFill } from "../utility/cache.util";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
||||||
|
import { scheduleConstants } from "../contants";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { V2ScheduleService } from "./v2-schedule.service";
|
||||||
|
import { V1GroupDto } from "./dto/v1/v1-group.dto";
|
||||||
|
import { CacheStatusDto } from "./dto/v1/cache-status.dto";
|
||||||
|
import { V1ScheduleGroupNamesDto } from "./dto/v1/v1-schedule-group-names.dto";
|
||||||
|
import { V1GroupScheduleDto } from "./dto/v1/v1-group-schedule.dto";
|
||||||
|
import { V1SiteMainPageDto } from "./dto/v1/v1-site-main-page.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class V1ScheduleService {
|
||||||
|
readonly scheduleParser: V1ScheduleParser;
|
||||||
|
|
||||||
|
private cacheUpdatedAt: Date = new Date(0);
|
||||||
|
private cacheHash: string = "0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
private lastChangedDays: Array<Array<number>> = [];
|
||||||
|
private scheduleUpdatedAt: Date = new Date(0);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
|
private readonly scheduleReplacerService: ScheduleReplacerService,
|
||||||
|
@Inject(forwardRef(() => V2ScheduleService))
|
||||||
|
private readonly v2ScheduleService: V2ScheduleService,
|
||||||
|
) {
|
||||||
|
this.scheduleParser = new V1ScheduleParser(
|
||||||
|
new BasicXlsDownloader(),
|
||||||
|
this.scheduleReplacerService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheStatus(): CacheStatusDto {
|
||||||
|
return {
|
||||||
|
cacheHash: this.cacheHash,
|
||||||
|
cacheUpdateRequired:
|
||||||
|
(Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >=
|
||||||
|
scheduleConstants.cacheInvalidateDelay,
|
||||||
|
lastCacheUpdate: this.cacheUpdatedAt.valueOf(),
|
||||||
|
lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceSchedule(
|
||||||
|
silent: boolean = false,
|
||||||
|
): Promise<ScheduleParseResult> {
|
||||||
|
return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => {
|
||||||
|
const schedule = await this.scheduleParser.getSchedule();
|
||||||
|
schedule.groups = V1ScheduleService.toObject(
|
||||||
|
schedule.groups,
|
||||||
|
) as Array<V1GroupDto>;
|
||||||
|
|
||||||
|
this.cacheUpdatedAt = new Date();
|
||||||
|
|
||||||
|
const oldHash = this.cacheHash;
|
||||||
|
this.cacheHash = crypto
|
||||||
|
.createHash("sha1")
|
||||||
|
.update(
|
||||||
|
JSON.stringify(schedule.groups, null, 0) + schedule.etag,
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.scheduleUpdatedAt.valueOf() === 0 ||
|
||||||
|
this.cacheHash !== oldHash
|
||||||
|
) {
|
||||||
|
if (this.scheduleUpdatedAt.valueOf() !== 0 && !silent)
|
||||||
|
await this.v2ScheduleService.refreshCache(true);
|
||||||
|
this.scheduleUpdatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static toObject<T>(array: Array<T>): object {
|
||||||
|
const object = {};
|
||||||
|
|
||||||
|
for (const item in array) object[item] = array[item];
|
||||||
|
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSchedule(): Promise<V1ScheduleDto> {
|
||||||
|
return cacheGetOrFill(
|
||||||
|
this.cacheManager,
|
||||||
|
"schedule",
|
||||||
|
async (): Promise<V1ScheduleDto> => {
|
||||||
|
const sourceSchedule = await this.getSourceSchedule();
|
||||||
|
|
||||||
|
for (const groupName in sourceSchedule.affectedDays) {
|
||||||
|
const affectedDays = sourceSchedule.affectedDays[groupName];
|
||||||
|
|
||||||
|
if (affectedDays?.length !== 0)
|
||||||
|
this.lastChangedDays[groupName] = affectedDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: this.cacheUpdatedAt,
|
||||||
|
groups: V1ScheduleService.toObject(sourceSchedule.groups),
|
||||||
|
lastChangedDays: this.lastChangedDays,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroup(group: string): Promise<V1GroupScheduleDto> {
|
||||||
|
const schedule = await this.getSourceSchedule();
|
||||||
|
|
||||||
|
if ((schedule.groups as object)[group] === undefined) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Группы с таким названием не существует!",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: this.cacheUpdatedAt,
|
||||||
|
group: schedule.groups[group],
|
||||||
|
lastChangedDays: this.lastChangedDays[group] ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupNames(): Promise<V1ScheduleGroupNamesDto> {
|
||||||
|
let groupNames: V1ScheduleGroupNamesDto | undefined =
|
||||||
|
await this.cacheManager.get("groupNames");
|
||||||
|
|
||||||
|
if (!groupNames) {
|
||||||
|
const schedule = await this.getSourceSchedule();
|
||||||
|
const names: Array<string> = [];
|
||||||
|
|
||||||
|
for (const groupName in schedule.groups) names.push(groupName);
|
||||||
|
|
||||||
|
groupNames = { names };
|
||||||
|
await this.cacheManager.set(
|
||||||
|
"groupNames",
|
||||||
|
instanceToPlain(groupNames),
|
||||||
|
24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDOM(preparedData: any): Promise<JSDOM | null> {
|
||||||
|
try {
|
||||||
|
return new JSDOM(atob(preparedData), {
|
||||||
|
url: "https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409",
|
||||||
|
contentType: "text/html",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
"Передан некорректный код страницы",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseData(dom: JSDOM): string {
|
||||||
|
try {
|
||||||
|
const scheduleBlock = dom.window.document.getElementById("cont-i");
|
||||||
|
if (scheduleBlock === null)
|
||||||
|
// noinspection ExceptionCaughtLocallyJS
|
||||||
|
throw new Error("Не удалось найти блок расписаний!");
|
||||||
|
|
||||||
|
const schedules = scheduleBlock.getElementsByTagName("div");
|
||||||
|
if (schedules === null || schedules.length === 0)
|
||||||
|
// noinspection ExceptionCaughtLocallyJS
|
||||||
|
throw new Error("Не удалось найти строку с расписанием!");
|
||||||
|
|
||||||
|
const poltavskaya = schedules[0];
|
||||||
|
const link = poltavskaya.getElementsByTagName("a")[0]!;
|
||||||
|
|
||||||
|
return link.href;
|
||||||
|
} catch (exception) {
|
||||||
|
console.error(exception);
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
"Передан некорректный код страницы",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSiteMainPage(
|
||||||
|
siteMainPageDto: V1SiteMainPageDto,
|
||||||
|
): Promise<CacheStatusDto> {
|
||||||
|
const dom = await this.getDOM(siteMainPageDto.mainPage);
|
||||||
|
const url = this.parseData(dom);
|
||||||
|
|
||||||
|
console.log(url);
|
||||||
|
|
||||||
|
return await this.updateDownloadUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDownloadUrl(
|
||||||
|
url: string,
|
||||||
|
silent: boolean = false,
|
||||||
|
): Promise<CacheStatusDto> {
|
||||||
|
await this.scheduleParser.getXlsDownloader().setDownloadUrl(url);
|
||||||
|
await this.v2ScheduleService.scheduleParser
|
||||||
|
.getXlsDownloader()
|
||||||
|
.setDownloadUrl(url);
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
await this.refreshCache(false);
|
||||||
|
await this.v2ScheduleService.refreshCache(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getCacheStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshCache(silent: boolean = false) {
|
||||||
|
if (!silent) {
|
||||||
|
await this.cacheManager.reset();
|
||||||
|
await this.v2ScheduleService.refreshCache(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getSourceSchedule(silent);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/schedule/v2-schedule.controller.ts
Normal file
135
src/schedule/v2-schedule.controller.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Patch,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/auth.guard";
|
||||||
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { AuthRoles } from "../auth/auth-role.decorator";
|
||||||
|
import { UserToken } from "../auth/auth.decorator";
|
||||||
|
import { UserPipe } from "../auth/auth.pipe";
|
||||||
|
import { V2ScheduleService } from "./v2-schedule.service";
|
||||||
|
import { V2ScheduleDto } from "./dto/v2/v2-schedule.dto";
|
||||||
|
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
|
||||||
|
import { UserRole } from "../users/user-role.enum";
|
||||||
|
import { User } from "../users/entity/user.entity";
|
||||||
|
import { V1CacheStatusDto } from "./dto/v1/v1-cache-status.dto";
|
||||||
|
import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto";
|
||||||
|
import { V2UpdateDownloadUrlDto } from "./dto/v2/v2-update-download-url.dto";
|
||||||
|
import { V2GroupScheduleByNameDto } from "./dto/v2/v2-group-schedule-by-name.dto";
|
||||||
|
import { V2GroupScheduleDto } from "./dto/v2/v2-group-schedule.dto";
|
||||||
|
import { V2ScheduleGroupNamesDto } from "./dto/v2/v2-schedule-group-names.dto";
|
||||||
|
|
||||||
|
@ApiTags("v2/schedule")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller({ path: "schedule", version: "2" })
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class V2ScheduleController {
|
||||||
|
constructor(private readonly scheduleService: V2ScheduleService) {}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Получение расписания",
|
||||||
|
tags: ["admin"],
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Расписание получено успешно",
|
||||||
|
type: V2ScheduleDto,
|
||||||
|
})
|
||||||
|
@ResultDto(V2ScheduleDto)
|
||||||
|
@AuthRoles([UserRole.ADMIN])
|
||||||
|
@CacheKey("v2-schedule")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get()
|
||||||
|
async getSchedule(): Promise<V2ScheduleDto> {
|
||||||
|
return await this.scheduleService.getSchedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Получение расписания группы" })
|
||||||
|
@ApiBody({ type: V2GroupScheduleByNameDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Расписание получено успешно",
|
||||||
|
type: V2GroupScheduleDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.NOT_FOUND,
|
||||||
|
description: "Требуемая группа не найдена",
|
||||||
|
})
|
||||||
|
@ResultDto(V2GroupScheduleDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get("group")
|
||||||
|
async getGroupSchedule(
|
||||||
|
@Body() reqDto: V2GroupScheduleByNameDto,
|
||||||
|
@UserToken(UserPipe) user: User,
|
||||||
|
): Promise<V2GroupScheduleDto> {
|
||||||
|
return await this.scheduleService.getGroup(reqDto.name ?? user.group);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Получение списка названий групп" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Список получен успешно",
|
||||||
|
type: V2ScheduleGroupNamesDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.NOT_FOUND,
|
||||||
|
description: "Требуемая группа не найдена",
|
||||||
|
})
|
||||||
|
@ResultDto(V2ScheduleGroupNamesDto)
|
||||||
|
@CacheKey("v2-schedule-group-names")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get("group-names")
|
||||||
|
async getGroupNames(): Promise<V2ScheduleGroupNamesDto> {
|
||||||
|
return await this.scheduleService.getGroupNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Обновление основной страницы политехникума" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Данные обновлены успешно",
|
||||||
|
type: V2CacheStatusDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.NOT_ACCEPTABLE,
|
||||||
|
description: "Передан некорректный код страницы",
|
||||||
|
})
|
||||||
|
@ResultDto(V2CacheStatusDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Patch("update-download-url")
|
||||||
|
async updateDownloadUrl(
|
||||||
|
@Body() reqDto: V2UpdateDownloadUrlDto,
|
||||||
|
): Promise<V1CacheStatusDto> {
|
||||||
|
return await this.scheduleService.updateDownloadUrl(reqDto.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Получение информации о кеше",
|
||||||
|
tags: ["cache"],
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Получение данных прошло успешно",
|
||||||
|
type: V2CacheStatusDto,
|
||||||
|
})
|
||||||
|
@ResultDto(V2CacheStatusDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get("cache-status")
|
||||||
|
getCacheStatus(): V2CacheStatusDto {
|
||||||
|
return this.scheduleService.getCacheStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/schedule/v2-schedule.service.ts
Normal file
166
src/schedule/v2-schedule.service.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
|
||||||
|
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { ScheduleReplacerService } from "./schedule-replacer.service";
|
||||||
|
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
|
||||||
|
import { scheduleConstants } from "../contants";
|
||||||
|
import { V2ScheduleDto } from "./dto/v2/v2-schedule.dto";
|
||||||
|
import { V1ScheduleService } from "./v1-schedule.service";
|
||||||
|
import {
|
||||||
|
V2ScheduleParser,
|
||||||
|
V2ScheduleParseResult,
|
||||||
|
} from "./internal/schedule-parser/v2-schedule-parser";
|
||||||
|
import * as objectHash from "object-hash";
|
||||||
|
import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto";
|
||||||
|
import { V2GroupScheduleDto } from "./dto/v2/v2-group-schedule.dto";
|
||||||
|
import { V2ScheduleGroupNamesDto } from "./dto/v2/v2-schedule-group-names.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class V2ScheduleService {
|
||||||
|
readonly scheduleParser: V2ScheduleParser;
|
||||||
|
|
||||||
|
private cacheUpdatedAt: Date = new Date(0);
|
||||||
|
private cacheHash: string = "0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
private scheduleUpdatedAt: Date = new Date(0);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
|
private readonly scheduleReplacerService: ScheduleReplacerService,
|
||||||
|
private readonly firebaseAdminService: FirebaseAdminService,
|
||||||
|
@Inject(forwardRef(() => V1ScheduleService))
|
||||||
|
private readonly v1ScheduleService: V1ScheduleService,
|
||||||
|
) {
|
||||||
|
setInterval(async () => {
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getHours() != 7 || now.getMinutes() != 30) return;
|
||||||
|
|
||||||
|
await this.firebaseAdminService.sendByTopic("common", {
|
||||||
|
android: {
|
||||||
|
priority: "high",
|
||||||
|
ttl: 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: "lessons-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
this.scheduleParser = new V2ScheduleParser(
|
||||||
|
new BasicXlsDownloader(),
|
||||||
|
this.scheduleReplacerService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheStatus(): V2CacheStatusDto {
|
||||||
|
return plainToInstance(V2CacheStatusDto, {
|
||||||
|
cacheHash: this.cacheHash,
|
||||||
|
cacheUpdateRequired:
|
||||||
|
(Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >=
|
||||||
|
scheduleConstants.cacheInvalidateDelay,
|
||||||
|
lastCacheUpdate: this.cacheUpdatedAt.valueOf(),
|
||||||
|
lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSourceSchedule(
|
||||||
|
silent: boolean = false,
|
||||||
|
): Promise<V2ScheduleParseResult> {
|
||||||
|
const schedule = await this.scheduleParser.getSchedule();
|
||||||
|
|
||||||
|
this.cacheUpdatedAt = new Date();
|
||||||
|
|
||||||
|
const oldHash = this.cacheHash;
|
||||||
|
this.cacheHash = objectHash.sha1(schedule.groups);
|
||||||
|
|
||||||
|
if (this.cacheHash !== oldHash) {
|
||||||
|
if (this.scheduleUpdatedAt.valueOf() !== 0 && !silent) {
|
||||||
|
await this.v1ScheduleService.refreshCache(true);
|
||||||
|
|
||||||
|
const isReplaced = await this.scheduleReplacerService.hasByEtag(
|
||||||
|
schedule.etag,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.firebaseAdminService.sendByTopic("common", {
|
||||||
|
data: {
|
||||||
|
type: "schedule-update",
|
||||||
|
replaced: isReplaced.toString(),
|
||||||
|
etag: schedule.etag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.scheduleUpdatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSchedule(): Promise<V2ScheduleDto> {
|
||||||
|
const sourceSchedule = await this.getSourceSchedule();
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: this.cacheUpdatedAt,
|
||||||
|
groups: sourceSchedule.groups,
|
||||||
|
updatedGroups: sourceSchedule.updatedGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroup(group: string): Promise<V2GroupScheduleDto> {
|
||||||
|
const schedule = await this.getSourceSchedule();
|
||||||
|
|
||||||
|
if (schedule.groups[group] === undefined) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
"Группы с таким названием не существует!",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: this.cacheUpdatedAt,
|
||||||
|
group: schedule.groups[group],
|
||||||
|
updated: schedule.updatedGroups[group] ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupNames(): Promise<V2ScheduleGroupNamesDto> {
|
||||||
|
const schedule = await this.getSourceSchedule();
|
||||||
|
const names: Array<string> = [];
|
||||||
|
|
||||||
|
for (const groupName in schedule.groups) names.push(groupName);
|
||||||
|
|
||||||
|
return plainToInstance(V2ScheduleGroupNamesDto, {
|
||||||
|
names: names,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDownloadUrl(
|
||||||
|
url: string,
|
||||||
|
silent: boolean = false,
|
||||||
|
): Promise<V2CacheStatusDto> {
|
||||||
|
await this.scheduleParser.getXlsDownloader().setDownloadUrl(url);
|
||||||
|
await this.v1ScheduleService.scheduleParser
|
||||||
|
.getXlsDownloader()
|
||||||
|
.setDownloadUrl(url);
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
await this.refreshCache(false);
|
||||||
|
await this.v1ScheduleService.refreshCache(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getCacheStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshCache(silent: boolean = false) {
|
||||||
|
if (!silent) {
|
||||||
|
await this.cacheManager.reset();
|
||||||
|
await this.v1ScheduleService.refreshCache(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getSourceSchedule(silent);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/users/dto/change-group.dto.ts
Normal file
4
src/users/dto/change-group.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../entity/user.entity";
|
||||||
|
|
||||||
|
export class ChangeGroupDto extends PickType(User, ["group"]) {}
|
||||||
4
src/users/dto/change-username.dto.ts
Normal file
4
src/users/dto/change-username.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PickType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../entity/user.entity";
|
||||||
|
|
||||||
|
export class ChangeUsernameDto extends PickType(User, ["username"]) {}
|
||||||
20
src/users/dto/v1/v1-client-user.dto.ts
Normal file
20
src/users/dto/v1/v1-client-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { OmitType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../../entity/user.entity";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
|
export class V1ClientUserDto extends OmitType(User, [
|
||||||
|
"accessToken",
|
||||||
|
"password",
|
||||||
|
"salt",
|
||||||
|
"fcm",
|
||||||
|
"version",
|
||||||
|
]) {
|
||||||
|
static fromUser(userDto: User): V1ClientUserDto {
|
||||||
|
return plainToInstance(V1ClientUserDto, {
|
||||||
|
id: userDto.id,
|
||||||
|
username: userDto.username,
|
||||||
|
group: userDto.group,
|
||||||
|
role: userDto.role,
|
||||||
|
} as V1ClientUserDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/users/dto/v2/v2-client-user.dto.ts
Normal file
20
src/users/dto/v2/v2-client-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { OmitType } from "@nestjs/swagger";
|
||||||
|
import { User } from "../../entity/user.entity";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
|
||||||
|
export class V2ClientUserDto extends OmitType(User, [
|
||||||
|
"password",
|
||||||
|
"salt",
|
||||||
|
"fcm",
|
||||||
|
"version",
|
||||||
|
]) {
|
||||||
|
static fromUser(userDto: User): V2ClientUserDto {
|
||||||
|
return plainToInstance(V2ClientUserDto, {
|
||||||
|
id: userDto.id,
|
||||||
|
username: userDto.username,
|
||||||
|
accessToken: userDto.accessToken,
|
||||||
|
group: userDto.group,
|
||||||
|
role: userDto.role,
|
||||||
|
} as V2ClientUserDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/users/entity/fcm-user.entity.ts
Normal file
19
src/users/entity/fcm-user.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsArray, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export class FcmUser {
|
||||||
|
/**
|
||||||
|
* Токен Firebase Cloud Messaging
|
||||||
|
* @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..."
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Топики на которые подписан пользователь
|
||||||
|
* @example ["schedule-update"]
|
||||||
|
*/
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@IsString()
|
||||||
|
topics: Array<string>;
|
||||||
|
}
|
||||||
83
src/users/entity/user.entity.ts
Normal file
83
src/users/entity/user.entity.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsJWT,
|
||||||
|
IsMongoId,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsSemVer,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { UserRole } from "../user-role.enum";
|
||||||
|
|
||||||
|
import { FcmUser } from "./fcm-user.entity";
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
/**
|
||||||
|
* Идентификатор (ObjectId)
|
||||||
|
* @example "66e1b7e255c5d5f1268cce90"
|
||||||
|
*/
|
||||||
|
@IsMongoId()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя
|
||||||
|
* @example "n08i40k"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4)
|
||||||
|
@MaxLength(10)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Соль пароля
|
||||||
|
* @example "$2b$08$34xwFv1WVJpvpVi3tZZuv."
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
salt: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хеш пароля
|
||||||
|
* @example "$2b$08$34xwFv1WVJpvpVi3tZZuv."
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Последний токен доступа
|
||||||
|
* @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..."
|
||||||
|
*/
|
||||||
|
@IsJWT()
|
||||||
|
accessToken: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группа
|
||||||
|
* @example "ИС-214/23"
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
group: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Роль
|
||||||
|
* @example STUDENT
|
||||||
|
*/
|
||||||
|
@IsEnum(UserRole)
|
||||||
|
role: UserRole;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные Firebase Cloud Messaging
|
||||||
|
*/
|
||||||
|
@IsObject()
|
||||||
|
@Type(() => FcmUser)
|
||||||
|
@IsOptional()
|
||||||
|
fcm?: FcmUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Версия установленного приложения
|
||||||
|
* @example "2.0.0"
|
||||||
|
*/
|
||||||
|
@IsSemVer()
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
5
src/users/user-role.enum.ts
Normal file
5
src/users/user-role.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum UserRole {
|
||||||
|
STUDENT = "STUDENT",
|
||||||
|
TEACHER = "TEACHER",
|
||||||
|
ADMIN = "ADMIN",
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { forwardRef, Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { UsersController } from "./users.controller";
|
import { V2UsersController } from "./v2-users.controller";
|
||||||
import { ScheduleModule } from "../schedule/schedule.module";
|
import { ScheduleModule } from "../schedule/schedule.module";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { V1UsersController } from "./v1-users.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => ScheduleModule), forwardRef(() => AuthModule)],
|
imports: [forwardRef(() => ScheduleModule), forwardRef(() => AuthModule)],
|
||||||
providers: [PrismaService, UsersService],
|
providers: [PrismaService, UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
controllers: [UsersController],
|
controllers: [V1UsersController, V2UsersController],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -7,56 +7,53 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import {
|
import { V1ScheduleService } from "../schedule/v1-schedule.service";
|
||||||
ChangeGroupReqDto,
|
import { User } from "./entity/user.entity";
|
||||||
ChangeUsernameReqDto,
|
import { ChangeUsernameDto } from "./dto/change-username.dto";
|
||||||
UserDto,
|
import { ChangeGroupDto } from "./dto/change-group.dto";
|
||||||
} from "../dto/user.dto";
|
import { plainToInstance } from "class-transformer";
|
||||||
import { ScheduleService } from "../schedule/schedule.service";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@Inject(forwardRef(() => ScheduleService))
|
@Inject(forwardRef(() => V1ScheduleService))
|
||||||
private readonly scheduleService: ScheduleService,
|
private readonly scheduleService: V1ScheduleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private static convertToDto = (user: UserDto | null) =>
|
async findUnique(where: Prisma.UserWhereUniqueInput): Promise<User | null> {
|
||||||
user as UserDto | null;
|
return plainToInstance(
|
||||||
|
User,
|
||||||
async findUnique(
|
await this.prismaService.user.findUnique({ where: where }),
|
||||||
where: Prisma.UserWhereUniqueInput,
|
);
|
||||||
): Promise<UserDto | null> {
|
|
||||||
return this.prismaService.user
|
|
||||||
.findUnique({ where: where })
|
|
||||||
.then(UsersService.convertToDto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(params: {
|
async update(params: {
|
||||||
where: Prisma.UserWhereUniqueInput;
|
where: Prisma.UserWhereUniqueInput;
|
||||||
data: Prisma.UserUpdateInput;
|
data: Prisma.UserUpdateInput;
|
||||||
}): Promise<UserDto> {
|
}): Promise<User> {
|
||||||
return this.prismaService.user
|
return plainToInstance(
|
||||||
.update(params)
|
User,
|
||||||
.then(UsersService.convertToDto);
|
await this.prismaService.user.update(params),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: Prisma.UserCreateInput): Promise<UserDto> {
|
async create(data: Prisma.UserCreateInput): Promise<User> {
|
||||||
return this.prismaService.user
|
return plainToInstance(
|
||||||
.create({ data })
|
User,
|
||||||
.then(UsersService.convertToDto);
|
await this.prismaService.user.create({ data }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async contains(where: Prisma.UserWhereUniqueInput): Promise<boolean> {
|
async contains(where: Prisma.UserWhereUniqueInput): Promise<boolean> {
|
||||||
return this.prismaService.user
|
return await this.prismaService.user
|
||||||
.count({ where })
|
.count({ where })
|
||||||
.then((count) => count > 0);
|
.then((count) => count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeUsername(
|
async changeUsername(
|
||||||
user: UserDto,
|
user: User,
|
||||||
changeUsernameDto: ChangeUsernameReqDto,
|
changeUsernameDto: ChangeUsernameDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (user.username === changeUsernameDto.username) return;
|
if (user.username === changeUsernameDto.username) return;
|
||||||
|
|
||||||
@@ -73,8 +70,8 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async changeGroup(
|
async changeGroup(
|
||||||
user: UserDto,
|
user: User,
|
||||||
changeGroupDto: ChangeGroupReqDto,
|
changeGroupDto: ChangeGroupDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (user.group === changeGroupDto.group) return;
|
if (user.group === changeGroupDto.group) return;
|
||||||
|
|
||||||
|
|||||||
@@ -8,86 +8,87 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/auth.guard";
|
import { AuthGuard } from "../auth/auth.guard";
|
||||||
import {
|
|
||||||
ChangeGroupReqDto,
|
|
||||||
ChangeUsernameReqDto,
|
|
||||||
ClientUserResDto,
|
|
||||||
} from "../dto/user.dto";
|
|
||||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
import { UserToken } from "../auth/auth.decorator";
|
import { UserToken } from "../auth/auth.decorator";
|
||||||
import { AuthService } from "../auth/auth.service";
|
import { AuthService } from "../auth/auth.service";
|
||||||
import { UsersService } from "./users.service";
|
import { UsersService } from "./users.service";
|
||||||
import {
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiConflictResponse,
|
|
||||||
ApiExtraModels,
|
|
||||||
ApiNotFoundResponse,
|
|
||||||
ApiOkResponse,
|
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
refs,
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
|
import { ChangeUsernameDto } from "./dto/change-username.dto";
|
||||||
|
import { ChangeGroupDto } from "./dto/change-group.dto";
|
||||||
|
import { V1ClientUserDto } from "./dto/v1/v1-client-user.dto";
|
||||||
|
|
||||||
@Controller("api/v1/users")
|
@ApiTags("v1/users")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller({ path: "users", version: "1" })
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class UsersController {
|
export class V1UsersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ApiExtraModels(ClientUserResDto)
|
@ApiOperation({ summary: "Получение данных о профиле пользователя" })
|
||||||
@ApiOperation({
|
@ApiResponse({
|
||||||
summary: "Получение данных о профиле пользователя",
|
status: HttpStatus.OK,
|
||||||
tags: ["users"],
|
|
||||||
})
|
|
||||||
@ApiOkResponse({
|
|
||||||
description: "Получение профиля прошло успешно",
|
description: "Получение профиля прошло успешно",
|
||||||
schema: refs(ClientUserResDto)[0],
|
type: V1ClientUserDto,
|
||||||
})
|
})
|
||||||
@ResultDto(ClientUserResDto)
|
@ResultDto(V1ClientUserDto)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get("me")
|
@Get("me")
|
||||||
async getMe(@UserToken() token: string): Promise<ClientUserResDto> {
|
async getMe(@UserToken() token: string): Promise<V1ClientUserDto> {
|
||||||
const userDto = await this.authService.decodeUserToken(token);
|
return V1ClientUserDto.fromUser(
|
||||||
|
await this.authService.decodeUserToken(token),
|
||||||
return ClientUserResDto.fromUserDto(userDto);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(ChangeUsernameReqDto)
|
@ApiOperation({ summary: "Смена имени пользователя" })
|
||||||
@ApiOperation({ summary: "Смена имени пользователя", tags: ["users"] })
|
@ApiBody({ type: ChangeUsernameDto })
|
||||||
@ApiBody({ schema: refs(ChangeUsernameReqDto)[0] })
|
@ApiResponse({
|
||||||
@ApiOkResponse({ description: "Смена имени профиля прошла успешно" })
|
status: HttpStatus.OK,
|
||||||
@ApiConflictResponse({
|
description: "Смена имени профиля прошла успешно",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.CONFLICT,
|
||||||
description: "Пользователь с таким именем уже существует",
|
description: "Пользователь с таким именем уже существует",
|
||||||
})
|
})
|
||||||
@ResultDto(null)
|
@ResultDto(null)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post("change-username")
|
@Post("change-username")
|
||||||
async changeUsername(
|
async changeUsername(
|
||||||
@Body() changeUsernameDto: ChangeUsernameReqDto,
|
@Body() reqDto: ChangeUsernameDto,
|
||||||
@UserToken() token: string,
|
@UserToken() token: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const user = await this.authService.decodeUserToken(token);
|
const user = await this.authService.decodeUserToken(token);
|
||||||
|
|
||||||
return await this.usersService.changeUsername(user, changeUsernameDto);
|
return await this.usersService.changeUsername(user, reqDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(ChangeGroupReqDto)
|
@ApiOperation({ summary: "Смена группы пользователя" })
|
||||||
@ApiOperation({ summary: "Смена группы пользователя", tags: ["users"] })
|
@ApiBody({ type: ChangeGroupDto })
|
||||||
@ApiBody({ schema: refs(ChangeGroupReqDto)[0] })
|
@ApiResponse({
|
||||||
@ApiOkResponse({ description: "Смена группы прошла успешно" })
|
status: HttpStatus.OK,
|
||||||
@ApiNotFoundResponse({
|
description: "Смена группы прошла успешно",
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.NOT_FOUND,
|
||||||
description: "Группа с таким названием не существует",
|
description: "Группа с таким названием не существует",
|
||||||
})
|
})
|
||||||
@ResultDto(null)
|
@ResultDto(null)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post("change-group")
|
@Post("change-group")
|
||||||
async changeGroup(
|
async changeGroup(
|
||||||
@Body() changeGroupDto: ChangeGroupReqDto,
|
@Body() reqDto: ChangeGroupDto,
|
||||||
@UserToken() token: string,
|
@UserToken() token: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const user = await this.authService.decodeUserToken(token);
|
const user = await this.authService.decodeUserToken(token);
|
||||||
|
|
||||||
return await this.usersService.changeGroup(user, changeGroupDto);
|
return await this.usersService.changeGroup(user, reqDto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
src/users/v2-users.controller.ts
Normal file
41
src/users/v2-users.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/auth.guard";
|
||||||
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
|
import { UserToken } from "../auth/auth.decorator";
|
||||||
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { V2ClientUserDto } from "./dto/v2/v2-client-user.dto";
|
||||||
|
|
||||||
|
@ApiTags("v2/users")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller({ path: "users", version: "2" })
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class V2UsersController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@ApiOperation({ summary: "Получение данных о профиле пользователя" })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: "Получение профиля прошло успешно",
|
||||||
|
type: V2ClientUserDto,
|
||||||
|
})
|
||||||
|
@ResultDto(V2ClientUserDto)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Get("me")
|
||||||
|
async getMe(@UserToken() token: string): Promise<V2ClientUserDto> {
|
||||||
|
return V2ClientUserDto.fromUser(
|
||||||
|
await this.authService.decodeUserToken(token),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/utility/class-validators/conditional-field.ts
Normal file
39
src/utility/class-validators/conditional-field.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationArguments,
|
||||||
|
ValidationOptions,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
// noinspection FunctionNamingConventionJS
|
||||||
|
export function NullIf(
|
||||||
|
canBeNull: (cls: object) => boolean,
|
||||||
|
validationOptions?: ValidationOptions,
|
||||||
|
) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: "nullIf",
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [canBeNull],
|
||||||
|
validator: {
|
||||||
|
validate(value: any, args: ValidationArguments) {
|
||||||
|
const canBeNullFunc: (cls: object) => boolean =
|
||||||
|
args.constraints[0];
|
||||||
|
|
||||||
|
const canBeNull = canBeNullFunc(args.object);
|
||||||
|
const currentValue = value;
|
||||||
|
|
||||||
|
// Логика валидации: если одно из полей null, то другое тоже должно быть null
|
||||||
|
|
||||||
|
return canBeNull
|
||||||
|
? currentValue !== null
|
||||||
|
: currentValue === null;
|
||||||
|
},
|
||||||
|
defaultMessage(args: ValidationArguments) {
|
||||||
|
return `${args.property} must be ${args.property === null ? "non-null" : "null"}!`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user