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": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"precommit": "npm run format && npm run lint",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"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",
"precommit": "npm run format && npm run lint"
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.4.5",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.4",
"@nestjs/swagger": "^7.4.2",
"@prisma/client": "^5.19.1",
"axios": "^1.7.7",
"@fastify/static": "^8.0.4",
"@nestjs/cache-manager": "^3.0.0",
"@nestjs/common": "^11.0.5",
"@nestjs/core": "^11.0.5",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.5",
"@nestjs/platform-fastify": "^11.0.5",
"@nestjs/swagger": "^11.0.3",
"@prisma/client": "^6.2.1",
"axios": "^1.7.9",
"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-validator": "^0.14.1",
"cookie": ">=0.7.0",
"cookie": ">=1.0.2",
"create-map-transform-fn": "gist:f65ddd8f17f8c388659aab76890f194b",
"dotenv": "^16.4.5",
"firebase-admin": "^12.6.0",
"jsdom": "^25.0.0",
"mongoose": "^8.6.1",
"nest-redoc": "^1.1.2",
"dotenv": "^16.4.7",
"firebase-admin": "^13.0.2",
"jsdom": "^26.0.0",
"object-hash": "^3.0.0",
"reflect-metadata": "^0.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.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"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@eslint/eslintrc": "3.2.0",
"@eslint/js": "9.18.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/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/jsdom": "^21.1.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.16.5",
"@types/node": "^22.10.9",
"@types/object-hash": "^3.0.6",
"@types/supertest": "^6.0.0",
"@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"cookie": ">=0.7.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.1.0",
"prettier": "^3.0.0",
"prisma": "^5.19.1",
"cookie": ">=1.0.2",
"eslint": "9.18.0",
"eslint-plugin-prettier": "5.2.3",
"fastify": "^5.2.1",
"jest": "^29.7.0",
"prettier": "3.4.2",
"prisma": "^6.2.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
"typescript": "^5.7.3",
"typescript-eslint": "8.21.0"
},
"jest": {
"moduleFileExtensions": [

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,10 @@ import { forwardRef, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { jwtConstants } from "../contants";
import { AuthService } from "./auth.service";
import { V1AuthController } from "./v1-auth.controller";
import { UsersModule } from "../users/users.module";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleModule } from "../schedule/schedule.module";
import { V2AuthController } from "./v2-auth.controller";
import { AuthController } from "./auth.controller";
@Module({
imports: [
@@ -19,7 +18,7 @@ import { V2AuthController } from "./v2-auth.controller";
}),
],
providers: [AuthService, PrismaService],
controllers: [V1AuthController, V2AuthController],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}

View File

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

View File

@@ -1,20 +1,21 @@
import {
ConflictException,
Injectable,
NotAcceptableException,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { UsersService } from "../users/users.service";
import { genSalt, hash } from "bcrypt";
import { Prisma } from "@prisma/client";
import { Types } from "mongoose";
import { UserRole } from "../users/user-role.enum";
import { User } from "../users/entity/user.entity";
import { SignInDto } from "./dto/sign-in.dto";
import { SignUpDto } from "./dto/sign-up.dto";
import { ChangePasswordDto } from "./dto/change-password.dto";
import { compare, genSalt, hash } from "bcrypt";
import UserRole from "../users/user-role.enum";
import User from "../users/entity/user.entity";
import ChangePasswordDto from "./dto/change-password.dto";
import axios from "axios";
import SignInErrorDto, { SignInErrorCode } from "./dto/sign-in-error.dto";
import { SignUpDto, SignUpVKDto } from "./dto/sign-up.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()
export class AuthService {
@@ -51,112 +52,124 @@ export class AuthService {
return user;
}
/**
* Регистрация нового пользователя
* @param signUp - данные нового пользователя
* @returns {User} - пользователь
* @throws {NotAcceptableException} - передана недопустимая роль
* @throws {ConflictException} - пользователь с таким именем уже существует
* @async
*/
async signUp(signUp: SignUpDto): Promise<User> {
const group = signUp.group.replaceAll(" ", "");
const username = signUp.username.trim();
async signUp(signUpDto: SignUpDto): Promise<UserDto | SignUpErrorDto> {
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUpDto.role))
return new SignUpErrorDto(SignUpErrorCode.DISALLOWED_ROLE);
if (![UserRole.STUDENT, UserRole.TEACHER].includes(signUp.role))
throw new NotAcceptableException("Передана неизвестная роль");
if (await this.usersService.contains({ username: signUpDto.username }))
return new SignUpErrorDto(SignUpErrorCode.USERNAME_ALREADY_EXISTS);
if (await this.usersService.contains({ username: username })) {
throw new ConflictException(
"Пользователь с таким именем уже существует!",
const id = ObjectID().toHexString();
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);
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> {
async signIn(signIn: SignInDto): Promise<UserDto | SignInErrorDto> {
const user = await this.usersService.findUnique({
username: signIn.username,
});
if (
!user ||
user.password !== (await hash(signIn.password, user.salt))
) {
throw new UnauthorizedException(
"Некорректное имя пользователя или пароль!",
if (!user || !(await compare(signIn.password, user.password)))
return new SignInErrorDto(SignInErrorCode.INCORRECT_CREDENTIALS);
return UserDto.fromPlain(
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 });
return await this.usersService.update({
return UserDto.fromPlain(
await this.usersService.update({
where: { id: user.id },
data: { accessToken: accessToken },
});
}
/**
* Обновление токена пользователя
* @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(
"Некорректный или недействительный токен!",
}),
["auth"],
);
}
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 - пользователь

View File

@@ -1,6 +1,6 @@
import { IsString } from "class-validator";
export class ChangePasswordDto {
export default class ChangePasswordDto {
/**
* Старый пароль
* @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";
export class SignInResponseDto {
export default class SignInResponseDto {
/**
* Идентификатор (ObjectId)
* @example "66e1b7e255c5d5f1268cce90"

View File

@@ -1,8 +1,15 @@
import { PickType } from "@nestjs/swagger";
import { User } from "../../users/entity/user.entity";
import { IsString } from "class-validator";
import { IsString, MaxLength, MinLength } 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"
@@ -10,3 +17,12 @@ export class SignInDto extends PickType(User, ["username"]) {
@IsString()
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 { 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 { SignInDto } from "./sign-in.dto";
import { User } from "../../users/entity/user.entity";
import {
IsEnum,
IsSemVer,
IsString,
MaxLength,
MinLength,
} from "class-validator";
import UserRole from "../../users/user-role.enum";
export class SignUpDto extends IntersectionType(
SignInDto,
PickType(User, ["role", "group"]),
PartialType(PickType(User, ["version"])),
) {}
export class SignUpDto {
/**
* Имя
* @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 { 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();
export const jwtConstants = {
secret: process.env.JWT_SECRET!,
secret: process.env.JWT_SECRET,
};
export const httpsConstants = {
certPath: process.env.CERT_PEM_PATH!,
keyPath: process.env.KEY_PEM_PATH!,
certPath: process.env.CERT_PEM_PATH,
keyPath: process.env.KEY_PEM_PATH,
};
export const apiConstants = {
port: +(process.env.API_PORT ?? 5050),
version: process.env.SERVER_VERSION!,
version: process.env.SERVER_VERSION,
};
export const firebaseConstants = {
serviceAccountPath: process.env.FIREBASE_ACCOUNT_PATH!,
serviceAccountPath: process.env.FIREBASE_ACCOUNT_PATH,
};
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 { FcmPostUpdateDto } from "./dto/fcm-post-update.dto";
import { isSemVer } from "class-validator";
import { User } from "../users/entity/user.entity";
import User from "../users/entity/user.entity";
import {
ApiBearerAuth,
ApiBody,
@@ -24,7 +24,7 @@ import {
ApiTags,
} from "@nestjs/swagger";
import { AuthRoles } from "../auth/auth-role.decorator";
import { UserRole } from "../users/user-role.enum";
import UserRole from "../users/user-role.enum";
import {
TokenMessage,
TopicMessage,

View File

@@ -12,9 +12,9 @@ import {
import { firebaseConstants } from "../contants";
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 { FcmUser } from "../users/entity/fcm-user.entity";
import FCM from "../users/entity/fcm-user.entity";
import { plainToInstance } from "class-transformer";
@Injectable()
@@ -47,12 +47,12 @@ export class FirebaseAdminService implements OnModuleInit {
await this.messaging.send(message);
}
private getFcmOrDefault(user: User, token: string): FcmUser {
private getFcmOrDefault(user: User, token: string): FCM {
if (!user.fcm) {
return plainToInstance(FcmUser, {
return plainToInstance(FCM, {
token: token,
topics: [],
} as FcmUser);
} as FCM);
}
return user.fcm;
@@ -68,7 +68,7 @@ export class FirebaseAdminService implements OnModuleInit {
if (!isNew) {
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);
fcm.token = token;
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { IsArray } from "class-validator";
export class ScheduleGroupNamesDto {
export default class GetGroupNamesDto {
/**
* Группы
* @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 { 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",
]) {
/**

View File

@@ -1,6 +1,6 @@
import { IsMongoId, IsObject, IsString } from "class-validator";
export class SetScheduleReplacerDto {
export default class SetScheduleReplacerDto {
/**
* Идентификатор заменителя (ObjectId)
* @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";
export class ScheduleTeacherNamesDto {
export default class TeacherNamesDto {
/**
* Группы
* @example ["Хомченко Н.Е."]

View File

@@ -1,6 +1,6 @@
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"

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { IsArray, IsString, ValidateNested } from "class-validator";
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"
@@ -15,6 +15,6 @@ export class GroupDto {
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => DayDto)
days: Array<DayDto>;
@Type(() => Day)
days: Array<Day>;
}

View File

@@ -1,6 +1,11 @@
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

View File

@@ -1,6 +1,11 @@
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"

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 { Type } from "class-transformer";
import { GroupDto } from "./group.dto";
import Group from "./group.entity";
import { ToMap } from "create-map-transform-fn";
export class ScheduleDto {
export default class Schedule {
/**
* Дата когда последний раз расписание было скачано с сервера политехникума
* @example "2024-10-18T21:50:06.680Z"
@@ -14,8 +14,8 @@ export class ScheduleDto {
/**
* Расписание групп
*/
@ToMap({ mapValueClass: GroupDto })
groups: Map<string, GroupDto>;
@ToMap({ mapValueClass: Group })
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 { 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"
@@ -11,8 +11,8 @@ export class TeacherLessonDto extends LessonDto {
*/
@IsString()
@IsOptional()
@NullIf((self: TeacherLessonDto) => {
return self.type === V2LessonType.BREAK;
@NullIf((self: TeacherLesson) => {
return self.type === LessonType.BREAK;
})
group: string | null;
}

View File

@@ -1,15 +1,15 @@
import { PickType } from "@nestjs/swagger";
import { ScheduleDto } from "./schedule.dto";
import Schedule from "./schedule.entity";
import { IsArray, IsObject, ValidateNested } from "class-validator";
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()
teacher: TeacherDto;
teacher: Teacher;
/**
* Обновлённые дни с последнего изменения расписания

View File

@@ -1,8 +1,13 @@
import { IsArray, IsString, ValidateNested } from "class-validator";
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 "Хомченко Н.Е."
@@ -15,6 +20,6 @@ export class TeacherDto {
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeacherDayDto)
days: Array<TeacherDayDto>;
@Type(() => TeacherDay)
days: Array<TeacherDay>;
}

View File

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

View File

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

View File

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

View File

@@ -52,10 +52,10 @@ export class BasicXlsDownloader implements XlsDownloaderInterface {
type HeaderValue = string | undefined;
const contentType: HeaderValue = response.headers["content-type"];
const etag: HeaderValue = response.headers["etag"];
const uploadedAt: HeaderValue = response.headers["last-modified"];
const requestedAt: HeaderValue = response.headers["date"];
const contentType = response.headers["content-type"] as HeaderValue;
const etag = response.headers["etag"] as HeaderValue;
const uploadedAt = response.headers["last-modified"] as HeaderValue;
const requestedAt = response.headers["date"] as HeaderValue;
if (!contentType || !etag || !uploadedAt || !requestedAt) {
return {
@@ -77,7 +77,7 @@ export class BasicXlsDownloader implements XlsDownloaderInterface {
etag: etag,
uploadedAt: new Date(uploadedAt),
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,
} from "@nestjs/swagger";
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 { 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")
@ApiBearerAuth()
@@ -69,13 +69,13 @@ export class ScheduleReplacerController {
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRole.ADMIN])
@ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях
async getReplacers(): Promise<ScheduleReplacerDto[]> {
async getReplacers(): Promise<ReplacerDto[]> {
return await this.scheduleReplaceService.getAll().then((result) => {
return result.map((replacer) => {
return plainToInstance(ScheduleReplacerDto, {
return plainToInstance(ReplacerDto, {
etag: replacer.etag,
size: replacer.data.byteLength,
} as ScheduleReplacerDto);
} as ReplacerDto);
});
});
}
@@ -84,13 +84,13 @@ export class ScheduleReplacerController {
@ApiResponse({
status: HttpStatus.OK,
description: "Отчистка прошла успешно",
type: ClearScheduleReplacerDto,
type: ClearReplacerDto,
})
@Post("clear")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRole.ADMIN])
@ResultDto(ClearScheduleReplacerDto)
async clear(): Promise<ClearScheduleReplacerDto> {
@ResultDto(ClearReplacerDto)
async clear(): Promise<ClearReplacerDto> {
const response = { count: await this.scheduleReplaceService.clear() };
await this.scheduleService.refreshCache();

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
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";
@Injectable()

View File

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

View File

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

View File

@@ -5,21 +5,21 @@ import { plainToInstance } from "class-transformer";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
import { scheduleConstants } from "../contants";
import { ScheduleDto } from "./dto/schedule.dto";
import {
V2ScheduleParser,
V2ScheduleParseResult,
} from "./internal/schedule-parser/v2-schedule-parser";
ScheduleParser,
ScheduleParseResult,
} from "./internal/schedule-parser/schedule-parser";
import * as objectHash from "object-hash";
import { CacheStatusDto } from "./dto/cache-status.dto";
import { GroupScheduleDto } from "./dto/group-schedule.dto";
import { ScheduleGroupNamesDto } from "./dto/schedule-group-names.dto";
import { TeacherScheduleDto } from "./dto/teacher-schedule.dto";
import { ScheduleTeacherNamesDto } from "./dto/schedule-teacher-names.dto";
import CacheStatusDto from "./dto/cache-status.dto";
import Schedule from "./entities/schedule.entity";
import GroupSchedule from "./entities/group-schedule.entity";
import TeacherSchedule from "./entities/teacher-schedule.entity";
import GetGroupNamesDto from "./dto/get-group-names.dto";
import TeacherNamesDto from "./dto/teacher-names.dto";
@Injectable()
export class ScheduleService {
readonly scheduleParser: V2ScheduleParser;
readonly scheduleParser: ScheduleParser;
private cacheUpdatedAt: Date = new Date(0);
private cacheHash: string = "0000000000000000000000000000000000000000";
@@ -31,11 +31,12 @@ export class ScheduleService {
private readonly scheduleReplacerService: ScheduleReplacerService,
private readonly firebaseAdminService: FirebaseAdminService,
) {
setInterval(async () => {
setInterval(() => {
const now = new Date();
if (now.getHours() != 7 || now.getMinutes() != 30) return;
await this.firebaseAdminService.sendByTopic("common", {
this.firebaseAdminService
.sendByTopic("common", {
android: {
priority: "high",
ttl: 60 * 60 * 1000,
@@ -43,10 +44,11 @@ export class ScheduleService {
data: {
type: "lessons-start",
},
});
})
.then();
}, 60000);
this.scheduleParser = new V2ScheduleParser(
this.scheduleParser = new ScheduleParser(
new BasicXlsDownloader(),
this.scheduleReplacerService,
);
@@ -63,7 +65,7 @@ export class ScheduleService {
});
}
async getSourceSchedule(): Promise<V2ScheduleParseResult> {
async getSourceSchedule(): Promise<ScheduleParseResult> {
const schedule = await this.scheduleParser.getSchedule();
this.cacheUpdatedAt = new Date();
@@ -91,7 +93,7 @@ export class ScheduleService {
return schedule;
}
async getSchedule(): Promise<ScheduleDto> {
async getSchedule(): Promise<Schedule> {
const sourceSchedule = await this.getSourceSchedule();
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 group = schedule.groups.get(name);
@@ -114,22 +116,22 @@ export class ScheduleService {
return {
updatedAt: this.cacheUpdatedAt,
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 names: Array<string> = [];
for (const name of schedule.groups.keys()) names.push(name);
return plainToInstance(ScheduleGroupNamesDto, {
return plainToInstance(GetGroupNamesDto, {
names: names,
});
}
async getTeacher(name: string): Promise<TeacherScheduleDto> {
async getTeacher(name: string): Promise<TeacherSchedule> {
const schedule = await this.getSourceSchedule();
const teacher = schedule.teachers.get(name);
@@ -142,17 +144,20 @@ export class ScheduleService {
return {
updatedAt: this.cacheUpdatedAt,
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 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,
});
}
@@ -166,7 +171,7 @@ export class ScheduleService {
}
async refreshCache() {
await this.cacheManager.reset();
await this.cacheManager.clear();
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 { User } from "../entity/user.entity";
import { IsString } from "class-validator";
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 { User } from "../entity/user.entity";
import { IsString, MaxLength, MinLength } from "class-validator";
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";
export class FcmUser {
// noinspection JSClassNamingConvention
export default class FCM {
/**
* Токен Firebase Cloud Messaging
* @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXCJ9..."

View File

@@ -2,6 +2,7 @@ import {
IsEnum,
IsJWT,
IsMongoId,
IsNumber,
IsObject,
IsOptional,
IsSemVer,
@@ -9,12 +10,13 @@ import {
MaxLength,
MinLength,
} from "class-validator";
import { Type } from "class-transformer";
import { UserRole } from "../user-role.enum";
import { plainToInstance, Type } from "class-transformer";
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)
* @example "66e1b7e255c5d5f1268cce90"
@@ -70,9 +72,9 @@ export class User {
* Данные Firebase Cloud Messaging
*/
@IsObject()
@Type(() => FcmUser)
@Type(() => FCM)
@IsOptional()
fcm?: FcmUser;
fcm?: FCM;
/**
* Версия установленного приложения
@@ -80,4 +82,19 @@ export class User {
*/
@IsSemVer()
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",
TEACHER = "TEACHER",
ADMIN = "ADMIN",
}
export default UserRole;

View File

@@ -1,16 +1,17 @@
import {
Body,
ConflictException,
Controller,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Post,
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 { UsersService } from "./users.service";
import {
ApiBearerAuth,
@@ -19,34 +20,34 @@ import {
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { ChangeUsernameDto } from "./dto/change-username.dto";
import { ChangeGroupDto } from "./dto/change-group.dto";
import { V1ClientUserDto } from "./dto/v1/v1-client-user.dto";
import { UserRole } from "./user-role.enum";
import User from "./entity/user.entity";
import ChangeUsernameDto from "./dto/change-username.dto";
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")
@ApiBearerAuth()
@Controller({ path: "users", version: "1" })
@UseGuards(AuthGuard)
export class V1UsersController {
export class UsersController {
constructor(
private readonly authService: AuthService,
private readonly usersService: UsersService,
private readonly scheduleService: ScheduleService,
) {}
@ApiOperation({ summary: "Получение данных о профиле пользователя" })
@ApiResponse({
status: HttpStatus.OK,
description: "Получение профиля прошло успешно",
type: V1ClientUserDto,
type: UserDto,
})
@ResultDto(V1ClientUserDto)
@HttpCode(HttpStatus.OK)
@ResultDto(UserDto)
@Get("me")
async getMe(@UserToken() token: string): Promise<V1ClientUserDto> {
return V1ClientUserDto.fromUser(
await this.authService.decodeUserToken(token),
);
getMe(@UserToken(UserPipe) user: User): UserDto {
return user.toDto();
}
@ApiOperation({ summary: "Смена имени пользователя" })
@@ -63,17 +64,30 @@ export class V1UsersController {
@HttpCode(HttpStatus.OK)
@Post("change-username")
async changeUsername(
@Body() reqDto: ChangeUsernameDto,
@UserToken() token: string,
@Body() changeUsernameDto: ChangeUsernameDto,
@UserToken(UserPipe) user: User,
): Promise<void> {
const user = await this.authService.decodeUserToken(token);
reqDto.username =
changeUsernameDto.username =
user.role == UserRole.ADMIN
? reqDto.username
: reqDto.username.replace(/\s/g, "");
? changeUsernameDto.username
: 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: "Смена группы пользователя" })
@@ -90,11 +104,21 @@ export class V1UsersController {
@HttpCode(HttpStatus.OK)
@Post("change-group")
async changeGroup(
@Body() reqDto: ChangeGroupDto,
@UserToken() token: string,
@Body() changeGroupDto: ChangeGroupDto,
@UserToken(UserPipe) user: User,
): 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 { UsersService } from "./users.service";
import { PrismaService } from "../prisma/prisma.service";
import { V2UsersController } from "./v2-users.controller";
import { ScheduleModule } from "../schedule/schedule.module";
import { AuthModule } from "../auth/auth.module";
import { V1UsersController } from "./v1-users.controller";
import { UsersController } from "./users.controller";
@Module({
imports: [forwardRef(() => ScheduleModule), forwardRef(() => AuthModule)],
providers: [PrismaService, UsersService],
exports: [UsersService],
controllers: [V1UsersController, V2UsersController],
controllers: [UsersController],
})
export class UsersModule {}

View File

@@ -1,90 +1,38 @@
import {
ConflictException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { Prisma } from "@prisma/client";
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";
import User from "./entity/user.entity";
@Injectable()
export class UsersService {
constructor(
private readonly prismaService: PrismaService,
@Inject(forwardRef(() => ScheduleService))
private readonly scheduleService: ScheduleService,
) {}
constructor(private readonly prismaService: PrismaService) {}
async findUnique(where: Prisma.UserWhereUniqueInput): Promise<User | null> {
return plainToInstance(
User,
async findUnique(where: Prisma.UserWhereUniqueInput): Promise<User> {
return User.fromPlain(
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: {
where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput;
}): Promise<User> {
return plainToInstance(
User,
await this.prismaService.user.update(params),
);
return User.fromPlain(await this.prismaService.user.update(params));
}
async create(data: Prisma.UserCreateInput): Promise<User> {
return plainToInstance(
User,
await this.prismaService.user.create({ data }),
);
return User.fromPlain(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
.count({ where })
.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],
validator: {
validate(value: any, args: ValidationArguments) {
const canBeNullFunc: (cls: object) => boolean =
args.constraints[0];
const canBeNull = canBeNullFunc(args.object);
const currentValue = value;
const canBeNull = (
args.constraints[0] as (cls: object) => boolean
)(args.object);
// Логика валидации: если одно из полей null, то другое тоже должно быть null
return canBeNull
? currentValue !== null
: currentValue === null;
return canBeNull ? value !== null : value === null;
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be ${args.property === null ? "non-null" : "null"}!`;

View File

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