diff --git a/.gitignore b/.gitignore index 4b56acf..853eeb4 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# HTTPS cerificates +/cert/ +*.pem \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 962d98d..2a9bde8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "UNLICENSED", "dependencies": { + "@nestjs/cache-manager": "^2.2.2", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", @@ -17,6 +18,7 @@ "@prisma/client": "^5.19.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", + "cache-manager": "^5.7.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", @@ -1626,6 +1628,17 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.2.2.tgz", + "integrity": "sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -3370,6 +3383,25 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -4539,6 +4571,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6780,6 +6817,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7808,6 +7850,14 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "engines": { + "node": ">=16" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index af8bc6f..d0ed697 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/cache-manager": "^2.2.2", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", @@ -28,6 +29,7 @@ "@prisma/client": "^5.19.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", + "cache-manager": "^5.7.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82f793a..dbe2402 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,13 +13,22 @@ datasource db { url = env("DATABASE_URL") } -model user { - id String @id @map("_id") @db.ObjectId +enum UserRole { + STUDENT + TEACHER + ADMIN +} + +model User { + id String @id @map("_id") @db.ObjectId // - username String @unique + username String @unique // salt String password String // - accessToken String @unique + accessToken String @unique + // + group String + role UserRole } diff --git a/src/app.module.ts b/src/app.module.ts index 6c56c00..b418f3e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,9 +2,15 @@ import { Module } from "@nestjs/common"; import { AuthModule } from "./auth/auth.module"; import { UsersModule } from "./users/users.module"; import { ScheduleModule } from "./schedule/schedule.module"; +import { CacheModule } from "@nestjs/cache-manager"; @Module({ - imports: [AuthModule, UsersModule, ScheduleModule], + imports: [ + AuthModule, + UsersModule, + ScheduleModule, + CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }), + ], controllers: [], providers: [], }) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 8ceac71..ff7b33c 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common"; +import { + Body, + Controller, + HttpCode, + HttpStatus, + NotFoundException, + Post, +} from "@nestjs/common"; import { AuthService } from "./auth.service"; import { ApiBody, @@ -12,52 +19,66 @@ import { refs, } from "@nestjs/swagger"; import { - SignInDto, - SignInResultDto, - SignUpDto, - SignUpResultDto, + SignInReqDto, + SignInResDto, + SignUpReqDto, + SignUpResDto, UpdateTokenDto, UpdateTokenResultDto, } from "../dto/auth.dto"; import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { ScheduleService } from "../schedule/schedule.service"; @Controller("api/v1/auth") export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly scheduleService: ScheduleService, + private readonly authService: AuthService, + ) {} - @ApiExtraModels(SignInDto) - @ApiExtraModels(SignInResultDto) + @ApiExtraModels(SignInReqDto) + @ApiExtraModels(SignInResDto) @ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] }) - @ApiBody({ schema: refs(SignInDto)[0] }) + @ApiBody({ schema: refs(SignInReqDto)[0] }) @ApiOkResponse({ description: "Авторизация прошла успешно", - schema: refs(SignInResultDto)[0], + schema: refs(SignInResDto)[0], }) @ApiUnauthorizedResponse({ description: "Некорректное имя пользователя или пароль", }) - @ResultDto(SignInResultDto) + @ResultDto(SignInResDto) @HttpCode(HttpStatus.OK) @Post("signIn") - signIn(@Body() signInDto: SignInDto) { + signIn(@Body() signInDto: SignInReqDto) { return this.authService.signIn(signInDto); } - @ApiExtraModels(SignUpDto) - @ApiExtraModels(SignUpResultDto) + @ApiExtraModels(SignUpReqDto) + @ApiExtraModels(SignUpResDto) @ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] }) - @ApiBody({ schema: refs(SignUpDto)[0] }) + @ApiBody({ schema: refs(SignUpReqDto)[0] }) @ApiCreatedResponse({ description: "Регистрация прошла успешно", - schema: refs(SignUpResultDto)[0], + schema: refs(SignUpResDto)[0], }) @ApiConflictResponse({ description: "Такой пользователь уже существует", }) - @ResultDto(SignUpResultDto) + @ResultDto(SignUpResDto) @HttpCode(HttpStatus.CREATED) @Post("signUp") - signUp(@Body() signUpDto: SignUpDto) { + async signUp(@Body() signUpDto: SignUpReqDto) { + if ( + !(await this.scheduleService.getGroupNames()).names.includes( + signUpDto.group, + ) + ) { + throw new NotFoundException( + "Передано название несуществующей группы", + ); + } + return this.authService.signUp(signUpDto); } diff --git a/src/auth/auth.decorator.ts b/src/auth/auth.decorator.ts index a265ad2..a98c96a 100644 --- a/src/auth/auth.decorator.ts +++ b/src/auth/auth.decorator.ts @@ -3,7 +3,7 @@ import { AuthGuard } from "./auth.guard"; // TODO: Найти применение этой функции // noinspection JSUnusedGlobalSymbols -export const UserId = createParamDecorator((_, context: ExecutionContext) => { +export const UserToken = createParamDecorator((_, context: ExecutionContext) => { return AuthGuard.extractTokenFromRequest( context.switchToHttp().getRequest(), ); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 763c5d7..16ccf74 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthController } from "./auth.controller"; import { UsersModule } from "../users/users.module"; import { UsersService } from "../users/users.service"; import { PrismaService } from "../prisma/prisma.service"; +import { ScheduleService } from "../schedule/schedule.service"; @Module({ imports: [ @@ -16,7 +17,7 @@ import { PrismaService } from "../prisma/prisma.service"; signOptions: { expiresIn: "720h" }, }), ], - providers: [AuthService, UsersService, PrismaService], + providers: [AuthService, UsersService, PrismaService, ScheduleService], controllers: [AuthController], exports: [AuthService], }) diff --git a/src/auth/auth.pipe.ts b/src/auth/auth.pipe.ts index 840a13b..fd72e50 100644 --- a/src/auth/auth.pipe.ts +++ b/src/auth/auth.pipe.ts @@ -4,8 +4,8 @@ import { UnauthorizedException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; -import { user } from "@prisma/client"; import { UsersService } from "../users/users.service"; +import { UserDto } from "../dto/user.dto"; @Injectable() export class UserFromTokenPipe implements PipeTransform { @@ -14,7 +14,7 @@ export class UserFromTokenPipe implements PipeTransform { private readonly usersService: UsersService, ) {} - async transform(token: string): Promise { + async transform(token: string): Promise { const jwtUser: { id: string } = await this.jwtService.decode(token); if (!jwtUser) @@ -24,6 +24,6 @@ export class UserFromTokenPipe implements PipeTransform { if (!user) throw new UnauthorizedException("Передан некорректный токен!"); - return user; + return user as UserDto; } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 6cd65dd..ea7af98 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,22 +1,24 @@ import { ConflictException, Injectable, + NotAcceptableException, NotFoundException, UnauthorizedException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { - SignInDto, - SignInResultDto, - SignUpDto, - SignUpResultDto, + SignInReqDto, + SignInResDto, + SignUpReqDto, + SignUpResDto, UpdateTokenDto, UpdateTokenResultDto, } from "../dto/auth.dto"; import { UsersService } from "../users/users.service"; import { genSalt, hash } from "bcrypt"; -import { Prisma } from "@prisma/client"; +import { Prisma, UserRole } from "@prisma/client"; import { Types } from "mongoose"; +import { UserDto, UserRoleDto } from "../dto/user.dto"; @Injectable() export class AuthService { @@ -25,16 +27,34 @@ export class AuthService { private readonly jwtService: JwtService, ) {} - async signUp(signUpDto: SignUpDto): Promise { - if (await this.usersService.contains({ username: signUpDto.username })) + async decodeUserToken(token: string): Promise { + const jwtUser: { id: string } = + await this.jwtService.verifyAsync(token); + + return this.usersService + .findUnique({ id: jwtUser.id }) + .then((user) => user as UserDto | null); + } + + async signUp(signUpDto: SignUpReqDto): Promise { + if ( + ![UserRoleDto.STUDENT, UserRoleDto.TEACHER].includes(signUpDto.role) + ) { + throw new NotAcceptableException("Передана неизвестная роль"); + } + + if ( + await this.usersService.contains({ username: signUpDto.username }) + ) { throw new ConflictException( "Пользователь с таким именем уже существует!", ); + } const salt = await genSalt(8); const id = new Types.ObjectId().toString("hex"); - const input: Prisma.userCreateInput = { + const input: Prisma.UserCreateInput = { id: id, username: signUpDto.username, salt: salt, @@ -42,6 +62,8 @@ export class AuthService { accessToken: await this.jwtService.signAsync({ id: id, }), + role: signUpDto.role as UserRole, + group: signUpDto.group, }; return this.usersService.create(input).then((user) => { @@ -52,7 +74,7 @@ export class AuthService { }); } - async signIn(signInDto: SignInDto): Promise { + async signIn(signInDto: SignInReqDto): Promise { const user = await this.usersService.findUnique({ username: signInDto.username, }); diff --git a/src/contants.ts b/src/contants.ts index 99e00e6..1554390 100644 --- a/src/contants.ts +++ b/src/contants.ts @@ -1,6 +1,12 @@ import { configDotenv } from "dotenv"; + configDotenv(); export const jwtConstants = { secret: process.env.JWT_SECRET!, }; + +export const httpsConstants = { + certPath: process.env.CERT_PEM_PATH!, + keyPath: process.env.KEY_PEM_PATH!, +}; diff --git a/src/dto/auth.dto.ts b/src/dto/auth.dto.ts index af9a688..1689341 100644 --- a/src/dto/auth.dto.ts +++ b/src/dto/auth.dto.ts @@ -1,19 +1,25 @@ -import { ApiProperty, PickType } from "@nestjs/swagger"; +import { ApiProperty, IntersectionType, PickType } from "@nestjs/swagger"; import { UserDto } from "./user.dto"; import { IsString } from "class-validator"; -export class SignInDto extends PickType(UserDto, ["username"]) { +// SignIn +export class SignInReqDto extends PickType(UserDto, ["username"]) { @ApiProperty({ description: "Пароль в исходном виде" }) @IsString() password: string; } -export class SignInResultDto extends PickType(UserDto, ["id", "accessToken"]) {} +export class SignInResDto extends PickType(UserDto, ["id", "accessToken"]) {} -export class SignUpDto extends SignInDto {} +// SignUp +export class SignUpReqDto extends IntersectionType( + SignInReqDto, + PickType(UserDto, ["role", "group"]), +) {} -export class SignUpResultDto extends SignInResultDto {} +export class SignUpResDto extends SignInResDto {} +// Update token export class UpdateTokenDto extends PickType(UserDto, ["accessToken"]) {} export class UpdateTokenResultDto extends UpdateTokenDto {} diff --git a/src/dto/schedule.dto.ts b/src/dto/schedule.dto.ts index e8d474c..44959c3 100644 --- a/src/dto/schedule.dto.ts +++ b/src/dto/schedule.dto.ts @@ -8,8 +8,8 @@ import { IsString, ValidateNested, } from "class-validator"; -import { ApiProperty } from "@nestjs/swagger"; -import { Type } from "class-transformer"; +import { ApiProperty, OmitType, PickType } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; export class LessonTimeDto { @ApiProperty({ @@ -49,8 +49,7 @@ export class LessonTimeDto { } export enum LessonTypeDto { - NONE = 0, - DEFAULT, + DEFAULT = 0, CUSTOM, } @@ -138,8 +137,9 @@ export class DayDto { @ApiProperty({ example: [], description: "Занятия" }) @IsArray() @ValidateNested({ each: true }) + @IsOptional() @Type(() => LessonDto) - lessons: Array; + lessons: Array; constructor(name: string) { this.name = name; @@ -160,7 +160,7 @@ export class DayDto { const lessonIdx = Number.parseInt(lessonRawIdx); const lesson = this.lessons[lessonIdx]; - if (lesson.type === LessonTypeDto.NONE) continue; + if (lesson === null) continue; this.nonNullIndices.push(lessonIdx); @@ -183,8 +183,9 @@ export class GroupDto { @ApiProperty({ example: [], description: "Дни недели" }) @IsArray() @ValidateNested({ each: true }) + @IsOptional() @Type(() => DayDto) - days: Array; + days: Array; constructor(name: string) { this.name = name; @@ -208,9 +209,48 @@ export class ScheduleDto { @IsString() etag: string; + @ApiProperty({ description: "Расписание групп" }) + @IsObject() + @IsOptional() + groups: any; + + @ApiProperty({ + example: { "ИС-214/23": [5, 6] }, + description: "Обновлённые дни с последнего изменения расписания", + }) + @IsObject() + @Type(() => Object) + @Transform(({ value }) => { + const object = {}; + + for (const key in value) { + object[key] = value[key]; + } + + return object; + }) + @Type(() => Object) + lastChangedDays: Array>; +} + +export class GroupScheduleRequestDto extends PickType(GroupDto, ["name"]) {} + +export class ScheduleGroupsDto { + @ApiProperty({ + example: ["ИС-214/23", "ИС-213/23"], + description: "Список названий всех групп в текущем расписании", + }) + @IsArray() + names: Array; +} + +export class GroupScheduleDto extends OmitType(ScheduleDto, [ + "groups", + "lastChangedDays", +]) { @ApiProperty({ description: "Расписание группы" }) @IsObject() - data: GroupDto; + group: GroupDto; @ApiProperty({ example: [5, 6], diff --git a/src/dto/user.dto.ts b/src/dto/user.dto.ts index 343cef3..b19bc20 100644 --- a/src/dto/user.dto.ts +++ b/src/dto/user.dto.ts @@ -1,36 +1,79 @@ import { ApiProperty, OmitType } from "@nestjs/swagger"; import { + IsEnum, IsJWT, IsMongoId, IsString, MaxLength, MinLength, } from "class-validator"; +import { Expose, plainToClass } from "class-transformer"; + +export enum UserRoleDto { + STUDENT = "STUDENT", + TEACHER = "TEACHER", + ADMIN = "ADMIN", +} export class UserDto { @ApiProperty({ description: "Идентификатор (ObjectId)" }) @IsMongoId() + @Expose() id: string; + @ApiProperty({ example: "n08i40k", description: "Имя" }) @IsString() @MinLength(4) @MaxLength(10) + @Expose() username: string; - @ApiProperty({ description: "Соль пароля" }) + + @ApiProperty({ + example: "$2b$08$34xwFv1WVJpvpVi3tZZuv.", + description: "Соль пароля", + }) @IsString() + @Expose() salt: string; - @ApiProperty({ description: "Хеш пароля" }) + + @ApiProperty({ + example: "$2b$08$34xwFv1WVJpvpVi3tZZuv...", + description: "Хеш пароля", + }) @IsString() + @Expose() password: string; - @ApiProperty({ description: "Последний токен доступа" }) + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + description: "Последний токен доступа", + }) @IsJWT() + @Expose() accessToken: string; + + @ApiProperty({ example: "ИС-214/23", description: "Группа пользователя" }) + @IsString() + @Expose() + group: string; + + @ApiProperty({ + example: UserRoleDto.STUDENT, + description: "Роль пользователя", + }) + @IsEnum(UserRoleDto) + @Expose() + role: UserRoleDto; } -// TODO: Доделать пользователей -// noinspection JSUnusedGlobalSymbols export class ClientUserDto extends OmitType(UserDto, [ "password", "salt", "accessToken", -]) {} +]) { + static fromUserDto(userDto: UserDto): ClientUserDto { + return plainToClass(ClientUserDto, userDto, { + excludeExtraneousValues: true, + }); + } +} diff --git a/src/main.ts b/src/main.ts index 71c7493..b6a3a59 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,9 +4,19 @@ import { ValidatorOptions } from "class-validator"; import { PartialValidationPipe } from "./utility/validation/partial-validation.pipe"; import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { httpsConstants } from "./contants"; +import * as path from "node:path"; +import * as fs from "node:fs"; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + httpsOptions: { + cert: fs.readFileSync( + path.join(__dirname, httpsConstants.certPath), + ), + key: fs.readFileSync(path.join(__dirname, httpsConstants.keyPath)), + }, + }); const validatorOptions: ValidatorOptions = { enableDebugMessages: true, forbidNonWhitelisted: true, diff --git a/src/schedule/internal/schedule-parser/schedule-parser.ts b/src/schedule/internal/schedule-parser/schedule-parser.ts index 1a474b2..c1edb4f 100644 --- a/src/schedule/internal/schedule-parser/schedule-parser.ts +++ b/src/schedule/internal/schedule-parser/schedule-parser.ts @@ -1,7 +1,6 @@ import { XlsDownloaderBase, XlsDownloaderCacheMode, - XlsDownloaderResult, } from "../xls-downloader/xls-downloader.base"; import * as XLSX from "xlsx"; @@ -17,11 +16,11 @@ import { trimAll } from "../../../utility/string.util"; type InternalId = { row: number; column: number; name: string }; type InternalDay = InternalId & { lessons: Array }; -export type ScheduleParseResult = { +export class ScheduleParseResult { etag: string; - group: GroupDto; - affectedDays: Array; -}; + groups: Array; + affectedDays: Array>; +} export class ScheduleParser { private lastResult: ScheduleParseResult | null = null; @@ -72,13 +71,13 @@ export class ScheduleParser { } parseSkeleton(worksheet: XLSX.Sheet): { - groupSkeleton: InternalId; + groupSkeletons: Array; daySkeletons: Array; } { const range = XLSX.utils.decode_range(worksheet["!ref"] || ""); let isHeaderParsed: boolean = false; - let group: InternalId = null; + const groups: Array = []; const days: Array = []; for (let row = range.s.r + 1; row <= range.e.r; ++row) { @@ -99,10 +98,9 @@ export class ScheduleParser { row, column, ); - if (!groupName || this.group !== groupName) continue; + if (!groupName) continue; - group = { row: row, column: column, name: groupName }; - break; + groups.push({ row: row, column: column, name: groupName }); } ++row; } @@ -116,33 +114,27 @@ export class ScheduleParser { break; } - return { daySkeletons: days, groupSkeleton: group }; + return { daySkeletons: days, groupSkeletons: groups }; } async getSchedule( forceCached: boolean = false, ): Promise { - let downloadData: XlsDownloaderResult; + if (forceCached && this.lastResult !== null) return this.lastResult; + + const downloadData = await this.xlsDownloader.downloadXLS(); if ( - !forceCached || - (downloadData = await this.xlsDownloader.getCachedXLS()) === null + !downloadData.new && + this.lastResult && + this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE ) { - console.debug("Обновление кеша..."); - downloadData = await this.xlsDownloader.downloadXLS(); + console.debug( + "Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...", + ); + console.debug("будет возвращён предыдущий результат."); - if ( - !downloadData.new && - this.lastResult && - this.xlsDownloader.getCacheMode() != XlsDownloaderCacheMode.NONE - ) { - console.debug( - "Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...", - ); - console.debug("будет возвращён предыдущий результат."); - - return this.lastResult; - } + return this.lastResult; } console.debug("Чтение кешированного XLS документа..."); @@ -150,102 +142,115 @@ export class ScheduleParser { const workBook = XLSX.read(downloadData.fileData); const workSheet = workBook.Sheets[workBook.SheetNames[0]]; - const { groupSkeleton, daySkeletons } = this.parseSkeleton(workSheet); + const { groupSkeletons, daySkeletons } = this.parseSkeleton(workSheet); - const group = new GroupDto(groupSkeleton.name); + const groups: Array = []; - for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) { - const daySkeleton = daySkeletons[dayIdx]; - const day = new DayDto(daySkeleton.name); + for (const groupSkeleton of groupSkeletons) { + const group = new GroupDto(groupSkeleton.name); - const lessonTimeColumn = daySkeletons[0].column + 1; - const rowDistance = daySkeletons[dayIdx + 1].row - daySkeleton.row; + for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) { + const daySkeleton = daySkeletons[dayIdx]; + const day = new DayDto(daySkeleton.name); - for ( - let row = daySkeleton.row; - row < daySkeleton.row + rowDistance; - ++row - ) { - const time = ScheduleParser.getCellName( - workSheet, - row, - lessonTimeColumn, - )?.replaceAll(" ", ""); - if (!time || typeof time !== "string") continue; + const lessonTimeColumn = daySkeletons[0].column + 1; + const rowDistance = + daySkeletons[dayIdx + 1].row - daySkeleton.row; - const rawName = ScheduleParser.getCellName( - workSheet, - row, - groupSkeleton.column, - ); - const cabinets: Array = []; - - const rawCabinets = String( - ScheduleParser.getCellName( + for ( + let row = daySkeleton.row; + row < daySkeleton.row + rowDistance; + ++row + ) { + const time = ScheduleParser.getCellName( workSheet, row, - groupSkeleton.column + 1, - ), - ); - if (rawCabinets !== "null") { - const rawLessonCabinetParts = rawCabinets.split(/(\n|\s)/g); + lessonTimeColumn, + )?.replaceAll(" ", ""); + if (!time || typeof time !== "string") continue; - for (const cabinet of rawLessonCabinetParts) { - if ( - cabinet.length === 0 || - cabinet === " " || - cabinet === "\n" - ) - continue; + const rawName = ScheduleParser.getCellName( + workSheet, + row, + groupSkeleton.column, + ); + const cabinets: Array = []; - cabinets.push(cabinet); + const rawCabinets = String( + ScheduleParser.getCellName( + workSheet, + row, + groupSkeleton.column + 1, + ), + ); + if (rawCabinets !== "null") { + const rawLessonCabinetParts = + rawCabinets.split(/(\n|\s)/g); + + for (const cabinet of rawLessonCabinetParts) { + if ( + cabinet.length === 0 || + cabinet === " " || + cabinet === "\n" + ) + continue; + + cabinets.push(cabinet); + } } + + if (!rawName || rawName.length === 0) { + day.lessons.push(null); + continue; + } + + const type = time?.includes("пара") + ? LessonTypeDto.DEFAULT + : LessonTypeDto.CUSTOM; + + const { name, teacherFullNames } = + this.parseTeacherFullNames( + trimAll(rawName?.replace("\n", "") ?? ""), + ); + + day.lessons.push( + new LessonDto( + type, + LessonTimeDto.fromString( + type === LessonTypeDto.DEFAULT + ? time.substring(5) + : time, + ), + name, + cabinets, + teacherFullNames, + ), + ); } - const type = - !rawName || rawName.length === 0 - ? LessonTypeDto.NONE - : time?.includes("пара") - ? LessonTypeDto.DEFAULT - : LessonTypeDto.CUSTOM; + day.fillIndices(); - const { name, teacherFullNames } = this.parseTeacherFullNames( - trimAll(rawName?.replace("\n", "") ?? ""), - ); - - day.lessons.push( - new LessonDto( - type, - LessonTimeDto.fromString( - type === LessonTypeDto.DEFAULT - ? time.substring(5) - : time, - ), - name, - cabinets, - teacherFullNames, - ), - ); + if (day.nonNullIndices.length == 0) group.days.push(null); + else group.days.push(day); } - day.fillIndices(); - group.days.push(day); + groups[group.name] = group; } return (this.lastResult = { etag: downloadData.etag, - group: group, - affectedDays: this.getAffectedDays(this.lastResult?.group, group), + groups: groups, + affectedDays: this.getAffectedDays(this.lastResult?.groups, groups), }); } private getAffectedDays( - cachedGroup: GroupDto | null, - group: GroupDto, - ): Array { - const affectedDays: Array = []; + cachedGroups: Array | null, + groups: Array, + ): Array> { + const affectedDays: Array> = []; - if (!cachedGroup) return affectedDays; + if (!cachedGroups) return affectedDays; // noinspection SpellCheckingInspection const dayEquals = (lday: DayDto | null, rday: DayDto): boolean => { @@ -276,14 +281,23 @@ export class ScheduleParser { return true; }; - for (const dayIdx in group.days) { - // noinspection SpellCheckingInspection - const lday = group.days[dayIdx]; - // noinspection SpellCheckingInspection - const rday = cachedGroup.days[dayIdx]; + for (const groupName in cachedGroups) { + const cachedGroup = cachedGroups[groupName]; + const group = groups[groupName]; - if (!dayEquals(lday, rday)) - affectedDays.push(Number.parseInt(dayIdx)); + const affectedGroupDays: Array = []; + + for (const dayIdx in group.days) { + // noinspection SpellCheckingInspection + const lday = group.days[dayIdx]; + // noinspection SpellCheckingInspection + const rday = cachedGroup.days[dayIdx]; + + if (!dayEquals(lday, rday)) + affectedGroupDays.push(Number.parseInt(dayIdx)); + } + + affectedDays[groupName] = affectedGroupDays; } return affectedDays; diff --git a/src/schedule/schedule.controller.ts b/src/schedule/schedule.controller.ts index 954b49a..544d134 100644 --- a/src/schedule/schedule.controller.ts +++ b/src/schedule/schedule.controller.ts @@ -1,16 +1,23 @@ import { + Body, Controller, Get, HttpCode, - HttpStatus, + HttpStatus, Post, UseGuards, } from "@nestjs/common"; import { AuthGuard } from "../auth/auth.guard"; import { ScheduleService } from "./schedule.service"; -import { ScheduleDto } from "../dto/schedule.dto"; +import { + GroupScheduleDto, + GroupScheduleRequestDto, + ScheduleDto, + ScheduleGroupsDto, +} from "../dto/schedule.dto"; import { ResultDto } from "../utility/validation/class-validator.interceptor"; import { ApiExtraModels, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, refs, @@ -19,7 +26,7 @@ import { @Controller("api/v1/schedule") @UseGuards(AuthGuard) export class ScheduleController { - constructor(private scheduleService: ScheduleService) {} + constructor(private readonly scheduleService: ScheduleService) {} @ApiExtraModels(ScheduleDto) @ApiOperation({ summary: "Получение расписания", tags: ["schedule"] }) @@ -33,4 +40,40 @@ export class ScheduleController { getSchedule(): Promise { return this.scheduleService.getSchedule(); } + + @ApiExtraModels(GroupScheduleDto) + @ApiOperation({ + summary: "Получение расписания группы", + tags: ["schedule"], + }) + @ApiOkResponse({ + description: "Расписание получено успешно", + schema: refs(GroupScheduleDto)[0], + }) + @ApiNotFoundResponse({ description: "Требуемая группа не найдена" }) + @ResultDto(GroupScheduleDto) + @HttpCode(HttpStatus.OK) + @Post("getGroup") + getGroupSchedule( + @Body() groupDto: GroupScheduleRequestDto, + ): Promise { + return this.scheduleService.getGroup(groupDto.name); + } + + @ApiExtraModels(ScheduleGroupsDto) + @ApiOperation({ + summary: "Получение списка названий всех групп в расписании", + tags: ["schedule"], + }) + @ApiOkResponse({ + description: "Список получен успешно", + schema: refs(ScheduleGroupsDto)[0], + }) + @ApiNotFoundResponse({ description: "Требуемая группа не найдена" }) + @ResultDto(ScheduleGroupsDto) + @HttpCode(HttpStatus.OK) + @Get("getGroupNames") + async getGroupNames(): Promise { + return this.scheduleService.getGroupNames(); + } } diff --git a/src/schedule/schedule.module.ts b/src/schedule/schedule.module.ts index 2f9a752..a7c62a8 100644 --- a/src/schedule/schedule.module.ts +++ b/src/schedule/schedule.module.ts @@ -5,7 +5,6 @@ import { UsersService } from "../users/users.service"; import { PrismaService } from "../prisma/prisma.service"; @Module({ - imports: [], providers: [ScheduleService, UsersService, PrismaService], controllers: [ScheduleController], exports: [ScheduleService], diff --git a/src/schedule/schedule.service.ts b/src/schedule/schedule.service.ts index 066f126..7234cd2 100644 --- a/src/schedule/schedule.service.ts +++ b/src/schedule/schedule.service.ts @@ -1,8 +1,19 @@ -import { Injectable } from "@nestjs/common"; -import { ScheduleParser } from "./internal/schedule-parser/schedule-parser"; +import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { + ScheduleParser, + ScheduleParseResult, +} from "./internal/schedule-parser/schedule-parser"; import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader"; import { XlsDownloaderCacheMode } from "./internal/xls-downloader/xls-downloader.base"; -import { ScheduleDto } from "../dto/schedule.dto"; +import { + GroupDto, + GroupScheduleDto, + ScheduleDto, + ScheduleGroupsDto, +} from "../dto/schedule.dto"; +import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager"; +import { instanceToPlain } from "class-transformer"; +import { cacheGetOrFill } from "../utility/cache.util"; @Injectable() export class ScheduleService { @@ -15,26 +26,86 @@ export class ScheduleService { ); private lastCacheUpdate: Date = new Date(0); - private lastChangedDays: Array = []; + private lastChangedDays: Array> = []; - constructor() {} + constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {} + + private async getSourceSchedule(): Promise { + return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => { + this.lastCacheUpdate = new Date(); + + const schedule = await this.scheduleParser.getSchedule(); + schedule.groups = ScheduleService.toObject( + schedule.groups, + ) as Array; + + return schedule; + }); + } + + private static toObject(array: Array): object { + const object = {}; + + for (const item in array) object[item] = array[item]; + + return object; + } async getSchedule(): Promise { - const now = new Date(); - const cacheExpired = - (this.lastCacheUpdate.valueOf() - now.valueOf()) / 1000 / 60 > 5; + return cacheGetOrFill(this.cacheManager, "schedule", async () => { + const sourceSchedule = await this.getSourceSchedule(); - if (cacheExpired) this.lastCacheUpdate = now; + for (const groupName in sourceSchedule.affectedDays) { + const affectedDays = sourceSchedule.affectedDays[groupName]; - const schedule = await this.scheduleParser.getSchedule(!cacheExpired); - if (schedule.affectedDays.length !== 0) - this.lastChangedDays = schedule.affectedDays; + if (affectedDays?.length !== 0) + this.lastChangedDays[groupName] = affectedDays; + } + + return { + updatedAt: this.lastCacheUpdate, + groups: ScheduleService.toObject(sourceSchedule.groups), + etag: sourceSchedule.etag, + lastChangedDays: this.lastChangedDays, + }; + }); + } + + async getGroup(group: string): Promise { + const schedule = await this.getSourceSchedule(); + console.log(schedule); + if ((schedule.groups as object)[group] === undefined) { + throw new NotFoundException( + "Группы с таким названием не существует!", + ); + } return { updatedAt: this.lastCacheUpdate, - data: schedule.group, + group: schedule.groups[group], etag: schedule.etag, - lastChangedDays: this.lastChangedDays, + lastChangedDays: this.lastChangedDays[group] ?? [], }; } + + async getGroupNames(): Promise { + let groupNames: ScheduleGroupsDto | undefined = + await this.cacheManager.get("groupNames"); + + if (!groupNames) { + const schedule = await this.getSourceSchedule(); + const names: Array = []; + + for (const groupName in schedule.groups) names.push(groupName); + + groupNames = { names }; + await this.cacheManager.set( + "groupNames", + instanceToPlain(groupNames), + 24 * 60 * 60 * 1000, + ); + } + + return groupNames; + } } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..ff41350 --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,30 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + NotFoundException, + UseGuards, +} from "@nestjs/common"; +import { AuthGuard } from "../auth/auth.guard"; +import { ClientUserDto } from "../dto/user.dto"; +import { ResultDto } from "../utility/validation/class-validator.interceptor"; +import { UserToken } from "../auth/auth.decorator"; +import { AuthService } from "../auth/auth.service"; + +@Controller("api/v1/users") +@UseGuards(AuthGuard) +export class UsersController { + constructor(private readonly authService: AuthService) {} + + @ResultDto(ClientUserDto) + @HttpCode(HttpStatus.OK) + @Get("me") + async getMe(@UserToken() token: string): Promise { + const userDto = await this.authService.decodeUserToken(token); + if (!userDto) + throw new NotFoundException("Не удалось найти пользователя!"); + + return ClientUserDto.fromUserDto(userDto); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 1fcc952..11c8a1f 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,9 +1,12 @@ import { Module } from "@nestjs/common"; import { UsersService } from "./users.service"; import { PrismaService } from "../prisma/prisma.service"; +import { UsersController } from "./users.controller"; +import { AuthService } from "../auth/auth.service"; @Module({ - providers: [PrismaService, UsersService], + providers: [PrismaService, UsersService, AuthService], exports: [UsersService], + controllers: [UsersController], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 61c834d..8b404d8 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,27 +1,41 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; -import { Prisma, user } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { UserDto } from "../dto/user.dto"; @Injectable() export class UsersService { constructor(private readonly prismaService: PrismaService) {} - async findUnique(where: Prisma.userWhereUniqueInput): Promise { - return this.prismaService.user.findUnique({ where: where }); + private static convertToDto = (user: UserDto | null) => + user as UserDto | null; + + async findUnique( + where: Prisma.UserWhereUniqueInput, + ): Promise { + return this.prismaService.user + .findUnique({ where: where }) + .then(UsersService.convertToDto); } async update(params: { - where: Prisma.userWhereUniqueInput; - data: Prisma.userUpdateInput; - }): Promise { - return this.prismaService.user.update(params); + where: Prisma.UserWhereUniqueInput; + data: Prisma.UserUpdateInput; + }): Promise { + return this.prismaService.user + .update(params) + .then(UsersService.convertToDto); } - async create(data: Prisma.userCreateInput): Promise { - return this.prismaService.user.create({ data }); + async create(data: Prisma.UserCreateInput): Promise { + return this.prismaService.user + .create({ data }) + .then(UsersService.convertToDto); } - async contains(where: Prisma.userWhereUniqueInput): Promise { - return (await this.prismaService.user.count({ where })) > 0; + async contains(where: Prisma.UserWhereUniqueInput): Promise { + return this.prismaService.user + .count({ where }) + .then((count) => count > 0); } } diff --git a/src/utility/cache.util.ts b/src/utility/cache.util.ts new file mode 100644 index 0000000..4222abe --- /dev/null +++ b/src/utility/cache.util.ts @@ -0,0 +1,16 @@ +import { Cache } from "@nestjs/cache-manager"; +import { instanceToPlain } from "class-transformer"; + +export async function cacheGetOrFill( + cache: Cache, + key: string, + onMiss: () => Promise, +): Promise { + const value: Record | undefined = await cache.get(key); + if (value !== undefined) return value as T; + + const newValue = await onMiss(); + await cache.set(key, instanceToPlain(newValue)); + + return newValue; +} diff --git a/src/utility/validation/is-map.ts b/src/utility/validation/is-map.ts new file mode 100644 index 0000000..202c824 --- /dev/null +++ b/src/utility/validation/is-map.ts @@ -0,0 +1,39 @@ +import { + isObject, + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "class-validator"; + +export function IsMap( + keyValidators: ((value: unknown) => boolean)[], + valueValidators: ((value: unknown) => boolean)[], + validationOptions?: ValidationOptions, +) { + return function (object: unknown, propertyName: string) { + registerDecorator({ + name: "isMap", + target: (object as any).constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: unknown, args: ValidationArguments): boolean { + if (!isObject(value)) return false; + const keys = Object.keys(value); + const isInvalid = keys.some((key) => { + const isKeyInvalid = keyValidators.some( + (validator) => !validator(key), + ); + if (isKeyInvalid) return true; + + return valueValidators.some( + (validator) => !validator(value[key]), + ); + }); + + return !isInvalid; + }, + }, + }); + }; +}