Refactor and reorganize codebase for better maintainability and clarity

- Rename DTOs to entities and move them to appropriate directories
- Remove deprecated controllers and services
- Update imports and dependencies
- Implement new class transformer decorators for better serialization
- Add VK authentication support
- Improve error handling and validation
- Update ESLint configuration and TypeScript settings
- Refactor schedule parsing logic
- Enhance user and authentication services
- Update Prisma schema and related entities
- Improve code organization and structure

This commit introduces significant changes to improve the overall structure and maintainability of the codebase, including better organization of DTOs, enhanced authentication features, and updated tooling configurations.
This commit is contained in:
2025-01-25 03:36:58 +04:00
parent 09a55fdff8
commit 1174f61487
76 changed files with 1273 additions and 2624 deletions

View File

@@ -1,25 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: false,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

42
eslint.config.mjs Normal file
View File

@@ -0,0 +1,42 @@
// @ts-check
import eslint from "@eslint/js";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["eslint.config.mjs", "**/node_modules/"],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 5,
sourceType: "module",
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/no-unused-vars": "warn",
},
},
);

View File

@@ -8,74 +8,78 @@
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"precommit": "npm run format && npm run lint",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json"
"precommit": "npm run format && npm run lint"
}, },
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^2.2.2", "@fastify/static": "^8.0.4",
"@nestjs/common": "^10.0.0", "@nestjs/cache-manager": "^3.0.0",
"@nestjs/core": "^10.4.5", "@nestjs/common": "^11.0.5",
"@nestjs/jwt": "^10.2.0", "@nestjs/core": "^11.0.5",
"@nestjs/platform-express": "^10.4.4", "@nestjs/jwt": "^11.0.0",
"@nestjs/swagger": "^7.4.2", "@nestjs/platform-express": "^11.0.5",
"@prisma/client": "^5.19.1", "@nestjs/platform-fastify": "^11.0.5",
"axios": "^1.7.7", "@nestjs/swagger": "^11.0.3",
"@prisma/client": "^6.2.1",
"axios": "^1.7.9",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cache-manager": "^5.7.6", "bson-objectid": "^2.0.4",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"cookie": ">=0.7.0", "cookie": ">=1.0.2",
"create-map-transform-fn": "gist:f65ddd8f17f8c388659aab76890f194b", "create-map-transform-fn": "gist:f65ddd8f17f8c388659aab76890f194b",
"dotenv": "^16.4.5", "dotenv": "^16.4.7",
"firebase-admin": "^12.6.0", "firebase-admin": "^13.0.2",
"jsdom": "^25.0.0", "jsdom": "^26.0.0",
"mongoose": "^8.6.1",
"nest-redoc": "^1.1.2",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"uuid": "^10.0.0", "uuid": "^11.0.5",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@eslint/eslintrc": "3.2.0",
"@nestjs/schematics": "^10.0.0", "@eslint/js": "9.18.0",
"@nestjs/testing": "^10.0.0", "@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.5",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.9",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17", "@types/express": "^5.0.0",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.14",
"@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": "^22.10.9",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "cookie": ">=1.0.2",
"@typescript-eslint/parser": "^8.0.0", "eslint": "9.18.0",
"cookie": ">=0.7.0", "eslint-plugin-prettier": "5.2.3",
"eslint": "^8.42.0", "fastify": "^5.2.1",
"eslint-config-prettier": "^9.0.0", "jest": "^29.7.0",
"eslint-plugin-prettier": "^5.0.0", "prettier": "3.4.2",
"jest": "^29.1.0", "prisma": "^6.2.1",
"prettier": "^3.0.0",
"prisma": "^5.19.1",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.1.0", "ts-jest": "^29.2.5",
"ts-loader": "^9.4.3", "ts-loader": "^9.5.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3" "typescript": "^5.7.3",
"typescript-eslint": "8.21.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@@ -35,9 +35,10 @@ model User {
// //
username String @unique username String @unique
// //
salt String
password String password String
// //
vkId Int?
//
accessToken String @unique accessToken String @unique
// //
group String group String

View File

@@ -13,7 +13,6 @@ import { FirebaseAdminModule } from "./firebase-admin/firebase-admin.module";
CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }), CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }),
FirebaseAdminModule, FirebaseAdminModule,
], ],
controllers: [],
providers: [], providers: [],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,6 +1,6 @@
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { UserRole } from "../users/user-role.enum"; import UserRole from "../users/user-role.enum";
export const AuthRoles = Reflector.createDecorator<UserRole[]>(); export const AuthRoles = Reflector.createDecorator<UserRole[]>();
export const AuthUnauthorized = Reflector.createDecorator<true>(); export const AuthUnauthorized = Reflector.createDecorator<true>();

158
src/auth/auth.controller.ts Normal file
View File

@@ -0,0 +1,158 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Res,
} 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 { SignInDto, SignInVKDto } from "./dto/sign-in.dto";
import { SignUpDto, SignUpVKDto } from "./dto/sign-up.dto";
import { ScheduleService } from "../schedule/schedule.service";
import SignInErrorDto from "./dto/sign-in-error.dto";
import { FastifyReply } from "fastify";
import SignUpErrorDto, { SignUpErrorCode } from "./dto/sign-up-error.dto";
import UserDto from "src/users/dto/user.dto";
import GetGroupNamesDto from "../schedule/dto/get-group-names.dto";
@ApiTags("v2/auth")
@Controller({ path: "auth", version: "1" })
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly scheduleService: ScheduleService,
) {}
@ApiOperation({ summary: "Авторизация по логину и паролю" })
@ApiBody({ type: SignInDto })
@ApiResponse({
status: HttpStatus.OK,
description: "Авторизация прошла успешно",
type: UserDto,
})
@ApiResponse({
status: HttpStatus.NOT_ACCEPTABLE,
description: "Переданы неверные входные данные",
type: SignInErrorDto,
})
@ResultDto([UserDto, SignInErrorDto])
@HttpCode(HttpStatus.OK)
@Post("sign-in")
async signIn(
@Body() signInDto: SignInDto,
@Res({ passthrough: true }) response: FastifyReply,
): Promise<UserDto | SignInErrorDto> {
const result = await this.authService.signIn(signInDto);
if (result instanceof SignInErrorDto)
response.status(HttpStatus.NOT_ACCEPTABLE);
return result;
}
@ApiOperation({ summary: "Регистрация по логину и паролю" })
@ApiBody({ type: SignUpDto })
@ApiResponse({
status: HttpStatus.CREATED,
description: "Регистрация прошла успешно",
type: UserDto,
})
@ApiResponse({
status: HttpStatus.NOT_ACCEPTABLE,
description: "Переданы неверные входные данные",
type: SignUpErrorDto,
})
@ResultDto([UserDto, SignUpErrorDto])
@HttpCode(HttpStatus.CREATED)
@Post("sign-up")
async signUp(
@Body() signUpDto: SignUpDto,
@Res({ passthrough: true }) response: FastifyReply,
): Promise<UserDto | SignUpErrorDto> {
const groupNames = await this.scheduleService
.getGroupNames()
.catch((): GetGroupNamesDto => null);
if (
groupNames &&
!groupNames.names.includes(signUpDto.group.replaceAll(" ", ""))
) {
response.status(HttpStatus.NOT_ACCEPTABLE);
return new SignUpErrorDto(SignUpErrorCode.INVALID_GROUP_NAME);
}
const result = await this.authService.signUp(signUpDto);
if (result instanceof SignUpErrorDto)
response.status(HttpStatus.NOT_ACCEPTABLE);
return result;
}
@ApiOperation({ summary: "Авторизация с помощью VK ID" })
@ApiBody({ type: SignInVKDto })
@ApiResponse({
status: HttpStatus.OK,
description: "Авторизация прошла успешно",
type: UserDto,
})
@ApiResponse({
status: HttpStatus.NOT_ACCEPTABLE,
description: "Переданы неверные входные данные",
type: SignInErrorDto,
})
@ResultDto([UserDto, SignInErrorDto])
@HttpCode(HttpStatus.OK)
@Post("sign-in-vk")
async signInVK(
@Body() signInVKDto: SignInVKDto,
@Res({ passthrough: true }) response: FastifyReply,
): Promise<UserDto | SignInErrorDto> {
const result = await this.authService.signInVK(signInVKDto);
if (result instanceof SignInErrorDto)
response.status(HttpStatus.NOT_ACCEPTABLE);
return result;
}
@ApiOperation({ summary: "Регистрация с помощью VK ID" })
@ApiBody({ type: SignUpVKDto })
@ApiResponse({
status: HttpStatus.CREATED,
description: "Регистрация прошла успешно",
type: UserDto,
})
@ApiResponse({
status: HttpStatus.NOT_ACCEPTABLE,
description: "Переданы неверные входные данные",
type: SignUpErrorDto,
})
@ResultDto([UserDto, SignUpErrorDto])
@HttpCode(HttpStatus.CREATED)
@Post("sign-up-vk")
async signUpVK(
@Body() signUpVKDto: SignUpVKDto,
@Res({ passthrough: true }) response: FastifyReply,
): Promise<UserDto | SignUpErrorDto> {
const groupNames = await this.scheduleService
.getGroupNames()
.catch((): GetGroupNamesDto => null);
if (
groupNames &&
!groupNames.names.includes(signUpVKDto.group.replaceAll(" ", ""))
) {
response.status(HttpStatus.NOT_ACCEPTABLE);
return new SignUpErrorDto(SignUpErrorCode.INVALID_GROUP_NAME);
}
const result = await this.authService.signUpVK(signUpVKDto);
if (result instanceof SignUpErrorDto)
response.status(HttpStatus.NOT_ACCEPTABLE);
return result;
}
}

View File

@@ -1,16 +1,19 @@
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
ForbiddenException,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
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.decorator"; import { AuthRoles, AuthUnauthorized } from "./auth-role.decorator";
import { isJWT } from "class-validator"; import { isJWT } from "class-validator";
import { FastifyRequest } from "fastify";
interface JWTUser {
id: string;
}
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
@@ -20,11 +23,10 @@ export class AuthGuard implements CanActivate {
private readonly reflector: Reflector, private readonly reflector: Reflector,
) {} ) {}
public static extractTokenFromRequest(req: Request): string { public static extractTokenFromRequest(req: FastifyRequest): string {
const [type, token] = req.headers.authorization?.split(" ") ?? []; const [type, token] = req.headers.authorization?.split(" ") ?? [];
if (type !== "Bearer" || !token || token.length === 0) if (type !== "Bearer" || !token || token.length === 0) return null;
throw new UnauthorizedException("Не указан токен!");
return token; return token;
} }
@@ -33,31 +35,24 @@ export class AuthGuard implements CanActivate {
if (this.reflector.get(AuthUnauthorized, context.getHandler())) if (this.reflector.get(AuthUnauthorized, context.getHandler()))
return true; return true;
const request = context.switchToHttp().getRequest(); const request: FastifyRequest = context.switchToHttp().getRequest();
const token = AuthGuard.extractTokenFromRequest(request); const token = AuthGuard.extractTokenFromRequest(request);
let jwtUser: { id: string } | null = null; if (!token || !isJWT(token)) throw new UnauthorizedException();
if ( const jwtUser = await this.jwtService
!isJWT(token) || .verifyAsync<JWTUser>(token)
!(jwtUser = await this.jwtService .catch((): JWTUser => null);
.verifyAsync(token) if (!jwtUser) throw new UnauthorizedException();
.catch(() => null))
)
throw new UnauthorizedException();
const user = await this.usersService.findUnique({ id: jwtUser.id }); const user = await this.usersService.findUnique({ id: jwtUser.id });
if (!user || user.accessToken !== token) if (!user) throw new UnauthorizedException();
throw new UnauthorizedException();
const acceptableRoles = this.reflector.get( const acceptableRoles = this.reflector.get(
AuthRoles, AuthRoles,
context.getHandler(), context.getHandler(),
); );
if (acceptableRoles != null && !acceptableRoles.includes(user.role)) return !(acceptableRoles && !acceptableRoles.includes(user.role));
throw new ForbiddenException();
return true;
} }
} }

View File

@@ -2,11 +2,10 @@ 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 { 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"; import { AuthController } from "./auth.controller";
@Module({ @Module({
imports: [ imports: [
@@ -19,7 +18,7 @@ import { V2AuthController } from "./v2-auth.controller";
}), }),
], ],
providers: [AuthService, PrismaService], providers: [AuthService, PrismaService],
controllers: [V1AuthController, V2AuthController], controllers: [AuthController],
exports: [AuthService], exports: [AuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -6,7 +6,7 @@ import {
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { User } from "../users/entity/user.entity"; import User from "../users/entity/user.entity";
@Injectable() @Injectable()
export class UserPipe implements PipeTransform { export class UserPipe implements PipeTransform {
@@ -25,6 +25,6 @@ export class UserPipe implements PipeTransform {
if (!user) if (!user)
throw new UnauthorizedException("Передан некорректный токен!"); throw new UnauthorizedException("Передан некорректный токен!");
return user as User; return user;
} }
} }

View File

@@ -1,20 +1,21 @@
import { import {
ConflictException, ConflictException,
Injectable, Injectable,
NotAcceptableException,
NotFoundException,
UnauthorizedException, UnauthorizedException,
} 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 { genSalt, hash } from "bcrypt"; import { compare, genSalt, hash } from "bcrypt";
import { Prisma } from "@prisma/client"; import UserRole from "../users/user-role.enum";
import { Types } from "mongoose"; import User from "../users/entity/user.entity";
import { UserRole } from "../users/user-role.enum"; import ChangePasswordDto from "./dto/change-password.dto";
import { User } from "../users/entity/user.entity"; import axios from "axios";
import { SignInDto } from "./dto/sign-in.dto"; import SignInErrorDto, { SignInErrorCode } from "./dto/sign-in-error.dto";
import { SignUpDto } from "./dto/sign-up.dto"; import { SignUpDto, SignUpVKDto } from "./dto/sign-up.dto";
import { ChangePasswordDto } from "./dto/change-password.dto"; import SignUpErrorDto, { SignUpErrorCode } from "./dto/sign-up-error.dto";
import { SignInDto, SignInVKDto } from "./dto/sign-in.dto";
import ObjectID from "bson-objectid";
import UserDto from "../users/dto/user.dto";
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -51,112 +52,124 @@ export class AuthService {
return user; return user;
} }
/** async signUp(signUpDto: SignUpDto): Promise<UserDto | SignUpErrorDto> {
* Регистрация нового пользователя if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role))
* @param signUp - данные нового пользователя return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE);
* @returns {User} - пользователь
* @throws {NotAcceptableException} - передана недопустимая роль
* @throws {ConflictException} - пользователь с таким именем уже существует
* @async
*/
async signUp(signUp: SignUpDto): Promise<User> {
const group = signUp.group.replaceAll(" ", "");
const username = signUp.username.trim();
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUp.role)) if (await this.usersService.contains({ username: signUpDto.username }))
throw new NotAcceptableException("Передана неизвестная роль"); return new SignUpErrorDto(SignUpErrorCode.USERNAME_ALREADY_EXISTS);
if (await this.usersService.contains({ username: username })) { const id = ObjectID().toHexString();
throw new ConflictException(
"Пользователь с таким именем уже существует!", return UserDto.fromPlain(
await this.usersService.create({
id: id,
username: signUpDto.username,
password: await hash(signUpDto.password, await genSalt(8)),
accessToken: await this.jwtService.signAsync({ id: id }),
group: signUpDto.group,
role: signUpDto.role,
version: signUpDto.version,
}),
["auth"],
); );
} }
const salt = await genSalt(8); async signIn(signIn: SignInDto): Promise<UserDto | SignInErrorDto> {
const id = new Types.ObjectId().toString("hex");
const input: Prisma.UserCreateInput = {
id: id,
username: username,
salt: salt,
password: await hash(signUp.password, salt),
accessToken: await this.jwtService.signAsync({
id: id,
}),
role: signUp.role as UserRole,
group: group,
version: signUp.version ?? "1.0.0",
};
return await this.usersService.create(input);
}
/**
* Авторизация пользователя
* @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: signIn.username, username: signIn.username,
}); });
if ( if (!user || !(await compare(signIn.password, user.password)))
!user || return new SignInErrorDto(SignInErrorCode.INCORRECT_CREDENTIALS);
user.password !== (await hash(signIn.password, user.salt))
) { return UserDto.fromPlain(
throw new UnauthorizedException( await this.usersService.update({
"Некорректное имя пользователя или пароль!", where: { id: user.id },
data: {
accessToken: await this.jwtService.signAsync({
id: user.id,
}),
},
}),
["auth"],
); );
} }
private static async parseVKID(accessToken: string): Promise<number> {
const form = new FormData();
form.append("access_token", accessToken);
form.append("v", "5.199");
const response = await axios.post(
"https://api.vk.com/method/account.getProfileInfo",
form,
{ responseType: "json" },
);
const data: { error?: any; response?: { id: number } } =
response.data as object;
if (response.status !== 200 || data.error !== undefined) return null;
return data.response.id;
}
async signUpVK(signUpDto: SignUpVKDto): Promise<UserDto | SignUpErrorDto> {
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role))
return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE);
if (await this.usersService.contains({ username: signUpDto.username }))
return new SignUpErrorDto(SignUpErrorCode.USERNAME_ALREADY_EXISTS);
const vkId = await AuthService.parseVKID(signUpDto.accessToken);
if (!vkId)
return new SignUpErrorDto(SignUpErrorCode.INVALID_VK_ACCESS_TOKEN);
if (await this.usersService.contains({ vkId: vkId }))
return new SignUpErrorDto(SignUpErrorCode.VK_ALREADY_EXISTS);
const id = ObjectID().toHexString();
return UserDto.fromPlain(
await this.usersService.create({
id: id,
username: signUpDto.username,
password: await hash(await genSalt(8), await genSalt(8)),
vkId: vkId,
accessToken: await this.jwtService.signAsync({
id: id,
}),
role: signUpDto.role,
group: signUpDto.group,
version: signUpDto.version,
}),
["auth"],
);
}
async signInVK(
signInVKDto: SignInVKDto,
): Promise<UserDto | SignInErrorDto> {
const vkId = await AuthService.parseVKID(signInVKDto.accessToken);
if (!vkId)
return new SignInErrorDto(SignInErrorCode.INVALID_VK_ACCESS_TOKEN);
const user = await this.usersService.findOne({ vkId: vkId });
if (!user)
return new SignInErrorDto(SignInErrorCode.INCORRECT_CREDENTIALS);
const accessToken = await this.jwtService.signAsync({ id: user.id }); const accessToken = await this.jwtService.signAsync({ id: user.id });
return await this.usersService.update({ return UserDto.fromPlain(
await this.usersService.update({
where: { id: user.id }, where: { id: user.id },
data: { accessToken: accessToken }, data: { accessToken: accessToken },
}); }),
} ["auth"],
/**
* Обновление токена пользователя
* @param oldToken - старый токен
* @returns {User} - пользователь
* @throws {NotFoundException} - некорректный или недействительный токен
* @throws {NotFoundException} - токен указывает на несуществующего пользователя
* @throws {NotFoundException} - текущий токен устарел и был обновлён на новый
* @async
*/
async updateToken(oldToken: string): Promise<User> {
if (
!(await this.jwtService.verifyAsync(oldToken, {
ignoreExpiration: true,
}))
) {
throw new NotFoundException(
"Некорректный или недействительный токен!",
); );
} }
const jwtUser: { id: string } = await this.jwtService.decode(oldToken);
const user = await this.usersService.findUnique({ id: jwtUser.id });
if (!user || user.accessToken !== oldToken) {
throw new NotFoundException(
"Некорректный или недействительный токен!",
);
}
const accessToken = await this.jwtService.signAsync({ id: user.id });
return await this.usersService.update({
where: { id: user.id },
data: { accessToken: accessToken },
});
}
/** /**
* Смена пароля пользователя * Смена пароля пользователя
* @param user - пользователь * @param user - пользователь

View File

@@ -1,6 +1,6 @@
import { IsString } from "class-validator"; import { IsString } from "class-validator";
export class ChangePasswordDto { export default class ChangePasswordDto {
/** /**
* Старый пароль * Старый пароль
* @example "my-old-password" * @example "my-old-password"

View File

@@ -0,0 +1,15 @@
import { IsEnum } from "class-validator";
export enum SignInErrorCode {
INCORRECT_CREDENTIALS = "INCORRECT_CREDENTIALS",
INVALID_VK_ACCESS_TOKEN = "INVALID_VK_ACCESS_TOKEN",
}
export default class SignInErrorDto {
@IsEnum(SignInErrorCode)
code: SignInErrorCode;
constructor(errorCode: SignInErrorCode) {
this.code = errorCode;
}
}

View File

@@ -1,6 +1,6 @@
import { IsJWT, IsMongoId, IsOptional, IsString } from "class-validator"; import { IsJWT, IsMongoId, IsOptional, IsString } from "class-validator";
export class SignInResponseDto { export default class SignInResponseDto {
/** /**
* Идентификатор (ObjectId) * Идентификатор (ObjectId)
* @example "66e1b7e255c5d5f1268cce90" * @example "66e1b7e255c5d5f1268cce90"

View File

@@ -1,8 +1,15 @@
import { PickType } from "@nestjs/swagger"; import { IsString, MaxLength, MinLength } from "class-validator";
import { User } from "../../users/entity/user.entity";
import { IsString } from "class-validator"; export class SignInDto {
/**
* Имя
* @example "n08i40k"
*/
@IsString()
@MinLength(1)
@MaxLength(20)
username: string;
export class SignInDto extends PickType(User, ["username"]) {
/** /**
* Пароль в исходном виде * Пароль в исходном виде
* @example "my-password" * @example "my-password"
@@ -10,3 +17,12 @@ export class SignInDto extends PickType(User, ["username"]) {
@IsString() @IsString()
password: string; password: string;
} }
export class SignInVKDto {
/**
* Токен VK
* @example "хз"
*/
@IsString()
accessToken: string;
}

View File

@@ -0,0 +1,18 @@
import { IsEnum } from "class-validator";
export enum SignUpErrorCode {
USERNAME_ALREADY_EXISTS = "USERNAME_ALREADY_EXISTS",
VK_ALREADY_EXISTS = "VK_ALREADY_EXISTS",
INVALID_VK_ACCESS_TOKEN = "INVALID_VK_ACCESS_TOKEN",
INVALID_GROUP_NAME = "INVALID_GROUP_NAME",
DISALLOWED_ROLE = "DISALLOWED_ROLE",
}
export default class SignUpErrorDto {
@IsEnum(SignUpErrorCode)
code: SignUpErrorCode;
constructor(errorCode: SignUpErrorCode) {
this.code = errorCode;
}
}

View File

@@ -1,4 +1,7 @@
import { PickType } from "@nestjs/swagger"; import { PickType } from "@nestjs/swagger";
import { User } from "../../users/entity/user.entity"; import User from "../../users/entity/user.entity";
export class SignUpResponseDto extends PickType(User, ["id", "accessToken"]) {} export default class SignUpResponseDto extends PickType(User, [
"id",
"accessToken",
]) {}

View File

@@ -1,9 +1,86 @@
import { IntersectionType, PartialType, PickType } from "@nestjs/swagger"; import {
import { SignInDto } from "./sign-in.dto"; IsEnum,
import { User } from "../../users/entity/user.entity"; IsSemVer,
IsString,
MaxLength,
MinLength,
} from "class-validator";
import UserRole from "../../users/user-role.enum";
export class SignUpDto extends IntersectionType( export class SignUpDto {
SignInDto, /**
PickType(User, ["role", "group"]), * Имя
PartialType(PickType(User, ["version"])), * @example "n08i40k"
) {} */
@IsString()
@MinLength(1)
@MaxLength(20)
username: string;
/**
* Пароль в исходном виде
* @example "my-password"
*/
@IsString()
password: string;
/**
* Группа
* @example "ИС-214/23"
*/
@IsString()
group: string;
/**
* Роль
* @example STUDENT
*/
@IsEnum(UserRole)
role: UserRole;
/**
* Версия установленного приложения
* @example "2.0.0"
*/
@IsSemVer()
version: string;
}
export class SignUpVKDto {
/**
* Токен VK
* @example "хз"
*/
@IsString()
accessToken: string;
/**
* Имя
* @example "n08i40k"
*/
@IsString()
@MinLength(1)
@MaxLength(20)
username: string;
/**
* Группа
* @example "ИС-214/23"
*/
@IsString()
group: string;
/**
* Роль
* @example STUDENT
*/
@IsEnum(UserRole)
role: UserRole;
/**
* Версия установленного приложения
* @example "2.0.0"
*/
@IsSemVer()
version: string;
}

View File

@@ -1,3 +1,3 @@
import { UpdateTokenDto } from "./update-token.dto"; import UpdateTokenDto from "./update-token.dto";
export class UpdateTokenResponseDto extends UpdateTokenDto {} export default class UpdateTokenResponseDto extends UpdateTokenDto {}

View File

@@ -1,4 +1,4 @@
import { PickType } from "@nestjs/swagger"; import { PickType } from "@nestjs/swagger";
import { User } from "../../users/entity/user.entity"; import User from "../../users/entity/user.entity";
export class UpdateTokenDto extends PickType(User, ["accessToken"]) {} export default class UpdateTokenDto extends PickType(User, ["accessToken"]) {}

View File

@@ -1,138 +0,0 @@
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 { 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";
import { ScheduleService } from "../schedule/schedule.service";
@ApiTags("v1/auth")
@Controller({ path: "auth", version: "1" })
export class V1AuthController {
constructor(
private readonly authService: AuthService,
private readonly scheduleService: ScheduleService,
) {}
@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),
);
}
}

View File

@@ -1,93 +0,0 @@
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 { 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";
import { ScheduleService } from "../schedule/schedule.service";
@ApiTags("v2/auth")
@Controller({ path: "auth", version: "2" })
export class V2AuthController {
constructor(
private readonly authService: AuthService,
private readonly scheduleService: ScheduleService,
) {}
@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),
);
}
}

View File

@@ -4,23 +4,23 @@ import * as process from "node:process";
configDotenv(); configDotenv();
export const jwtConstants = { export const jwtConstants = {
secret: process.env.JWT_SECRET!, secret: process.env.JWT_SECRET,
}; };
export const httpsConstants = { export const httpsConstants = {
certPath: process.env.CERT_PEM_PATH!, certPath: process.env.CERT_PEM_PATH,
keyPath: process.env.KEY_PEM_PATH!, keyPath: process.env.KEY_PEM_PATH,
}; };
export const apiConstants = { export const apiConstants = {
port: +(process.env.API_PORT ?? 5050), port: +(process.env.API_PORT ?? 5050),
version: process.env.SERVER_VERSION!, version: process.env.SERVER_VERSION,
}; };
export const firebaseConstants = { export const firebaseConstants = {
serviceAccountPath: process.env.FIREBASE_ACCOUNT_PATH!, serviceAccountPath: process.env.FIREBASE_ACCOUNT_PATH,
}; };
export const scheduleConstants = { export const scheduleConstants = {
cacheInvalidateDelay: +(process.env.SERVER_CACHE_INVALIDATE_DELAY! ?? 5), cacheInvalidateDelay: +(process.env.SERVER_CACHE_INVALIDATE_DELAY ?? 5),
}; };

View File

@@ -15,7 +15,7 @@ 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-post-update.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 User from "../users/entity/user.entity";
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -24,7 +24,7 @@ import {
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { AuthRoles } from "../auth/auth-role.decorator"; import { AuthRoles } from "../auth/auth-role.decorator";
import { UserRole } from "../users/user-role.enum"; import UserRole from "../users/user-role.enum";
import { import {
TokenMessage, TokenMessage,
TopicMessage, TopicMessage,

View File

@@ -12,9 +12,9 @@ import {
import { firebaseConstants } from "../contants"; import { firebaseConstants } from "../contants";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { User } from "../users/entity/user.entity"; import User from "../users/entity/user.entity";
import { TokenMessage } from "firebase-admin/lib/messaging/messaging-api"; import { TokenMessage } from "firebase-admin/lib/messaging/messaging-api";
import { FcmUser } from "../users/entity/fcm-user.entity"; import FCM from "../users/entity/fcm-user.entity";
import { plainToInstance } from "class-transformer"; import { plainToInstance } from "class-transformer";
@Injectable() @Injectable()
@@ -47,12 +47,12 @@ export class FirebaseAdminService implements OnModuleInit {
await this.messaging.send(message); await this.messaging.send(message);
} }
private getFcmOrDefault(user: User, token: string): FcmUser { private getFcmOrDefault(user: User, token: string): FCM {
if (!user.fcm) { if (!user.fcm) {
return plainToInstance(FcmUser, { return plainToInstance(FCM, {
token: token, token: token,
topics: [], topics: [],
} as FcmUser); } as FCM);
} }
return user.fcm; return user.fcm;
@@ -68,7 +68,7 @@ export class FirebaseAdminService implements OnModuleInit {
if (!isNew) { if (!isNew) {
if (fcm.token === token) return { userDto: user, isNew: false }; if (fcm.token === token) return { userDto: user, isNew: false };
for (const topic in fcm.topics) for (const topic of fcm.topics)
await this.messaging.subscribeToTopic(token, topic); await this.messaging.subscribeToTopic(token, topic);
fcm.token = token; fcm.token = token;
} }

View File

@@ -3,22 +3,30 @@ 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 { 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"; import { VersioningType } from "@nestjs/common";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
{
httpsOptions: { httpsOptions: {
cert: fs.readFileSync(httpsConstants.certPath), cert: fs.readFileSync(httpsConstants.certPath),
key: fs.readFileSync(httpsConstants.keyPath), key: fs.readFileSync(httpsConstants.keyPath),
}, },
}); },
);
const validatorOptions: ValidatorOptions = { const validatorOptions: ValidatorOptions = {
enableDebugMessages: true, enableDebugMessages: true,
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
whitelist: true, strictGroups: true,
}; };
app.useGlobalPipes(new PartialValidationPipe(validatorOptions)); app.useGlobalPipes(new PartialValidationPipe(validatorOptions));
app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions)); app.useGlobalInterceptors(new ClassValidatorInterceptor(validatorOptions));
@@ -29,21 +37,24 @@ async function bootstrap() {
type: VersioningType.URI, type: VersioningType.URI,
}); });
const swaggerConfig = RedocModule.createDocumentBuilder() const swaggerConfig = new DocumentBuilder()
.setTitle("Schedule Parser") .setTitle("Schedule Parser")
.setDescription("Парсер расписания") .setDescription("Парсер расписания")
.setVersion(apiConstants.version) .setVersion(apiConstants.version)
.build(); .build();
const swaggerDocument = RedocModule.createDocument(app, swaggerConfig, {
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig, {
deepScanRoutes: true, deepScanRoutes: true,
}); });
swaggerDocument.servers = [ swaggerDocument.servers = [
{ {
url: `https://localhost:${apiConstants.port}`, url: `https://localhost:${apiConstants.port}`,
description: "Локальный сервер для разработки", description: "Локальный сервер для разработки",
}, },
]; ];
await RedocModule.setup("api-docs", app, swaggerDocument, {});
SwaggerModule.setup("api-docs", app, swaggerDocument, {});
await app.listen(apiConstants.port); await app.listen(apiConstants.port);
} }

View File

@@ -1,6 +1,6 @@
import { IsBoolean, IsHash, IsNumber } from "class-validator"; import { IsBoolean, IsHash, IsNumber } from "class-validator";
export class CacheStatusDto { export default class CacheStatusDto {
/** /**
* Хеш данных парсера * Хеш данных парсера
* @example "40bd001563085fc35165329ea1ff5c5ecbdbbeef" * @example "40bd001563085fc35165329ea1ff5c5ecbdbbeef"

View File

@@ -1,6 +1,6 @@
import { IsNumber } from "class-validator"; import { IsNumber } from "class-validator";
export class ClearScheduleReplacerDto { export default class ClearReplacerDto {
/** /**
* Количество удалённых заменителей расписания * Количество удалённых заменителей расписания
* @example 1 * @example 1

View File

@@ -1,6 +1,6 @@
import { IsArray } from "class-validator"; import { IsArray } from "class-validator";
export class ScheduleGroupNamesDto { export default class GetGroupNamesDto {
/** /**
* Группы * Группы
* @example ["ИС-214/23", "ИС-213/23"] * @example ["ИС-214/23", "ИС-213/23"]

View File

@@ -1,112 +0,0 @@
import "reflect-metadata";
import { V2LessonType } from "../enum/v2-lesson-type.enum";
import {
IsArray,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { Transform, Type } from "class-transformer";
import { NullIf } from "../../utility/class-validators/conditional-field";
import { LessonTimeDto } from "./lesson-time.dto";
import { LessonSubGroupDto } from "./lesson-sub-group.dto";
export class LessonDto {
/**
* Тип занятия
* @example DEFAULT
*/
@IsEnum(V2LessonType)
@Transform(({ value, options }) => {
if (options?.groups?.includes("v1")) {
switch (value as V2LessonType) {
case V2LessonType.CONSULTATION:
case V2LessonType.INDEPENDENT_WORK:
case V2LessonType.EXAM:
case V2LessonType.EXAM_WITH_GRADE:
case V2LessonType.EXAM_DEFAULT:
return V2LessonType.DEFAULT;
default:
return value;
}
} else if (options?.groups?.includes("v2")) {
switch (value as V2LessonType) {
case V2LessonType.EXAM_DEFAULT:
return V2LessonType.EXAM;
default:
return value;
}
}
return value;
})
type: V2LessonType;
/**
* Индексы пар, если присутствуют
* @example [1, 3]
* @optional
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
@IsOptional()
@NullIf((self: LessonDto) => {
return self.type !== V2LessonType.DEFAULT;
})
defaultRange: Array<number> | null;
/**
* Название занятия
* @example "Элементы высшей математики"
* @optional
*/
@IsString()
@IsOptional()
@NullIf((self: LessonDto) => {
return self.type === V2LessonType.BREAK;
})
@Transform(({ value, obj, options }) => {
if (!value) return value;
if (options?.groups?.includes("v1")) {
switch (obj.type as V2LessonType) {
case V2LessonType.INDEPENDENT_WORK:
return `Самостоятельная | ${value}`;
case V2LessonType.CONSULTATION:
return `Консультация | ${value}`;
case V2LessonType.EXAM:
return `ЗАЧЕТ | ${value}`;
case V2LessonType.EXAM_WITH_GRADE:
return `ЗАЧЕТ С ОЦЕНКОЙ | ${value}`;
case V2LessonType.EXAM_DEFAULT:
return `ЗАЧЕТ С ОЦЕНКОЙ | ${value}`;
default:
return value;
}
}
return value;
})
name: string | null;
/**
* Начало и конец занятия
*/
@Type(() => LessonTimeDto)
time: LessonTimeDto;
/**
* Тип занятия
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => LessonSubGroupDto)
@IsOptional()
@NullIf((self: LessonDto) => {
return self.type !== V2LessonType.DEFAULT;
})
subGroups: Array<LessonSubGroupDto> | null;
}

View File

@@ -1,8 +1,8 @@
import { PickType } from "@nestjs/swagger"; import { PickType } from "@nestjs/swagger";
import { IsNumber } from "class-validator"; import { IsNumber } from "class-validator";
import { SetScheduleReplacerDto } from "./set-schedule-replacer.dto"; import SetScheduleReplacerDto from "./set-schedule-replacer.dto";
export class ScheduleReplacerDto extends PickType(SetScheduleReplacerDto, [ export default class ReplacerDto extends PickType(SetScheduleReplacerDto, [
"etag", "etag",
]) { ]) {
/** /**

View File

@@ -1,6 +1,6 @@
import { IsMongoId, IsObject, IsString } from "class-validator"; import { IsMongoId, IsObject, IsString } from "class-validator";
export class SetScheduleReplacerDto { export default class SetScheduleReplacerDto {
/** /**
* Идентификатор заменителя (ObjectId) * Идентификатор заменителя (ObjectId)
* @example "66e6f1c8775ffeda400d7967" * @example "66e6f1c8775ffeda400d7967"

View File

@@ -1,15 +0,0 @@
import { DayDto } from "./day.dto";
import { IsArray, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { OmitType } from "@nestjs/swagger";
import { TeacherLessonDto } from "./teacher-lesson.dto";
export class TeacherDayDto extends OmitType(DayDto, ["lessons"]) {
/**
* Занятия
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeacherLessonDto)
lessons: Array<TeacherLessonDto>;
}

View File

@@ -1,6 +1,6 @@
import { IsArray } from "class-validator"; import { IsArray } from "class-validator";
export class ScheduleTeacherNamesDto { export default class TeacherNamesDto {
/** /**
* Группы * Группы
* @example ["Хомченко Н.Е."] * @example ["Хомченко Н.Е."]

View File

@@ -1,6 +1,6 @@
import { IsUrl } from "class-validator"; import { IsUrl } from "class-validator";
export class UpdateDownloadUrlDto { export default class UpdateDownloadUrlDto {
/** /**
* Прямая ссылка на скачивание расписания * Прямая ссылка на скачивание расписания
* @example "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls" * @example "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls"

View File

@@ -5,21 +5,16 @@ import {
IsString, IsString,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { Transform, Type } from "class-transformer"; import { Type } from "class-transformer";
import { LessonDto } from "./lesson.dto"; import Lesson from "./lesson.entity";
export class DayDto { // noinspection JSClassNamingConvention
export default class Day {
/** /**
* День недели * День недели
* @example "Понедельник" * @example "Понедельник"
*/ */
@IsString() @IsString()
@Transform(({ value, obj, options }) => {
if ((obj as DayDto).street && options?.groups?.includes("v1"))
return `${value} | ${(obj as DayDto).street}`;
return value;
})
name: string; name: string;
/** /**
@@ -27,11 +22,6 @@ export class DayDto {
* @example "Железнодорожная, 13" * @example "Железнодорожная, 13"
*/ */
@IsString() @IsString()
@Transform(({ value, options }) => {
if (value && options?.groups?.includes("v1")) return undefined;
return value;
})
@IsOptional() @IsOptional()
street?: string; street?: string;
@@ -47,6 +37,6 @@ export class DayDto {
*/ */
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => LessonDto) @Type(() => Lesson)
lessons: Array<LessonDto>; lessons: Array<Lesson>;
} }

View File

@@ -1,16 +1,16 @@
import { PickType } from "@nestjs/swagger"; import { PickType } from "@nestjs/swagger";
import { IsArray, IsObject, ValidateNested } from "class-validator"; import { IsArray, IsObject, ValidateNested } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { ScheduleDto } from "./schedule.dto"; import Schedule from "./schedule.entity";
import { GroupDto } from "./group.dto"; import Group from "./group.entity";
export class GroupScheduleDto extends PickType(ScheduleDto, ["updatedAt"]) { export default class GroupSchedule extends PickType(Schedule, ["updatedAt"]) {
/** /**
* Расписание группы * Расписание группы
*/ */
@IsObject() @IsObject()
@Type(() => GroupDto) @Type(() => Group)
group: GroupDto; group: Group;
/** /**
* Обновлённые дни с последнего изменения расписания * Обновлённые дни с последнего изменения расписания

View File

@@ -1,8 +1,8 @@
import { IsArray, IsString, ValidateNested } from "class-validator"; import { IsArray, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { DayDto } from "./day.dto"; import Day from "./day.entity";
export class GroupDto { export default class Group {
/** /**
* Название группы * Название группы
* @example "ИС-214/23" * @example "ИС-214/23"
@@ -15,6 +15,6 @@ export class GroupDto {
*/ */
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => DayDto) @Type(() => Day)
days: Array<DayDto>; days: Array<Day>;
} }

View File

@@ -1,6 +1,11 @@
import { IsNumber, IsOptional, IsString } from "class-validator"; import { IsNumber, IsOptional, IsString } from "class-validator";
import {
ClassTransformerCtor,
Ctor,
} from "../../utility/class-trasformer/class-transformer-ctor";
export class LessonSubGroupDto { @ClassTransformerCtor()
export default class LessonSubGroup extends Ctor<LessonSubGroup> {
/** /**
* Номер подгруппы * Номер подгруппы
* @example 1 * @example 1

View File

@@ -1,6 +1,11 @@
import { IsDateString } from "class-validator"; import { IsDateString } from "class-validator";
import {
ClassTransformerCtor,
Ctor,
} from "../../utility/class-trasformer/class-transformer-ctor";
export class LessonTimeDto { @ClassTransformerCtor()
export default class LessonTime extends Ctor<LessonTime> {
/** /**
* Начало занятия * Начало занятия
* @example "2024-10-07T04:30:00.000Z" * @example "2024-10-07T04:30:00.000Z"

View File

@@ -0,0 +1,72 @@
import "reflect-metadata";
import { LessonType } from "../enum/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 LessonTime from "./lesson-time.entity";
import LessonSubGroup from "./lesson-sub-group.entity";
import {
ClassTransformerCtor,
Ctor,
} from "../../utility/class-trasformer/class-transformer-ctor";
@ClassTransformerCtor()
export default class Lesson extends Ctor<Lesson> {
/**
* Тип занятия
* @example DEFAULT
*/
@IsEnum(LessonType)
type: LessonType;
/**
* Индексы пар, если присутствуют
* @example [1, 3]
* @optional
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
@IsOptional()
@NullIf((self: Lesson) => {
return self.type !== LessonType.DEFAULT;
})
defaultRange: Array<number> | null;
/**
* Название занятия
* @example "Элементы высшей математики"
* @optional
*/
@IsString()
@IsOptional()
@NullIf((self: Lesson) => {
return self.type === LessonType.BREAK;
})
name: string | null;
/**
* Начало и конец занятия
*/
@Type(() => LessonTime)
time: LessonTime;
/**
* Тип занятия
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => LessonSubGroup)
@IsOptional()
@NullIf((self: Lesson) => {
return self.type !== LessonType.DEFAULT;
})
subGroups: Array<LessonSubGroup> | null;
}

View File

@@ -1,9 +1,9 @@
import { IsArray, IsDate, ValidateNested } from "class-validator"; import { IsArray, IsDate, ValidateNested } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { GroupDto } from "./group.dto"; import Group from "./group.entity";
import { ToMap } from "create-map-transform-fn"; import { ToMap } from "create-map-transform-fn";
export class ScheduleDto { export default class Schedule {
/** /**
* Дата когда последний раз расписание было скачано с сервера политехникума * Дата когда последний раз расписание было скачано с сервера политехникума
* @example "2024-10-18T21:50:06.680Z" * @example "2024-10-18T21:50:06.680Z"
@@ -14,8 +14,8 @@ export class ScheduleDto {
/** /**
* Расписание групп * Расписание групп
*/ */
@ToMap({ mapValueClass: GroupDto }) @ToMap({ mapValueClass: Group })
groups: Map<string, GroupDto>; groups: Map<string, Group>;
/** /**
* Обновлённые дни с последнего изменения расписания * Обновлённые дни с последнего изменения расписания

View File

@@ -0,0 +1,46 @@
import {
IsArray,
IsDateString,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import TeacherLesson from "./teacher-lesson.entity";
import {
ClassTransformerCtor,
Ctor,
} from "../../utility/class-trasformer/class-transformer-ctor";
@ClassTransformerCtor()
export default class TeacherDay extends Ctor<TeacherDay> {
/**
* День недели
* @example "Понедельник"
*/
@IsString()
name: string;
/**
* Улица (v2)
* @example "Железнодорожная, 13"
*/
@IsString()
@IsOptional()
street?: string;
/**
* Дата
* @example "2024-10-06T20:00:00.000Z"
*/
@IsDateString()
date: Date;
/**
* Занятия
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeacherLesson)
lessons: Array<TeacherLesson>;
}

View File

@@ -1,9 +1,9 @@
import { LessonDto } from "./lesson.dto"; import Lesson from "./lesson.entity";
import { IsOptional, IsString } from "class-validator"; import { IsOptional, IsString } from "class-validator";
import { NullIf } from "../../utility/class-validators/conditional-field"; import { NullIf } from "../../utility/class-validators/conditional-field";
import { V2LessonType } from "../enum/v2-lesson-type.enum"; import { LessonType } from "../enum/lesson-type.enum";
export class TeacherLessonDto extends LessonDto { export default class TeacherLesson extends Lesson {
/** /**
* Название группы * Название группы
* @example "ИС-214/23" * @example "ИС-214/23"
@@ -11,8 +11,8 @@ export class TeacherLessonDto extends LessonDto {
*/ */
@IsString() @IsString()
@IsOptional() @IsOptional()
@NullIf((self: TeacherLessonDto) => { @NullIf((self: TeacherLesson) => {
return self.type === V2LessonType.BREAK; return self.type === LessonType.BREAK;
}) })
group: string | null; group: string | null;
} }

View File

@@ -1,15 +1,15 @@
import { PickType } from "@nestjs/swagger"; import { PickType } from "@nestjs/swagger";
import { ScheduleDto } from "./schedule.dto"; import Schedule from "./schedule.entity";
import { IsArray, IsObject, ValidateNested } from "class-validator"; import { IsArray, IsObject, ValidateNested } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { TeacherDto } from "./teacher.dto"; import Teacher from "./teacher.entity";
export class TeacherScheduleDto extends PickType(ScheduleDto, ["updatedAt"]) { export default class TeacherSchedule extends PickType(Schedule, ["updatedAt"]) {
/** /**
* Расписание преподавателя * Расписание преподавателя
*/ */
@IsObject() @IsObject()
teacher: TeacherDto; teacher: Teacher;
/** /**
* Обновлённые дни с последнего изменения расписания * Обновлённые дни с последнего изменения расписания

View File

@@ -1,8 +1,13 @@
import { IsArray, IsString, ValidateNested } from "class-validator"; import { IsArray, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { TeacherDayDto } from "./teacher-day.dto"; import TeacherDay from "./teacher-day.entity";
import {
ClassTransformerCtor,
Ctor,
} from "../../utility/class-trasformer/class-transformer-ctor";
export class TeacherDto { @ClassTransformerCtor()
export default class Teacher extends Ctor<Teacher> {
/** /**
* ФИО преподавателя * ФИО преподавателя
* @example "Хомченко Н.Е." * @example "Хомченко Н.Е."
@@ -15,6 +20,6 @@ export class TeacherDto {
*/ */
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => TeacherDayDto) @Type(() => TeacherDay)
days: Array<TeacherDayDto>; days: Array<TeacherDay>;
} }

View File

@@ -1,4 +1,4 @@
export enum V2LessonType { export enum LessonType {
DEFAULT = 0, // Обычная DEFAULT = 0, // Обычная
ADDITIONAL, // Допы ADDITIONAL, // Допы
BREAK, // Перемена BREAK, // Перемена

View File

@@ -1,15 +1,15 @@
import { V2ScheduleParser, V2ScheduleParseResult } from "./v2-schedule-parser"; import { ScheduleParser, ScheduleParseResult } from "./schedule-parser";
import { BasicXlsDownloader } from "../xls-downloader/basic-xls-downloader"; import { BasicXlsDownloader } from "../xls-downloader/basic-xls-downloader";
import { DayDto } from "../../dto/day.dto"; import Day from "../../entities/day.entity";
import { GroupDto } from "../../dto/group.dto"; import Group from "../../entities/group.entity";
import instanceToInstance2 from "../../../utility/class-trasformer/instance-to-instance-2"; import instanceToInstance2 from "../../../utility/class-trasformer/instance-to-instance-2";
describe("V2ScheduleParser", () => { describe("ScheduleParser", () => {
let parser: V2ScheduleParser; let parser: ScheduleParser;
beforeEach(async () => { beforeEach(() => {
const xlsDownloader = new BasicXlsDownloader(); const xlsDownloader = new BasicXlsDownloader();
parser = new V2ScheduleParser(xlsDownloader); parser = new ScheduleParser(xlsDownloader);
}); });
describe("Ошибки", () => { describe("Ошибки", () => {
@@ -32,10 +32,10 @@ describe("V2ScheduleParser", () => {
const schedule = await parser.getSchedule(); const schedule = await parser.getSchedule();
expect(schedule).toBeDefined(); expect(schedule).toBeDefined();
const group: GroupDto | undefined = schedule.groups.get("ИС-214/23"); const group: Group | undefined = schedule.groups.get("ИС-214/23");
expect(group).toBeDefined(); expect(group).toBeDefined();
const monday: DayDto = group.days[0]; const monday: Day = group.days[0];
expect(monday).toBeDefined(); expect(monday).toBeDefined();
const name = monday.name; const name = monday.name;
@@ -63,14 +63,13 @@ describe("V2ScheduleParser", () => {
it("Зачёт с оценкой v2", async () => { it("Зачёт с оценкой v2", async () => {
const schedule = await parser.getSchedule().then((v) => const schedule = await parser.getSchedule().then((v) =>
instanceToInstance2(V2ScheduleParseResult, v, { instanceToInstance2(ScheduleParseResult, v, {
groups: ["v2"], groups: ["v2"],
}), }),
); );
expect(schedule).toBeDefined(); expect(schedule).toBeDefined();
const group: GroupDto | undefined = const group: Group | undefined = schedule.groups.get("ИС-214/23");
schedule.groups.get("ИС-214/23");
expect(group).toBeDefined(); expect(group).toBeDefined();
const day = group.days[0]; const day = group.days[0];

View File

@@ -5,17 +5,17 @@ import { Range, WorkSheet } from "xlsx";
import { toNormalString, trimAll } from "../../../utility/string.util"; import { toNormalString, trimAll } from "../../../utility/string.util";
import { plainToClass, plainToInstance, Type } from "class-transformer"; import { plainToClass, plainToInstance, Type } from "class-transformer";
import * as objectHash from "object-hash"; import * as objectHash from "object-hash";
import { LessonTimeDto } from "../../dto/lesson-time.dto"; import LessonTime from "../../entities/lesson-time.entity";
import { V2LessonType } from "../../enum/v2-lesson-type.enum"; import { LessonType } from "../../enum/lesson-type.enum";
import { LessonSubGroupDto } from "../../dto/lesson-sub-group.dto"; import LessonSubGroup from "../../entities/lesson-sub-group.entity";
import { LessonDto } from "../../dto/lesson.dto"; import Lesson from "../../entities/lesson.entity";
import { DayDto } from "../../dto/day.dto"; import Day from "../../entities/day.entity";
import { GroupDto } from "../../dto/group.dto"; import Group from "../../entities/group.entity";
import * as assert from "node:assert"; import * as assert from "node:assert";
import { ScheduleReplacerService } from "../../schedule-replacer.service"; import { ScheduleReplacerService } from "../../schedule-replacer.service";
import { TeacherDto } from "../../dto/teacher.dto"; import Teacher from "../../entities/teacher.entity";
import { TeacherDayDto } from "../../dto/teacher-day.dto"; import TeacherDay from "../../entities/teacher-day.entity";
import { TeacherLessonDto } from "../../dto/teacher-lesson.dto"; import TeacherLesson from "../../entities/teacher-lesson.entity";
import { import {
IsArray, IsArray,
IsDate, IsDate,
@@ -24,6 +24,7 @@ import {
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { ToMap } from "create-map-transform-fn"; import { ToMap } from "create-map-transform-fn";
import { ClassProperties } from "../../../utility/class-trasformer/class-transformer-ctor";
type InternalId = { type InternalId = {
/** /**
@@ -46,12 +47,12 @@ type InternalTime = {
/** /**
* Временной отрезок * Временной отрезок
*/ */
timeRange: LessonTimeDto; timeRange: LessonTime;
/** /**
* Тип пары на этой строке * Тип пары на этой строке
*/ */
lessonType: V2LessonType; lessonType: LessonType;
/** /**
* Индекс пары на этой строке * Индекс пары на этой строке
@@ -64,7 +65,7 @@ type InternalTime = {
xlsxRange: Range; xlsxRange: Range;
}; };
export class V2ScheduleParseResult { export class ScheduleParseResult {
/** /**
* ETag расписания * ETag расписания
*/ */
@@ -94,15 +95,15 @@ export class V2ScheduleParseResult {
* Расписание групп в виде списка. * Расписание групп в виде списка.
* Ключ - название группы. * Ключ - название группы.
*/ */
@ToMap({ mapValueClass: GroupDto }) @ToMap({ mapValueClass: Group })
groups: Map<string, GroupDto>; groups: Map<string, Group>;
/** /**
* Расписание преподавателей в виде списка. * Расписание преподавателей в виде списка.
* Ключ - ФИО преподавателя * Ключ - ФИО преподавателя
*/ */
@ToMap({ mapValueClass: TeacherDto }) @ToMap({ mapValueClass: Teacher })
teachers: Map<string, TeacherDto>; teachers: Map<string, Teacher>;
/** /**
* Список групп у которых было обновлено расписание с момента последнего обновления файла. * Список групп у которых было обновлено расписание с момента последнего обновления файла.
@@ -126,8 +127,8 @@ export class V2ScheduleParseResult {
updatedTeachers: Array<Array<number>>; updatedTeachers: Array<Array<number>>;
} }
export class V2ScheduleParser { export class ScheduleParser {
private lastResult: V2ScheduleParseResult | null = null; private lastResult: ScheduleParseResult | null = null;
/** /**
* @param xlsDownloader - класс для загрузки расписания с сайта политехникума * @param xlsDownloader - класс для загрузки расписания с сайта политехникума
@@ -176,113 +177,94 @@ export class V2ScheduleParser {
row: number, row: number,
column: number, column: number,
): string | null { ): string | null {
const cell: XLSX.CellObject | null = const cell = worksheet[
worksheet[XLSX.utils.encode_cell({ r: row, c: column })]; XLSX.utils.encode_cell({ r: row, c: column })
] as XLSX.CellObject;
return toNormalString(cell?.w); return toNormalString(cell?.w);
} }
/** /**
* Парсит информацию о паре исходя из текста в записи * Парсит информацию о паре исходя из текста в записи
* @param lessonName - текст в записи * @param text - текст в записи
* @returns {{ * @returns {{
* name: string; * name: string;
* subGroups: Array<LessonSubGroupDto>; * subGroups: Array<LessonSubGroup>;
* }} - название пары и список подгрупп * }} - название пары и список подгрупп
* @private * @private
* @static * @static
*/ */
private static parseNameAndSubGroups(lessonName: string): { private static parseNameAndSubGroups(text: string): {
name: string; name: string;
subGroups: Array<LessonSubGroupDto>; subGroups: Array<LessonSubGroup>;
} { } {
if (!text) return { name: text, subGroups: [] };
// хд // хд
const lessonRegExp = /(?:[А-Я][а-я]+[А-Я]{2}(?:\([0-9][а-я]+\))?)+$/m;
const teacherRegExp =
/([А-Я][а-я]+)([А-Я])([А-Я])(?:\(([0-9])[а-я]+\))?/g;
const allRegex = const rawTeachers = (text
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\(\s?[0-9]\s?подгруппа\s?\))?(?:,\s)?)+$/gm; .replaceAll(/[\s\n\t.,]+/g, "")
const teacherAndSubGroupRegex = .match(lessonRegExp) ?? [])[0];
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\(\s?[0-9]\s?подгруппа\s?\))?)+/gm;
const allMatch = allRegex.exec(lessonName); // если не ничего не найдено
if (!rawTeachers)
return {
name: text
.replaceAll(/[\t\n]+/g, "") // Убираем все переносы
.replaceAll(/\s+/g, " ") // Убираем все лишние пробелы
.trim() // Убираем пробелы по краям
.replace(/\.$/m, ""), // Убираем точку в конце названия, если присутствует
subGroups: [],
};
// если не ничё не найдено const teacherIt = rawTeachers.matchAll(teacherRegExp);
if (allMatch === null) return { name: lessonName, subGroups: [] };
const all: Array<string> = []; const subGroups: Array<LessonSubGroup> = [];
let lessonName: string;
let allInnerMatch: RegExpExecArray; let m: RegExpMatchArray;
while ( while ((m = teacherIt.next().value as RegExpMatchArray)) {
(allInnerMatch = teacherAndSubGroupRegex.exec(allMatch[0])) !== null if (!lessonName) {
) { lessonName = text
if (allInnerMatch.index === teacherAndSubGroupRegex.lastIndex) .substring(0, text.indexOf(m[1]))
teacherAndSubGroupRegex.lastIndex++; .replaceAll(/[\t\n]+/g, "") // Убираем все переносы
.replaceAll(/\s+/g, " ") // Убираем все лишние пробелы
all.push(allInnerMatch[0].trim()); .trim() // Убираем пробелы по краям
.replace(/\.$/m, ""); // Убираем точку в конце названия, если присутствует
} }
// парадокс
if (all.length === 0) {
throw new Error("Парадокс");
}
const subGroups: Array<LessonSubGroupDto> = [];
for (const teacherAndSubGroup of all) {
const teacherRegex = /[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\./g;
const subGroupRegex = /\([0-9]подгруппа\)/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.replaceAll(" ", ""),
);
const subGroup = subGroupMatch
? Number.parseInt(subGroupMatch[0][1])
: 1;
subGroups.push( subGroups.push(
plainToClass(LessonSubGroupDto, { new LessonSubGroup({
teacher: teacherFIO, number: +(m[4] ?? "0"),
number: subGroup, cabinet: null,
cabinet: "", teacher: `${m[1]} ${m[2]}.${m[3]}.`,
}), }),
); );
} }
for (const index in subGroups) { // фикс, если у кого-то отсутствует индекс подгруппы
if (subGroups.length === 1) {
break;
}
// бляздец // если 1 преподаватель
switch (index) { if (subGroups.length === 1) subGroups[0].number = 1;
case "0": else if (subGroups.length === 2) {
subGroups[index].number = // если индексы отсутствуют у обоих, ставим поочерёдно
subGroups[+index + 1].number === 2 ? 1 : 2; if (subGroups[0].number === 0 && subGroups[1].number === 0) {
continue; subGroups[0].number = 1;
case "1": subGroups[1].number = 2;
subGroups[index].number =
subGroups[+index - 1].number === 1 ? 2 : 1;
continue;
default:
subGroups[index].number = +index;
} }
// если индекс отсутствует у первого, ставим 2, если у второго индекс 1 и наоборот
else if (subGroups[0].number === 0)
subGroups[0].number = subGroups[1].number === 1 ? 2 : 1;
// если индекс отсутствует у второго, ставим 2, если у первого индекс 1 и наоборот
else if (subGroups[1].number === 0)
subGroups[1].number = subGroups[0].number === 1 ? 2 : 1;
} }
return { return {
name: lessonName name: lessonName,
.substring(0, allMatch.index)
.replaceAll(".", "")
.trim(),
subGroups: subGroups, subGroups: subGroups,
}; };
} }
@@ -308,7 +290,7 @@ export class V2ScheduleParser {
const days: Array<InternalId> = []; const days: Array<InternalId> = [];
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 = V2ScheduleParser.getCellData(workSheet, row, 0); const dayName = ScheduleParser.getCellData(workSheet, row, 0);
if (!dayName) continue; if (!dayName) continue;
if (!isHeaderParsed) { if (!isHeaderParsed) {
@@ -320,7 +302,7 @@ export class V2ScheduleParser {
column <= range.e.c; column <= range.e.c;
++column ++column
) { ) {
const groupName = V2ScheduleParser.getCellData( const groupName = ScheduleParser.getCellData(
workSheet, workSheet,
row, row,
column, column,
@@ -359,47 +341,45 @@ export class V2ScheduleParser {
} }
private static convertGroupsToTeachers( private static convertGroupsToTeachers(
groups: Map<string, GroupDto>, groups: Map<string, Group>,
): Map<string, TeacherDto> { ): Map<string, Teacher> {
const result = new Map<string, TeacherDto>(); const result = new Map<string, Teacher>();
for (const groupName of groups.keys()) { for (const groupName of groups.keys()) {
const group = groups.get(groupName); const group = groups.get(groupName);
for (const day of group.days) { for (const day of group.days) {
for (const lesson of day.lessons) { for (const lesson of day.lessons) {
if (lesson.type !== V2LessonType.DEFAULT) continue; if (lesson.type !== LessonType.DEFAULT) continue;
for (const subGroup of lesson.subGroups) { for (const subGroup of lesson.subGroups) {
let teacherDto: TeacherDto = result.get( let teacherDto: Teacher = result.get(subGroup.teacher);
subGroup.teacher,
);
if (!teacherDto) { if (!teacherDto) {
teacherDto = new TeacherDto(); teacherDto = new Teacher({
result.set(subGroup.teacher, teacherDto); name: subGroup.teacher,
days: [],
});
teacherDto.name = subGroup.teacher; result.set(subGroup.teacher, teacherDto);
teacherDto.days = [];
} }
let teacherDay: TeacherDayDto = let teacherDay = teacherDto.days[
teacherDto.days[day.name]; day.name
] as TeacherDay;
if (!teacherDay) { if (!teacherDay) {
teacherDay = teacherDto.days[day.name] = teacherDay = teacherDto.days[day.name] =
new TeacherDayDto(); new TeacherDay({
name: day.name,
// TODO: Что это блять такое? date: day.date,
// noinspection JSConstantReassignment lessons: [],
teacherDay.name = day.name; });
teacherDay.date = day.date;
teacherDay.lessons = [];
} }
const teacherLesson = structuredClone( const teacherLesson = structuredClone(
lesson, lesson,
) as TeacherLessonDto; ) as TeacherLesson;
teacherLesson.group = groupName; teacherLesson.group = groupName;
teacherDay.lessons.push(teacherLesson); teacherDay.lessons.push(teacherLesson);
@@ -413,8 +393,11 @@ export class V2ScheduleParser {
const days = teacher.days; const days = teacher.days;
// eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const dayName in days) { for (const dayName in days) {
const day = days[dayName]; const day = days[dayName];
// eslint-disable-next-line @typescript-eslint/no-array-delete
delete days[dayName]; delete days[dayName];
day.lessons.sort( day.lessons.sort(
@@ -432,10 +415,10 @@ export class V2ScheduleParser {
/** /**
* Возвращает текущее расписание * Возвращает текущее расписание
* @returns {V2ScheduleParseResult} - расписание * @returns {ScheduleParseResult} - расписание
* @async * @async
*/ */
async getSchedule(): Promise<V2ScheduleParseResult> { async getSchedule(): Promise<ScheduleParseResult> {
const headData = await this.xlsDownloader.fetch(true); const headData = await this.xlsDownloader.fetch(true);
this.xlsDownloader.verifyFetchResult(headData); this.xlsDownloader.verifyFetchResult(headData);
@@ -467,9 +450,9 @@ export class V2ScheduleParser {
const workSheet = workBook.Sheets[workBook.SheetNames[0]]; const workSheet = workBook.Sheets[workBook.SheetNames[0]];
const { groupSkeletons, daySkeletons } = const { groupSkeletons, daySkeletons } =
V2ScheduleParser.parseSkeleton(workSheet); ScheduleParser.parseSkeleton(workSheet);
const groups = new Map<string, GroupDto>(); const groups = new Map<string, Group>();
const daysTimes: Array<Array<InternalTime>> = []; const daysTimes: Array<Array<InternalTime>> = [];
let daysTimesFilled = false; let daysTimesFilled = false;
@@ -478,13 +461,13 @@ export class V2ScheduleParser {
.e.r; .e.r;
for (const groupSkeleton of groupSkeletons) { for (const groupSkeleton of groupSkeletons) {
const group = new GroupDto(); const group = new Group();
group.name = groupSkeleton.name; group.name = groupSkeleton.name;
group.days = []; group.days = [];
for (let dayIdx = 0; dayIdx < daySkeletons.length; ++dayIdx) { for (let dayIdx = 0; dayIdx < daySkeletons.length; ++dayIdx) {
const daySkeleton = daySkeletons[dayIdx]; const daySkeleton = daySkeletons[dayIdx];
const day = new DayDto(); const day = new Day();
{ {
const daySpaceIndex = daySkeleton.name.indexOf(" "); const daySpaceIndex = daySkeleton.name.indexOf(" ");
day.name = daySkeleton.name.substring(0, daySpaceIndex); day.name = daySkeleton.name.substring(0, daySpaceIndex);
@@ -504,8 +487,8 @@ export class V2ScheduleParser {
? daySkeletons[dayIdx + 1].row ? daySkeletons[dayIdx + 1].row
: saturdayEndRow) - daySkeleton.row; : saturdayEndRow) - daySkeleton.row;
const dayTimes: Array<InternalTime> = daysTimesFilled const dayTimes = daysTimesFilled
? daysTimes[day.name] ? (daysTimes[day.name] as Array<InternalTime>)
: []; : [];
if (!daysTimesFilled) { if (!daysTimesFilled) {
@@ -514,7 +497,7 @@ export class V2ScheduleParser {
row < daySkeleton.row + rowDistance; row < daySkeleton.row + rowDistance;
++row ++row
) { ) {
const time = V2ScheduleParser.getCellData( const time = ScheduleParser.getCellData(
workSheet, workSheet,
row, row,
lessonTimeColumn, lessonTimeColumn,
@@ -524,19 +507,17 @@ export class V2ScheduleParser {
// type // type
const lessonType = time.includes("пара") const lessonType = time.includes("пара")
? V2LessonType.DEFAULT ? LessonType.DEFAULT
: V2LessonType.ADDITIONAL; : LessonType.ADDITIONAL;
const defaultIndex = const defaultIndex =
lessonType === V2LessonType.DEFAULT lessonType === LessonType.DEFAULT ? +time[0] : null;
? +time[0]
: null;
// time // time
const timeRange = new LessonTimeDto(); const timeRange = new LessonTime({
start: new Date(day.date),
timeRange.start = new Date(day.date); end: new Date(day.date),
timeRange.end = new Date(day.date); });
const timeString = time.replaceAll(".", ":"); const timeString = time.replaceAll(".", ":");
const timeRegex = /(\d+:\d+)-(\d+:\d+)/g; const timeRegex = /(\d+:\d+)-(\d+:\d+)/g;
@@ -562,7 +543,7 @@ export class V2ScheduleParser {
lessonType: lessonType, lessonType: lessonType,
defaultIndex: defaultIndex, defaultIndex: defaultIndex,
xlsxRange: V2ScheduleParser.getMergeFromStart( xlsxRange: ScheduleParser.getMergeFromStart(
workSheet, workSheet,
row, row,
lessonTimeColumn, lessonTimeColumn,
@@ -574,7 +555,7 @@ export class V2ScheduleParser {
} }
for (const time of dayTimes) { for (const time of dayTimes) {
const lessonsOrStreet = V2ScheduleParser.parseLesson( const lessonsOrStreet = ScheduleParser.parseLesson(
workSheet, workSheet,
day, day,
dayTimes, dayTimes,
@@ -583,11 +564,11 @@ export class V2ScheduleParser {
); );
if (typeof lessonsOrStreet === "string") { if (typeof lessonsOrStreet === "string") {
day.street = lessonsOrStreet as string; day.street = lessonsOrStreet;
continue; continue;
} }
for (const lesson of lessonsOrStreet as Array<LessonDto>) for (const lesson of lessonsOrStreet)
day.lessons.push(lesson); day.lessons.push(lesson);
} }
@@ -599,12 +580,12 @@ export class V2ScheduleParser {
groups.set(group.name, group); groups.set(group.name, group);
} }
const updatedGroups = V2ScheduleParser.getUpdatedGroups( const updatedGroups = ScheduleParser.getUpdatedGroups(
this.lastResult?.groups, this.lastResult?.groups,
groups, groups,
); );
const teachers = V2ScheduleParser.convertGroupsToTeachers(groups); const teachers = ScheduleParser.convertGroupsToTeachers(groups);
return (this.lastResult = { return (this.lastResult = {
downloadedAt: headData.requestedAt, downloadedAt: headData.requestedAt,
@@ -630,16 +611,16 @@ export class V2ScheduleParser {
private static parseLesson( private static parseLesson(
workSheet: XLSX.Sheet, workSheet: XLSX.Sheet,
day: DayDto, day: Day,
dayTimes: Array<InternalTime>, dayTimes: Array<InternalTime>,
time: InternalTime, time: InternalTime,
column: number, column: number,
): Array<LessonDto> | string { ): Array<Lesson> | string {
const row = time.xlsxRange.s.r; const row = time.xlsxRange.s.r;
// name // name
let rawName = trimAll( let rawName = trimAll(
V2ScheduleParser.getCellData(workSheet, row, column)?.replaceAll( ScheduleParser.getCellData(workSheet, row, column)?.replaceAll(
/[\n\r]/g, /[\n\r]/g,
" ", " ",
) ?? "", ) ?? "",
@@ -647,87 +628,94 @@ export class V2ScheduleParser {
if (rawName.length === 0) return []; if (rawName.length === 0) return [];
const lesson = new LessonDto(); const lessonData = {} as ClassProperties<Lesson>;
if (this.otherStreetRegExp.test(rawName)) return rawName; if (this.otherStreetRegExp.test(rawName)) return rawName;
else if (rawName.includes("ЭКЗАМЕН")) { else if (rawName.includes("ЭКЗАМЕН")) {
lesson.type = V2LessonType.EXAM_DEFAULT; lessonData.type = LessonType.EXAM_DEFAULT;
rawName = trimAll(rawName.replace("ЭКЗАМЕН", "")); rawName = trimAll(rawName.replace("ЭКЗАМЕН", ""));
} else if (rawName.includes("ЗАЧЕТ С ОЦЕНКОЙ")) { } else if (rawName.includes("ЗАЧЕТ С ОЦЕНКОЙ")) {
lesson.type = V2LessonType.EXAM_WITH_GRADE; lessonData.type = LessonType.EXAM_WITH_GRADE;
rawName = trimAll(rawName.replace("ЗАЧЕТ С ОЦЕНКОЙ", "")); rawName = trimAll(rawName.replace("ЗАЧЕТ С ОЦЕНКОЙ", ""));
} else if (rawName.includes("ЗАЧЕТ")) { } else if (rawName.includes("ЗАЧЕТ")) {
lesson.type = V2LessonType.EXAM; lessonData.type = LessonType.EXAM;
rawName = trimAll(rawName.replace("ЗАЧЕТ", "")); rawName = trimAll(rawName.replace("ЗАЧЕТ", ""));
} else if (rawName.includes("(консультация)")) { } else if (rawName.includes("(консультация)")) {
lesson.type = V2LessonType.CONSULTATION; lessonData.type = LessonType.CONSULTATION;
rawName = trimAll(rawName.replace("(консультация)", "")); rawName = trimAll(rawName.replace("(консультация)", ""));
} else if (this.consultationRegExp.test(rawName)) { } else if (this.consultationRegExp.test(rawName)) {
lesson.type = V2LessonType.CONSULTATION; lessonData.type = LessonType.CONSULTATION;
rawName = trimAll(rawName.replace(this.consultationRegExp, "")); rawName = trimAll(rawName.replace(this.consultationRegExp, ""));
} else if (rawName.includes("САМОСТОЯТЕЛЬНАЯ РАБОТА")) { } else if (rawName.includes("САМОСТОЯТЕЛЬНАЯ РАБОТА")) {
lesson.type = V2LessonType.INDEPENDENT_WORK; lessonData.type = LessonType.INDEPENDENT_WORK;
rawName = trimAll(rawName.replace("САМОСТОЯТЕЛЬНАЯ РАБОТА", "")); rawName = trimAll(rawName.replace("САМОСТОЯТЕЛЬНАЯ РАБОТА", ""));
} else lesson.type = time.lessonType; } else lessonData.type = time.lessonType;
lesson.defaultRange = lessonData.defaultRange =
time.defaultIndex !== null time.defaultIndex !== null
? [time.defaultIndex, time.defaultIndex] ? [time.defaultIndex, time.defaultIndex]
: null; : null;
lesson.time = new LessonTimeDto();
lesson.time.start = time.timeRange.start;
// check if multi-lesson // check if multi-lesson
const range = this.getMergeFromStart(workSheet, row, column); const range = this.getMergeFromStart(workSheet, row, column);
const endTime = dayTimes.filter((dayTime) => { const endTime = dayTimes.filter((dayTime) => {
return dayTime.xlsxRange.e.r === range.e.r; return dayTime.xlsxRange.e.r === range.e.r;
})[0]; })[0];
lesson.time.end = endTime?.timeRange.end ?? time.timeRange.end;
if (lesson.defaultRange !== null) lessonData.time = new LessonTime({
lesson.defaultRange[1] = endTime?.defaultIndex ?? time.defaultIndex; start: time.timeRange.start,
end: endTime?.timeRange.end ?? time.timeRange.end,
});
if (lessonData.defaultRange !== null)
lessonData.defaultRange[1] =
endTime?.defaultIndex ?? time.defaultIndex;
// name and subGroups (subGroups unfilled) // name and subGroups (subGroups unfilled)
{ {
const nameAndGroups = V2ScheduleParser.parseNameAndSubGroups( const nameAndGroups = ScheduleParser.parseNameAndSubGroups(
trimAll(rawName?.replaceAll(/[\n\r]/g, "") ?? ""), rawName ?? "",
); );
lesson.name = nameAndGroups.name; lessonData.name = nameAndGroups.name;
lesson.subGroups = nameAndGroups.subGroups; lessonData.subGroups = nameAndGroups.subGroups;
} }
// cabinets // cabinets
{ {
const cabinets = V2ScheduleParser.parseCabinets( const cabinets = ScheduleParser.parseCabinets(
workSheet, workSheet,
row, row,
column + 1, column + 1,
); );
if (cabinets.length === 1) { if (cabinets.length === 1) {
for (const index in lesson.subGroups) // eslint-disable-next-line @typescript-eslint/no-for-in-array
lesson.subGroups[index].cabinet = cabinets[0]; for (const index in lessonData.subGroups)
} else if (cabinets.length === lesson.subGroups.length) { lessonData.subGroups[index].cabinet = cabinets[0];
for (const index in lesson.subGroups) } else if (cabinets.length === lessonData.subGroups.length) {
lesson.subGroups[index].cabinet = cabinets[index]; // eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const index in lessonData.subGroups) {
lessonData.subGroups[index].cabinet =
cabinets[lessonData.subGroups[index].number - 1];
}
} else if (cabinets.length !== 0) { } else if (cabinets.length !== 0) {
if (cabinets.length > lesson.subGroups.length) { if (cabinets.length > lessonData.subGroups.length) {
// eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const index in cabinets) { for (const index in cabinets) {
if (lesson.subGroups[index] === undefined) { if (lessonData.subGroups[index] === undefined) {
lesson.subGroups.push( lessonData.subGroups.push(
plainToInstance(LessonSubGroupDto, { plainToInstance(LessonSubGroup, {
number: +index + 1, number: +index + 1,
teacher: "Ошибка в расписании", teacher: "Ошибка в расписании",
cabinet: cabinets[index], cabinet: cabinets[index],
} as LessonSubGroupDto), } as LessonSubGroup),
); );
continue; continue;
} }
lesson.subGroups[index].cabinet = cabinets[index]; lessonData.subGroups[index].cabinet = cabinets[index];
} }
} else throw new Error("Разное кол-во кабинетов и подгрупп!"); } else throw new Error("Разное кол-во кабинетов и подгрупп!");
} }
@@ -738,20 +726,20 @@ export class V2ScheduleParser {
? null ? null
: day.lessons[day.lessons.length - 1]; : day.lessons[day.lessons.length - 1];
if (!prevLesson) return [lesson]; if (!prevLesson) return [lessonData];
return [ return [
plainToInstance(LessonDto, { new Lesson({
type: V2LessonType.BREAK, type: LessonType.BREAK,
defaultRange: null, defaultRange: null,
name: null, name: null,
time: plainToInstance(LessonTimeDto, { time: new LessonTime({
start: prevLesson.time.end, start: prevLesson.time.end,
end: lesson.time.start, end: lessonData.time.start,
} as LessonTimeDto), }),
subGroups: [], subGroups: [],
} as LessonDto), }),
lesson, new Lesson(lessonData),
]; ];
} }
@@ -762,7 +750,7 @@ export class V2ScheduleParser {
) { ) {
const cabinets: Array<string> = []; const cabinets: Array<string> = [];
{ {
const rawCabinets = V2ScheduleParser.getCellData( const rawCabinets = ScheduleParser.getCellData(
workSheet, workSheet,
row, row,
column, column,
@@ -782,8 +770,8 @@ export class V2ScheduleParser {
} }
private static getUpdatedGroups( private static getUpdatedGroups(
cachedGroups: Map<string, GroupDto> | null, cachedGroups: Map<string, Group> | null,
currentGroups: Map<string, GroupDto>, currentGroups: Map<string, Group>,
): Array<Array<number>> { ): Array<Array<number>> {
if (!cachedGroups) return []; if (!cachedGroups) return [];
@@ -795,6 +783,7 @@ export class V2ScheduleParser {
const affectedGroupDays: Array<number> = []; const affectedGroupDays: Array<number> = [];
// eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const dayIdx in currentGroup.days) { for (const dayIdx in currentGroup.days) {
if ( if (
objectHash.sha1(currentGroup.days[dayIdx]) !== objectHash.sha1(currentGroup.days[dayIdx]) !==

View File

@@ -4,7 +4,7 @@ import { XlsDownloaderInterface } from "./xls-downloader.interface";
describe("BasicXlsDownloader", () => { describe("BasicXlsDownloader", () => {
let downloader: XlsDownloaderInterface; let downloader: XlsDownloaderInterface;
beforeEach(async () => { beforeEach(() => {
downloader = new BasicXlsDownloader(); downloader = new BasicXlsDownloader();
}); });

View File

@@ -52,10 +52,10 @@ export class BasicXlsDownloader implements XlsDownloaderInterface {
type HeaderValue = string | undefined; type HeaderValue = string | undefined;
const contentType: HeaderValue = response.headers["content-type"]; const contentType = response.headers["content-type"] as HeaderValue;
const etag: HeaderValue = response.headers["etag"]; const etag = response.headers["etag"] as HeaderValue;
const uploadedAt: HeaderValue = response.headers["last-modified"]; const uploadedAt = response.headers["last-modified"] as HeaderValue;
const requestedAt: HeaderValue = response.headers["date"]; const requestedAt = response.headers["date"] as HeaderValue;
if (!contentType || !etag || !uploadedAt || !requestedAt) { if (!contentType || !etag || !uploadedAt || !requestedAt) {
return { return {
@@ -77,7 +77,7 @@ export class BasicXlsDownloader implements XlsDownloaderInterface {
etag: etag, etag: etag,
uploadedAt: new Date(uploadedAt), uploadedAt: new Date(uploadedAt),
requestedAt: new Date(requestedAt), requestedAt: new Date(requestedAt),
data: head ? undefined : response.data.buffer, data: head ? undefined : (response.data as Buffer).buffer,
}; };
} }

View File

@@ -20,11 +20,11 @@ import {
ApiTags, 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"; import { plainToInstance } from "class-transformer";
import { ScheduleService } from "./schedule.service"; import { ScheduleService } from "./schedule.service";
import UserRole from "../users/user-role.enum";
import ReplacerDto from "./dto/replacer.dto";
import ClearReplacerDto from "./dto/clear-replacer.dto";
@ApiTags("v1/schedule-replacer") @ApiTags("v1/schedule-replacer")
@ApiBearerAuth() @ApiBearerAuth()
@@ -69,13 +69,13 @@ export class ScheduleReplacerController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@AuthRoles([UserRole.ADMIN]) @AuthRoles([UserRole.ADMIN])
@ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях @ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях
async getReplacers(): Promise<ScheduleReplacerDto[]> { async getReplacers(): Promise<ReplacerDto[]> {
return await this.scheduleReplaceService.getAll().then((result) => { return await this.scheduleReplaceService.getAll().then((result) => {
return result.map((replacer) => { return result.map((replacer) => {
return plainToInstance(ScheduleReplacerDto, { return plainToInstance(ReplacerDto, {
etag: replacer.etag, etag: replacer.etag,
size: replacer.data.byteLength, size: replacer.data.byteLength,
} as ScheduleReplacerDto); } as ReplacerDto);
}); });
}); });
} }
@@ -84,13 +84,13 @@ export class ScheduleReplacerController {
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Отчистка прошла успешно", description: "Отчистка прошла успешно",
type: ClearScheduleReplacerDto, type: ClearReplacerDto,
}) })
@Post("clear") @Post("clear")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@AuthRoles([UserRole.ADMIN]) @AuthRoles([UserRole.ADMIN])
@ResultDto(ClearScheduleReplacerDto) @ResultDto(ClearReplacerDto)
async clear(): Promise<ClearScheduleReplacerDto> { async clear(): Promise<ClearReplacerDto> {
const response = { count: await this.scheduleReplaceService.clear() }; const response = { count: await this.scheduleReplaceService.clear() };
await this.scheduleService.refreshCache(); await this.scheduleService.refreshCache();

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { SetScheduleReplacerDto } from "./dto/set-schedule-replacer.dto"; import SetScheduleReplacerDto from "./dto/set-schedule-replacer.dto";
import { plainToInstance } from "class-transformer"; import { plainToInstance } from "class-transformer";
@Injectable() @Injectable()

View File

@@ -1,8 +1,10 @@
import { import {
Body,
Controller, Controller,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
NotAcceptableException,
Param, Param,
Patch, Patch,
UseGuards, UseGuards,
@@ -20,22 +22,22 @@ import { AuthRoles, AuthUnauthorized } from "../auth/auth-role.decorator";
import { UserToken } from "../auth/auth.decorator"; import { UserToken } from "../auth/auth.decorator";
import { UserPipe } from "../auth/auth.pipe"; import { UserPipe } from "../auth/auth.pipe";
import { ScheduleService } from "./schedule.service"; import { ScheduleService } from "./schedule.service";
import { ScheduleDto } from "./dto/schedule.dto";
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager"; import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
import { UserRole } from "../users/user-role.enum"; import Schedule from "./entities/schedule.entity";
import { User } from "../users/entity/user.entity"; import UserRole from "src/users/user-role.enum";
import { CacheStatusDto } from "./dto/cache-status.dto"; import GroupSchedule from "./entities/group-schedule.entity";
import { GroupScheduleDto } from "./dto/group-schedule.dto"; import User from "src/users/entity/user.entity";
import { ScheduleGroupNamesDto } from "./dto/schedule-group-names.dto"; import GroupNamesDto from "./dto/get-group-names.dto";
import { TeacherScheduleDto } from "./dto/teacher-schedule.dto"; import TeacherSchedule from "./entities/teacher-schedule.entity";
import { ScheduleTeacherNamesDto } from "./dto/schedule-teacher-names.dto"; import TeacherNamesDto from "./dto/teacher-names.dto";
import instanceToInstance2 from "../utility/class-trasformer/instance-to-instance-2"; import CacheStatusDto from "./dto/cache-status.dto";
import UpdateDownloadUrlDto from "./dto/update-download-url.dto";
@ApiTags("v2/schedule") @ApiTags("v1/schedule")
@ApiBearerAuth() @ApiBearerAuth()
@Controller({ path: "schedule", version: "2" }) @Controller({ path: "schedule", version: "1" })
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class V2ScheduleController { export class ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {} constructor(private readonly scheduleService: ScheduleService) {}
@ApiOperation({ @ApiOperation({
@@ -45,58 +47,49 @@ export class V2ScheduleController {
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Расписание получено успешно", description: "Расписание получено успешно",
type: ScheduleDto, type: Schedule,
}) })
@ResultDto(ScheduleDto) @ResultDto(Schedule)
@AuthRoles([UserRole.ADMIN]) @AuthRoles([UserRole.ADMIN])
@CacheKey("v2-schedule") @CacheKey("schedule")
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Get() @Get()
async getSchedule(): Promise<ScheduleDto> { async getSchedule(): Promise<Schedule> {
return await this.scheduleService.getSchedule().then((result) => return await this.scheduleService.getSchedule();
instanceToInstance2(ScheduleDto, result, {
groups: ["v1"],
}),
);
} }
@ApiOperation({ summary: "Получение расписания группы" }) @ApiOperation({ summary: "Получение расписания группы" })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Расписание получено успешно", description: "Расписание получено успешно",
type: GroupScheduleDto, type: GroupSchedule,
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.NOT_FOUND, status: HttpStatus.NOT_FOUND,
description: "Требуемая группа не найдена", description: "Требуемая группа не найдена",
}) })
@ResultDto(GroupScheduleDto) @ResultDto(null)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Get("group") @Get("group")
async getGroupSchedule( async getGroupSchedule(
@UserToken(UserPipe) user: User, @UserToken(UserPipe) user: User,
): Promise<GroupScheduleDto> { ): Promise<GroupSchedule> {
return await this.scheduleService.getGroup(user.group).then((result) => return await this.scheduleService.getGroup(user.group);
instanceToInstance2(GroupScheduleDto, result, {
groups: ["v1"],
}),
);
} }
@ApiOperation({ summary: "Получение списка названий групп" }) @ApiOperation({ summary: "Получение списка названий групп" })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Список получен успешно", description: "Список получен успешно",
type: ScheduleGroupNamesDto, type: GroupNamesDto,
}) })
@ResultDto(ScheduleGroupNamesDto) @ResultDto(null)
@CacheKey("v2-schedule-group-names") @CacheKey("schedule-group-names")
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@AuthUnauthorized() @AuthUnauthorized()
@HttpCode(HttpStatus.OK)
@Get("group-names") @Get("group-names")
async getGroupNames(): Promise<ScheduleGroupNamesDto> { async getGroupNames(): Promise<GroupNamesDto> {
return await this.scheduleService.getGroupNames(); return await this.scheduleService.getGroupNames();
} }
@@ -104,38 +97,40 @@ export class V2ScheduleController {
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Расписание получено успешно", description: "Расписание получено успешно",
type: TeacherScheduleDto, type: TeacherSchedule,
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.NOT_FOUND, status: HttpStatus.NOT_FOUND,
description: "Требуемый преподаватель не найден", description: "Требуемый преподаватель не найден",
}) })
@ResultDto(TeacherScheduleDto) @ResultDto(null)
@HttpCode(HttpStatus.OK)
@Get("teacher/:name") @Get("teacher/:name")
async getTeacherSchedule( async getTeacherSchedule(
@Param("name") name: string, @Param("name") name: string,
): Promise<TeacherScheduleDto> { @UserToken(UserPipe) user: User,
return await this.scheduleService.getTeacher(name).then((result) => ): Promise<TeacherSchedule> {
instanceToInstance2(TeacherScheduleDto, result, { if (name === "self") {
groups: ["v1"], if (user.role === UserRole.STUDENT)
}), throw new NotAcceptableException();
);
return await this.scheduleService.getTeacher(user.username);
}
return await this.scheduleService.getTeacher(name);
} }
@ApiOperation({ summary: "Получение списка ФИО преподавателей" }) @ApiOperation({ summary: "Получение списка ФИО преподавателей" })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Список получен успешно", description: "Список получен успешно",
type: ScheduleTeacherNamesDto, type: TeacherNamesDto,
}) })
@ResultDto(ScheduleTeacherNamesDto) @ResultDto(null)
@CacheKey("v2-schedule-teacher-names") @CacheKey("schedule-teacher-names")
@UseInterceptors(CacheInterceptor) @UseInterceptors(CacheInterceptor)
@AuthUnauthorized() @AuthUnauthorized()
@HttpCode(HttpStatus.OK)
@Get("teacher-names") @Get("teacher-names")
async getTeacherNames(): Promise<ScheduleTeacherNamesDto> { async getTeacherNames(): Promise<TeacherNamesDto> {
return await this.scheduleService.getTeacherNames(); return await this.scheduleService.getTeacherNames();
} }
@@ -152,8 +147,10 @@ export class V2ScheduleController {
@ResultDto(CacheStatusDto) @ResultDto(CacheStatusDto)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Patch("update-download-url") @Patch("update-download-url")
async updateDownloadUrl(): Promise<CacheStatusDto> { async updateDownloadUrl(
return this.scheduleService.getCacheStatus(); @Body() reqDto: UpdateDownloadUrlDto,
): Promise<CacheStatusDto> {
return await this.scheduleService.updateDownloadUrl(reqDto.url);
} }
@ApiOperation({ @ApiOperation({
@@ -166,7 +163,6 @@ export class V2ScheduleController {
type: CacheStatusDto, type: CacheStatusDto,
}) })
@ResultDto(CacheStatusDto) @ResultDto(CacheStatusDto)
@HttpCode(HttpStatus.OK)
@Get("cache-status") @Get("cache-status")
getCacheStatus(): CacheStatusDto { getCacheStatus(): CacheStatusDto {
return this.scheduleService.getCacheStatus(); return this.scheduleService.getCacheStatus();

View File

@@ -5,19 +5,12 @@ 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 { ScheduleService } from "./schedule.service"; import { ScheduleService } from "./schedule.service";
import { V2ScheduleController } from "./v2-schedule.controller"; import { ScheduleController } from "./schedule.controller";
import { V3ScheduleController } from "./v3-schedule.controller";
import { V4ScheduleController } from "./v4-schedule.controller";
@Module({ @Module({
imports: [forwardRef(() => UsersModule), FirebaseAdminModule], imports: [forwardRef(() => UsersModule), FirebaseAdminModule],
providers: [PrismaService, ScheduleService, ScheduleReplacerService], providers: [PrismaService, ScheduleService, ScheduleReplacerService],
controllers: [ controllers: [ScheduleController, ScheduleReplacerController],
V2ScheduleController,
V3ScheduleController,
V4ScheduleController,
ScheduleReplacerController,
],
exports: [ScheduleService], exports: [ScheduleService],
}) })
export class ScheduleModule {} export class ScheduleModule {}

View File

@@ -5,21 +5,21 @@ import { plainToInstance } from "class-transformer";
import { ScheduleReplacerService } from "./schedule-replacer.service"; import { ScheduleReplacerService } from "./schedule-replacer.service";
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service"; import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
import { scheduleConstants } from "../contants"; import { scheduleConstants } from "../contants";
import { ScheduleDto } from "./dto/schedule.dto";
import { import {
V2ScheduleParser, ScheduleParser,
V2ScheduleParseResult, ScheduleParseResult,
} from "./internal/schedule-parser/v2-schedule-parser"; } from "./internal/schedule-parser/schedule-parser";
import * as objectHash from "object-hash"; import * as objectHash from "object-hash";
import { CacheStatusDto } from "./dto/cache-status.dto"; import CacheStatusDto from "./dto/cache-status.dto";
import { GroupScheduleDto } from "./dto/group-schedule.dto"; import Schedule from "./entities/schedule.entity";
import { ScheduleGroupNamesDto } from "./dto/schedule-group-names.dto"; import GroupSchedule from "./entities/group-schedule.entity";
import { TeacherScheduleDto } from "./dto/teacher-schedule.dto"; import TeacherSchedule from "./entities/teacher-schedule.entity";
import { ScheduleTeacherNamesDto } from "./dto/schedule-teacher-names.dto"; import GetGroupNamesDto from "./dto/get-group-names.dto";
import TeacherNamesDto from "./dto/teacher-names.dto";
@Injectable() @Injectable()
export class ScheduleService { export class ScheduleService {
readonly scheduleParser: V2ScheduleParser; readonly scheduleParser: ScheduleParser;
private cacheUpdatedAt: Date = new Date(0); private cacheUpdatedAt: Date = new Date(0);
private cacheHash: string = "0000000000000000000000000000000000000000"; private cacheHash: string = "0000000000000000000000000000000000000000";
@@ -31,11 +31,12 @@ export class ScheduleService {
private readonly scheduleReplacerService: ScheduleReplacerService, private readonly scheduleReplacerService: ScheduleReplacerService,
private readonly firebaseAdminService: FirebaseAdminService, private readonly firebaseAdminService: FirebaseAdminService,
) { ) {
setInterval(async () => { setInterval(() => {
const now = new Date(); const now = new Date();
if (now.getHours() != 7 || now.getMinutes() != 30) return; if (now.getHours() != 7 || now.getMinutes() != 30) return;
await this.firebaseAdminService.sendByTopic("common", { this.firebaseAdminService
.sendByTopic("common", {
android: { android: {
priority: "high", priority: "high",
ttl: 60 * 60 * 1000, ttl: 60 * 60 * 1000,
@@ -43,10 +44,11 @@ export class ScheduleService {
data: { data: {
type: "lessons-start", type: "lessons-start",
}, },
}); })
.then();
}, 60000); }, 60000);
this.scheduleParser = new V2ScheduleParser( this.scheduleParser = new ScheduleParser(
new BasicXlsDownloader(), new BasicXlsDownloader(),
this.scheduleReplacerService, this.scheduleReplacerService,
); );
@@ -63,7 +65,7 @@ export class ScheduleService {
}); });
} }
async getSourceSchedule(): Promise<V2ScheduleParseResult> { async getSourceSchedule(): Promise<ScheduleParseResult> {
const schedule = await this.scheduleParser.getSchedule(); const schedule = await this.scheduleParser.getSchedule();
this.cacheUpdatedAt = new Date(); this.cacheUpdatedAt = new Date();
@@ -91,7 +93,7 @@ export class ScheduleService {
return schedule; return schedule;
} }
async getSchedule(): Promise<ScheduleDto> { async getSchedule(): Promise<Schedule> {
const sourceSchedule = await this.getSourceSchedule(); const sourceSchedule = await this.getSourceSchedule();
return { return {
@@ -101,7 +103,7 @@ export class ScheduleService {
}; };
} }
async getGroup(name: string): Promise<GroupScheduleDto> { async getGroup(name: string): Promise<GroupSchedule> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
const group = schedule.groups.get(name); const group = schedule.groups.get(name);
@@ -114,22 +116,22 @@ export class ScheduleService {
return { return {
updatedAt: this.cacheUpdatedAt, updatedAt: this.cacheUpdatedAt,
group: group, group: group,
updated: schedule.updatedGroups[name] ?? [], updated: (schedule.updatedGroups[name] as Array<number>) ?? [],
}; };
} }
async getGroupNames(): Promise<ScheduleGroupNamesDto> { async getGroupNames(): Promise<GetGroupNamesDto> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
const names: Array<string> = []; const names: Array<string> = [];
for (const name of schedule.groups.keys()) names.push(name); for (const name of schedule.groups.keys()) names.push(name);
return plainToInstance(ScheduleGroupNamesDto, { return plainToInstance(GetGroupNamesDto, {
names: names, names: names,
}); });
} }
async getTeacher(name: string): Promise<TeacherScheduleDto> { async getTeacher(name: string): Promise<TeacherSchedule> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
const teacher = schedule.teachers.get(name); const teacher = schedule.teachers.get(name);
@@ -142,17 +144,20 @@ export class ScheduleService {
return { return {
updatedAt: this.cacheUpdatedAt, updatedAt: this.cacheUpdatedAt,
teacher: teacher, teacher: teacher,
updated: schedule.updatedGroups[name] ?? [], updated: (schedule.updatedGroups[name] as Array<number>) ?? [],
}; };
} }
async getTeacherNames(): Promise<ScheduleTeacherNamesDto> { async getTeacherNames(): Promise<TeacherNamesDto> {
const schedule = await this.getSourceSchedule(); const schedule = await this.getSourceSchedule();
const names: Array<string> = []; const names: Array<string> = [];
for (const name of schedule.teachers.keys()) names.push(name); for (const name of schedule.teachers.keys()) {
if (name === "Ошибка в расписании") continue;
names.push(name);
}
return plainToInstance(ScheduleTeacherNamesDto, { return plainToInstance(TeacherNamesDto, {
names: names, names: names,
}); });
} }
@@ -166,7 +171,7 @@ export class ScheduleService {
} }
async refreshCache() { async refreshCache() {
await this.cacheManager.reset(); await this.cacheManager.clear();
await this.getSourceSchedule(); await this.getSourceSchedule();
} }

View File

@@ -1,105 +0,0 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Param,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard";
import { ResultDto } from "../utility/validation/class-validator.interceptor";
import {
ApiBearerAuth,
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 { ScheduleService } from "./schedule.service";
import { ScheduleDto } from "./dto/schedule.dto";
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
import { UserRole } from "../users/user-role.enum";
import { User } from "../users/entity/user.entity";
import { GroupScheduleDto } from "./dto/group-schedule.dto";
import { TeacherScheduleDto } from "./dto/teacher-schedule.dto";
import instanceToInstance2 from "../utility/class-trasformer/instance-to-instance-2";
@ApiTags("v3/schedule")
@ApiBearerAuth()
@Controller({ path: "schedule", version: "3" })
@UseGuards(AuthGuard)
export class V3ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {}
@ApiOperation({
summary: "Получение расписания",
tags: ["admin"],
})
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: ScheduleDto,
})
@ResultDto(ScheduleDto)
@AuthRoles([UserRole.ADMIN])
@CacheKey("v3-schedule")
@UseInterceptors(CacheInterceptor)
@HttpCode(HttpStatus.OK)
@Get()
async getSchedule(): Promise<ScheduleDto> {
return await this.scheduleService.getSchedule().then((result) =>
instanceToInstance2(ScheduleDto, result, {
groups: ["v2"],
}),
);
}
@ApiOperation({ summary: "Получение расписания группы" })
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: GroupScheduleDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Требуемая группа не найдена",
})
@ResultDto(GroupScheduleDto)
@HttpCode(HttpStatus.OK)
@Get("group")
async getGroupSchedule(
@UserToken(UserPipe) user: User,
): Promise<GroupScheduleDto> {
return await this.scheduleService.getGroup(user.group).then((result) =>
instanceToInstance2(GroupScheduleDto, result, {
groups: ["v2"],
}),
);
}
@ApiOperation({ summary: "Получение расписания преподавателя" })
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: TeacherScheduleDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Требуемый преподаватель не найден",
})
@ResultDto(TeacherScheduleDto)
@HttpCode(HttpStatus.OK)
@Get("teacher/:name")
async getTeacherSchedule(
@Param("name") name: string,
): Promise<TeacherScheduleDto> {
return await this.scheduleService.getTeacher(name).then((result) =>
instanceToInstance2(TeacherScheduleDto, result, {
groups: ["v2"],
}),
);
}
}

View File

@@ -1,128 +0,0 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard";
import { ResultDto } from "../utility/validation/class-validator.interceptor";
import {
ApiBearerAuth,
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 { ScheduleService } from "./schedule.service";
import { ScheduleDto } from "./dto/schedule.dto";
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
import { UserRole } from "../users/user-role.enum";
import { User } from "../users/entity/user.entity";
import { GroupScheduleDto } from "./dto/group-schedule.dto";
import { TeacherScheduleDto } from "./dto/teacher-schedule.dto";
import instanceToInstance2 from "../utility/class-trasformer/instance-to-instance-2";
import { CacheStatusDto } from "./dto/cache-status.dto";
import { UpdateDownloadUrlDto } from "./dto/update-download-url.dto";
@ApiTags("v4/schedule")
@ApiBearerAuth()
@Controller({ path: "schedule", version: "4" })
@UseGuards(AuthGuard)
export class V4ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {}
@ApiOperation({
summary: "Получение расписания",
tags: ["admin"],
})
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: ScheduleDto,
})
@ResultDto(ScheduleDto)
@AuthRoles([UserRole.ADMIN])
@CacheKey("v4-schedule")
@UseInterceptors(CacheInterceptor)
@HttpCode(HttpStatus.OK)
@Get()
async getSchedule(): Promise<ScheduleDto> {
return await this.scheduleService.getSchedule().then((result) =>
instanceToInstance2(ScheduleDto, result, {
groups: ["v3"],
}),
);
}
@ApiOperation({ summary: "Получение расписания группы" })
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: GroupScheduleDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Требуемая группа не найдена",
})
@ResultDto(GroupScheduleDto)
@HttpCode(HttpStatus.OK)
@Get("group")
async getGroupSchedule(
@UserToken(UserPipe) user: User,
): Promise<GroupScheduleDto> {
return await this.scheduleService.getGroup(user.group).then((result) =>
instanceToInstance2(GroupScheduleDto, result, {
groups: ["v3"],
}),
);
}
@ApiOperation({ summary: "Получение расписания преподавателя" })
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: TeacherScheduleDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Требуемый преподаватель не найден",
})
@ResultDto(TeacherScheduleDto)
@HttpCode(HttpStatus.OK)
@Get("teacher/:name")
async getTeacherSchedule(
@Param("name") name: string,
): Promise<TeacherScheduleDto> {
return await this.scheduleService.getTeacher(name).then((result) =>
instanceToInstance2(TeacherScheduleDto, result, {
groups: ["v3"],
}),
);
}
@ApiOperation({ summary: "Обновление основной страницы политехникума" })
@ApiResponse({
status: HttpStatus.OK,
description: "Данные обновлены успешно",
type: CacheStatusDto,
})
@ApiResponse({
status: HttpStatus.NOT_ACCEPTABLE,
description: "Передан некорректный код страницы",
})
@ResultDto(CacheStatusDto)
@HttpCode(HttpStatus.OK)
@Patch("update-download-url")
async updateDownloadUrl(
@Body() reqDto: UpdateDownloadUrlDto,
): Promise<CacheStatusDto> {
return await this.scheduleService.updateDownloadUrl(reqDto.url);
}
}

View File

@@ -1,4 +1,10 @@
import { PickType } from "@nestjs/swagger"; import { IsString } from "class-validator";
import { User } from "../entity/user.entity";
export class ChangeGroupDto extends PickType(User, ["group"]) {} export default class ChangeGroupDto {
/**
* Группа
* @example "ИС-214/23"
*/
@IsString()
group: string;
}

View File

@@ -1,4 +1,12 @@
import { PickType } from "@nestjs/swagger"; import { IsString, MaxLength, MinLength } from "class-validator";
import { User } from "../entity/user.entity";
export class ChangeUsernameDto extends PickType(User, ["username"]) {} export default class ChangeUsernameDto {
/**
* Имя
* @example "n08i40k"
*/
@IsString()
@MinLength(1)
@MaxLength(20)
username: string;
}

70
src/users/dto/user.dto.ts Normal file
View File

@@ -0,0 +1,70 @@
import {
IsEnum,
IsJWT,
IsMongoId,
IsNumber,
IsOptional,
IsString,
MaxLength,
MinLength,
} from "class-validator";
import { Exclude, Expose, plainToInstance } from "class-transformer";
import UserRole from "../user-role.enum";
@Exclude()
export default class UserDto {
/**
* Идентификатор (ObjectId)
* @example "66e1b7e255c5d5f1268cce90"
*/
@Expose()
@IsMongoId()
id: string;
/**
* Имя
* @example "n08i40k"
*/
@Expose()
@IsString()
@MinLength(1)
@MaxLength(20)
username: string;
/**
* Группа
* @example "ИС-214/23"
*/
@Expose()
@IsString()
group: string;
/**
* Роль
* @example STUDENT
*/
@Expose()
@IsEnum(UserRole)
role: UserRole;
/**
* Идентификатор аккаунта VK
* @example "2.0.0"
*/
@Expose()
@IsNumber()
@IsOptional()
vkId?: number;
/**
* Последний токен доступа
* @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..."
*/
@Expose({ groups: ["auth"] })
@IsJWT({ groups: ["auth"] })
accessToken: string;
static fromPlain(plain: object, groups: Array<string> = []): UserDto {
return plainToInstance(UserDto, plain, { groups: groups });
}
}

View File

@@ -1,20 +0,0 @@
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);
}
}

View File

@@ -1,20 +0,0 @@
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);
}
}

View File

@@ -1,6 +1,7 @@
import { IsArray, IsString, ValidateNested } from "class-validator"; import { IsArray, IsString, ValidateNested } from "class-validator";
export class FcmUser { // noinspection JSClassNamingConvention
export default class FCM {
/** /**
* Токен Firebase Cloud Messaging * Токен Firebase Cloud Messaging
* @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..." * @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..."

View File

@@ -2,6 +2,7 @@ import {
IsEnum, IsEnum,
IsJWT, IsJWT,
IsMongoId, IsMongoId,
IsNumber,
IsObject, IsObject,
IsOptional, IsOptional,
IsSemVer, IsSemVer,
@@ -9,12 +10,13 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from "class-validator"; } from "class-validator";
import { Type } from "class-transformer"; import { plainToInstance, Type } from "class-transformer";
import { UserRole } from "../user-role.enum"; import UserRole from "../user-role.enum";
import { FcmUser } from "./fcm-user.entity"; import FCM from "./fcm-user.entity";
import UserDto from "../dto/user.dto";
export class User { export default class User {
/** /**
* Идентификатор (ObjectId) * Идентификатор (ObjectId)
* @example "66e1b7e255c5d5f1268cce90" * @example "66e1b7e255c5d5f1268cce90"
@@ -70,9 +72,9 @@ export class User {
* Данные Firebase Cloud Messaging * Данные Firebase Cloud Messaging
*/ */
@IsObject() @IsObject()
@Type(() => FcmUser) @Type(() => FCM)
@IsOptional() @IsOptional()
fcm?: FcmUser; fcm?: FCM;
/** /**
* Версия установленного приложения * Версия установленного приложения
@@ -80,4 +82,19 @@ export class User {
*/ */
@IsSemVer() @IsSemVer()
version: string; version: string;
/**
* Идентификатор аккаунта VK
* @example "2.0.0"
*/
@IsNumber()
vkId?: number;
static fromPlain(plain: object): User {
return plainToInstance(User, plain);
}
toDto(groups: Array<string> = []): UserDto {
return plainToInstance(UserDto, this, { groups: groups });
}
} }

View File

@@ -1,5 +1,7 @@
export enum UserRole { enum UserRole {
STUDENT = "STUDENT", STUDENT = "STUDENT",
TEACHER = "TEACHER", TEACHER = "TEACHER",
ADMIN = "ADMIN", ADMIN = "ADMIN",
} }
export default UserRole;

View File

@@ -1,16 +1,17 @@
import { import {
Body, Body,
ConflictException,
Controller, Controller,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
NotFoundException,
Post, Post,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard"; import { AuthGuard } from "../auth/auth.guard";
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 { UsersService } from "./users.service"; import { UsersService } from "./users.service";
import { import {
ApiBearerAuth, ApiBearerAuth,
@@ -19,34 +20,34 @@ import {
ApiResponse, ApiResponse,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { ChangeUsernameDto } from "./dto/change-username.dto"; import User from "./entity/user.entity";
import { ChangeGroupDto } from "./dto/change-group.dto"; import ChangeUsernameDto from "./dto/change-username.dto";
import { V1ClientUserDto } from "./dto/v1/v1-client-user.dto"; import UserRole from "./user-role.enum";
import { UserRole } from "./user-role.enum"; import ChangeGroupDto from "./dto/change-group.dto";
import { UserPipe } from "../auth/auth.pipe";
import { ScheduleService } from "../schedule/schedule.service";
import UserDto from "./dto/user.dto";
@ApiTags("v1/users") @ApiTags("v1/users")
@ApiBearerAuth() @ApiBearerAuth()
@Controller({ path: "users", version: "1" }) @Controller({ path: "users", version: "1" })
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class V1UsersController { export class UsersController {
constructor( constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly scheduleService: ScheduleService,
) {} ) {}
@ApiOperation({ summary: "Получение данных о профиле пользователя" }) @ApiOperation({ summary: "Получение данных о профиле пользователя" })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: "Получение профиля прошло успешно", description: "Получение профиля прошло успешно",
type: V1ClientUserDto, type: UserDto,
}) })
@ResultDto(V1ClientUserDto) @ResultDto(UserDto)
@HttpCode(HttpStatus.OK)
@Get("me") @Get("me")
async getMe(@UserToken() token: string): Promise<V1ClientUserDto> { getMe(@UserToken(UserPipe) user: User): UserDto {
return V1ClientUserDto.fromUser( return user.toDto();
await this.authService.decodeUserToken(token),
);
} }
@ApiOperation({ summary: "Смена имени пользователя" }) @ApiOperation({ summary: "Смена имени пользователя" })
@@ -63,17 +64,30 @@ export class V1UsersController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post("change-username") @Post("change-username")
async changeUsername( async changeUsername(
@Body() reqDto: ChangeUsernameDto, @Body() changeUsernameDto: ChangeUsernameDto,
@UserToken() token: string, @UserToken(UserPipe) user: User,
): Promise<void> { ): Promise<void> {
const user = await this.authService.decodeUserToken(token); changeUsernameDto.username =
reqDto.username =
user.role == UserRole.ADMIN user.role == UserRole.ADMIN
? reqDto.username ? changeUsernameDto.username
: reqDto.username.replace(/\s/g, ""); : changeUsernameDto.username.replace(/\s/g, "");
return await this.usersService.changeUsername(user, reqDto); if (user.username === changeUsernameDto.username) return;
if (
await this.usersService.contains({
username: changeUsernameDto.username,
})
) {
throw new ConflictException(
"Пользователь с таким именем уже существует",
);
}
await this.usersService.update({
where: { id: user.id },
data: { username: changeUsernameDto.username },
});
} }
@ApiOperation({ summary: "Смена группы пользователя" }) @ApiOperation({ summary: "Смена группы пользователя" })
@@ -90,11 +104,21 @@ export class V1UsersController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post("change-group") @Post("change-group")
async changeGroup( async changeGroup(
@Body() reqDto: ChangeGroupDto, @Body() changeGroupDto: ChangeGroupDto,
@UserToken() token: string, @UserToken(UserPipe) user: User,
): Promise<void> { ): Promise<void> {
const user = await this.authService.decodeUserToken(token); if (user.group === changeGroupDto.group) return;
return await this.usersService.changeGroup(user, reqDto); const groupNames = await this.scheduleService.getGroupNames();
if (!groupNames.names.includes(changeGroupDto.group)) {
throw new NotFoundException(
"Группа с таким названием не существует",
);
}
await this.usersService.update({
where: { id: user.id },
data: { group: changeGroupDto.group },
});
} }
} }

View File

@@ -1,15 +1,14 @@
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 { 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"; import { UsersController } from "./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: [V1UsersController, V2UsersController], controllers: [UsersController],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -1,90 +1,38 @@
import { import { Injectable } from "@nestjs/common";
ConflictException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} 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 { User } from "./entity/user.entity"; import User from "./entity/user.entity";
import { ChangeUsernameDto } from "./dto/change-username.dto";
import { ChangeGroupDto } from "./dto/change-group.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))
private readonly scheduleService: ScheduleService,
) {}
async findUnique(where: Prisma.UserWhereUniqueInput): Promise<User | null> { async findUnique(where: Prisma.UserWhereUniqueInput): Promise<User> {
return plainToInstance( return User.fromPlain(
User,
await this.prismaService.user.findUnique({ where: where }), await this.prismaService.user.findUnique({ where: where }),
); );
} }
async findOne(where: Prisma.UserWhereInput): Promise<User> {
return User.fromPlain(
await this.prismaService.user.findFirst({ where: where }),
);
}
async update(params: { async update(params: {
where: Prisma.UserWhereUniqueInput; where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput; data: Prisma.UserUpdateInput;
}): Promise<User> { }): Promise<User> {
return plainToInstance( return User.fromPlain(await this.prismaService.user.update(params));
User,
await this.prismaService.user.update(params),
);
} }
async create(data: Prisma.UserCreateInput): Promise<User> { async create(data: Prisma.UserCreateInput): Promise<User> {
return plainToInstance( return User.fromPlain(await this.prismaService.user.create({ data }));
User,
await this.prismaService.user.create({ data }),
);
} }
async contains(where: Prisma.UserWhereUniqueInput): Promise<boolean> { async contains(where: Prisma.UserWhereInput): Promise<boolean> {
return await this.prismaService.user return await this.prismaService.user
.count({ where }) .count({ where })
.then((count) => count > 0); .then((count) => count > 0);
} }
async changeUsername(
user: User,
changeUsernameDto: ChangeUsernameDto,
): Promise<void> {
if (user.username === changeUsernameDto.username) return;
if (await this.contains({ username: changeUsernameDto.username })) {
throw new ConflictException(
"Пользователь с таким именем уже существует",
);
}
await this.update({
where: { id: user.id },
data: { username: changeUsernameDto.username },
});
}
async changeGroup(
user: User,
changeGroupDto: ChangeGroupDto,
): Promise<void> {
if (user.group === changeGroupDto.group) return;
const groupNames = await this.scheduleService.getGroupNames();
if (!groupNames.names.includes(changeGroupDto.group)) {
throw new NotFoundException(
"Группа с таким названием не существует",
);
}
await this.update({
where: { id: user.id },
data: { group: changeGroupDto.group },
});
}
} }

View File

@@ -1,41 +0,0 @@
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),
);
}
}

View File

@@ -0,0 +1,33 @@
import "reflect-metadata";
import { plainToInstance } from "class-transformer";
export type ClassProperties<T> = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
[K in keyof T]: T[K] extends Function ? never : T[K];
};
export class Ctor<T> {
constructor(object: ClassProperties<T>) {
if (object != undefined)
throw new Error(
"You should use ClassTransformerCtor decorator on class to use this feature!",
);
}
}
// noinspection FunctionNamingConventionJS
export function ClassTransformerCtor() {
return function (target: any) {
const newCtor: any = function (object: object) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment
const obj: any = new target();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
Object.assign(obj, plainToInstance(target, object));
return obj;
};
return newCtor;
};
}

View File

@@ -18,17 +18,12 @@ export function NullIf(
constraints: [canBeNull], constraints: [canBeNull],
validator: { validator: {
validate(value: any, args: ValidationArguments) { validate(value: any, args: ValidationArguments) {
const canBeNullFunc: (cls: object) => boolean = const canBeNull = (
args.constraints[0]; args.constraints[0] as (cls: object) => boolean
)(args.object);
const canBeNull = canBeNullFunc(args.object);
const currentValue = value;
// Логика валидации: если одно из полей null, то другое тоже должно быть null // Логика валидации: если одно из полей null, то другое тоже должно быть null
return canBeNull ? value !== null : value === null;
return canBeNull
? currentValue !== null
: currentValue === null;
}, },
defaultMessage(args: ValidationArguments) { defaultMessage(args: ValidationArguments) {
return `${args.property} must be ${args.property === null ? "non-null" : "null"}!`; return `${args.property} must be ${args.property === null ? "non-null" : "null"}!`;

View File

@@ -1,3 +1,4 @@
/* eslint-disable */
import "reflect-metadata"; import "reflect-metadata";
import { import {
@@ -10,7 +11,7 @@ import {
UnprocessableEntityException, UnprocessableEntityException,
} from "@nestjs/common"; } from "@nestjs/common";
import { map, Observable } from "rxjs"; import { map, Observable } from "rxjs";
import { instanceToPlain, plainToInstance } from "class-transformer"; import { instanceToPlain } from "class-transformer";
import { validate, ValidationOptions } from "class-validator"; import { validate, ValidationOptions } from "class-validator";
@Injectable() @Injectable()
@@ -32,13 +33,19 @@ export class ClassValidatorInterceptor implements NestInterceptor {
handler.name, handler.name,
); );
const providedGroups: Array<string> = Reflect.getMetadata(
"design:result-dto-groups",
cls.prototype,
handler.name,
);
const isArrayOfDto = Reflect.getMetadata( const isArrayOfDto = Reflect.getMetadata(
"design:result-dto-array", "design:result-dto-array",
cls.prototype, cls.prototype,
handler.name, handler.name,
); );
if (classDto === null) return returnValue; if (classDto === null) return instanceToPlain(returnValue);
if (classDto === undefined) { if (classDto === undefined) {
console.warn( console.warn(
@@ -47,26 +54,26 @@ export class ClassValidatorInterceptor implements NestInterceptor {
return returnValue; return returnValue;
} }
const groups = [
...providedGroups,
...(this.validatorOptions.groups ?? []),
];
const dtoArray: Array<any> = isArrayOfDto const dtoArray: Array<any> = isArrayOfDto
? classDto ? classDto
: [classDto]; : [classDto];
for (let idx = 0; idx < dtoArray.length; idx++) { for (let idx = 0; idx < dtoArray.length; idx++) {
const returnValueDto = plainToInstance( if (!(returnValue instanceof Object))
dtoArray[idx],
instanceToPlain(returnValue),
);
if (!(returnValueDto instanceof Object))
throw new InternalServerErrorException( throw new InternalServerErrorException(
returnValueDto, returnValue,
"Return value is not object!", "Return value is not object!",
); );
const validationErrors = await validate( const validationErrors = await validate(returnValue, {
returnValueDto, ...this.validatorOptions,
this.validatorOptions, groups: groups,
); });
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
if (idx !== dtoArray.length - 1) continue; if (idx !== dtoArray.length - 1) continue;
@@ -82,6 +89,7 @@ export class ClassValidatorInterceptor implements NestInterceptor {
statusCode: HttpStatus.UNPROCESSABLE_ENTITY, statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
}); });
} }
return returnValue; return returnValue;
} }
}), }),
@@ -90,7 +98,7 @@ export class ClassValidatorInterceptor implements NestInterceptor {
} }
// noinspection FunctionNamingConventionJS // noinspection FunctionNamingConventionJS
export function ResultDto(dtoType: any) { export function ResultDto(dtoType: any, groups: Array<string> = []) {
return (target: NonNullable<unknown>, propertyKey: string | symbol) => { return (target: NonNullable<unknown>, propertyKey: string | symbol) => {
Reflect.defineMetadata( Reflect.defineMetadata(
"design:result-dto", "design:result-dto",
@@ -104,5 +112,11 @@ export function ResultDto(dtoType: any) {
target, target,
propertyKey, propertyKey,
); );
Reflect.defineMetadata(
"design:result-dto-groups",
groups,
target,
propertyKey,
);
}; };
} }

View File

@@ -1,13 +0,0 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const ResponseVersion = createParamDecorator(
(_, context: ExecutionContext) => {
const sourceVersion: string | null = context.switchToHttp().getRequest()
.headers.version;
const parsedVersion = Number.parseInt(sourceVersion);
if (Number.isNaN(parsedVersion) || parsedVersion < 0) return 0;
return parsedVersion;
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"builder": "swc",
"module": "commonjs", "module": "commonjs",
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,