Много

This commit is contained in:
2024-09-12 00:39:01 +04:00
parent 6b07bd89b8
commit 8fb9214246
24 changed files with 643 additions and 194 deletions

4
.gitignore vendored
View File

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

50
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -13,7 +13,13 @@ datasource db {
url = env("DATABASE_URL")
}
model user {
enum UserRole {
STUDENT
TEACHER
ADMIN
}
model User {
id String @id @map("_id") @db.ObjectId
//
username String @unique
@@ -22,4 +28,7 @@ model user {
password String
//
accessToken String @unique
//
group String
role UserRole
}

View File

@@ -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: [],
})

View File

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

View File

@@ -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(),
);

View File

@@ -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],
})

View File

@@ -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<user | null> {
async transform(token: string): Promise<UserDto> {
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;
}
}

View File

@@ -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<SignUpResultDto> {
if (await this.usersService.contains({ username: signUpDto.username }))
async decodeUserToken(token: string): Promise<UserDto | null> {
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<SignUpResDto> {
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<SignInResultDto> {
async signIn(signInDto: SignInReqDto): Promise<SignInResDto> {
const user = await this.usersService.findUnique({
username: signInDto.username,
});

View File

@@ -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!,
};

View File

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

View File

@@ -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<LessonDto>;
lessons: Array<LessonDto | null>;
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<DayDto>;
days: Array<DayDto | null>;
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<Array<number>>;
}
export class GroupScheduleRequestDto extends PickType(GroupDto, ["name"]) {}
export class ScheduleGroupsDto {
@ApiProperty({
example: ["ИС-214/23", "ИС-213/23"],
description: "Список названий всех групп в текущем расписании",
})
@IsArray()
names: Array<string>;
}
export class GroupScheduleDto extends OmitType(ScheduleDto, [
"groups",
"lastChangedDays",
]) {
@ApiProperty({ description: "Расписание группы" })
@IsObject()
data: GroupDto;
group: GroupDto;
@ApiProperty({
example: [5, 6],

View File

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

View File

@@ -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,

View File

@@ -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<InternalId> };
export type ScheduleParseResult = {
export class ScheduleParseResult {
etag: string;
group: GroupDto;
affectedDays: Array<number>;
};
groups: Array<GroupDto>;
affectedDays: Array<Array<number>>;
}
export class ScheduleParser {
private lastResult: ScheduleParseResult | null = null;
@@ -72,13 +71,13 @@ export class ScheduleParser {
}
parseSkeleton(worksheet: XLSX.Sheet): {
groupSkeleton: InternalId;
groupSkeletons: Array<InternalId>;
daySkeletons: Array<InternalDay>;
} {
const range = XLSX.utils.decode_range(worksheet["!ref"] || "");
let isHeaderParsed: boolean = false;
let group: InternalId = null;
const groups: Array<InternalId> = [];
const days: Array<InternalDay> = [];
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,25 +114,20 @@ export class ScheduleParser {
break;
}
return { daySkeletons: days, groupSkeleton: group };
return { daySkeletons: days, groupSkeletons: groups };
}
async getSchedule(
forceCached: boolean = false,
): Promise<ScheduleParseResult> {
let downloadData: XlsDownloaderResult;
if (forceCached && this.lastResult !== null) return this.lastResult;
if (
!forceCached ||
(downloadData = await this.xlsDownloader.getCachedXLS()) === null
) {
console.debug("Обновление кеша...");
downloadData = await this.xlsDownloader.downloadXLS();
const downloadData = await this.xlsDownloader.downloadXLS();
if (
!downloadData.new &&
this.lastResult &&
this.xlsDownloader.getCacheMode() != XlsDownloaderCacheMode.NONE
this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE
) {
console.debug(
"Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...",
@@ -143,15 +136,17 @@ export class ScheduleParser {
return this.lastResult;
}
}
console.debug("Чтение кешированного XLS документа...");
const workBook = XLSX.read(downloadData.fileData);
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
const { groupSkeleton, daySkeletons } = this.parseSkeleton(workSheet);
const { groupSkeletons, daySkeletons } = this.parseSkeleton(workSheet);
const groups: Array<GroupDto> = [];
for (const groupSkeleton of groupSkeletons) {
const group = new GroupDto(groupSkeleton.name);
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
@@ -159,7 +154,8 @@ export class ScheduleParser {
const day = new DayDto(daySkeleton.name);
const lessonTimeColumn = daySkeletons[0].column + 1;
const rowDistance = daySkeletons[dayIdx + 1].row - daySkeleton.row;
const rowDistance =
daySkeletons[dayIdx + 1].row - daySkeleton.row;
for (
let row = daySkeleton.row;
@@ -188,7 +184,8 @@ export class ScheduleParser {
),
);
if (rawCabinets !== "null") {
const rawLessonCabinetParts = rawCabinets.split(/(\n|\s)/g);
const rawLessonCabinetParts =
rawCabinets.split(/(\n|\s)/g);
for (const cabinet of rawLessonCabinetParts) {
if (
@@ -202,14 +199,17 @@ export class ScheduleParser {
}
}
const type =
!rawName || rawName.length === 0
? LessonTypeDto.NONE
: time?.includes("пара")
if (!rawName || rawName.length === 0) {
day.lessons.push(null);
continue;
}
const type = time?.includes("пара")
? LessonTypeDto.DEFAULT
: LessonTypeDto.CUSTOM;
const { name, teacherFullNames } = this.parseTeacherFullNames(
const { name, teacherFullNames } =
this.parseTeacherFullNames(
trimAll(rawName?.replace("\n", "") ?? ""),
);
@@ -229,23 +229,28 @@ export class ScheduleParser {
}
day.fillIndices();
group.days.push(day);
if (day.nonNullIndices.length == 0) group.days.push(null);
else 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<number> {
const affectedDays: Array<number> = [];
cachedGroups: Array<GroupDto> | null,
groups: Array<GroupDto>,
): Array<Array<number>> {
const affectedDays: Array<Array<number>> = [];
if (!cachedGroup) return affectedDays;
if (!cachedGroups) return affectedDays;
// noinspection SpellCheckingInspection
const dayEquals = (lday: DayDto | null, rday: DayDto): boolean => {
@@ -276,6 +281,12 @@ export class ScheduleParser {
return true;
};
for (const groupName in cachedGroups) {
const cachedGroup = cachedGroups[groupName];
const group = groups[groupName];
const affectedGroupDays: Array<number> = [];
for (const dayIdx in group.days) {
// noinspection SpellCheckingInspection
const lday = group.days[dayIdx];
@@ -283,7 +294,10 @@ export class ScheduleParser {
const rday = cachedGroup.days[dayIdx];
if (!dayEquals(lday, rday))
affectedDays.push(Number.parseInt(dayIdx));
affectedGroupDays.push(Number.parseInt(dayIdx));
}
affectedDays[groupName] = affectedGroupDays;
}
return affectedDays;

View File

@@ -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<ScheduleDto> {
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<GroupScheduleDto> {
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<ScheduleGroupsDto> {
return this.scheduleService.getGroupNames();
}
}

View File

@@ -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],

View File

@@ -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<number> = [];
private lastChangedDays: Array<Array<number>> = [];
constructor() {}
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}
private async getSourceSchedule(): Promise<ScheduleParseResult> {
return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => {
this.lastCacheUpdate = new Date();
const schedule = await this.scheduleParser.getSchedule();
schedule.groups = ScheduleService.toObject(
schedule.groups,
) as Array<GroupDto>;
return schedule;
});
}
private static toObject<T>(array: Array<T>): object {
const object = {};
for (const item in array) object[item] = array[item];
return object;
}
async getSchedule(): Promise<ScheduleDto> {
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,
data: schedule.group,
etag: schedule.etag,
groups: ScheduleService.toObject(sourceSchedule.groups),
etag: sourceSchedule.etag,
lastChangedDays: this.lastChangedDays,
};
});
}
async getGroup(group: string): Promise<GroupScheduleDto> {
const schedule = await this.getSourceSchedule();
console.log(schedule);
if ((schedule.groups as object)[group] === undefined) {
throw new NotFoundException(
"Группы с таким названием не существует!",
);
}
return {
updatedAt: this.lastCacheUpdate,
group: schedule.groups[group],
etag: schedule.etag,
lastChangedDays: this.lastChangedDays[group] ?? [],
};
}
async getGroupNames(): Promise<ScheduleGroupsDto> {
let groupNames: ScheduleGroupsDto | undefined =
await this.cacheManager.get("groupNames");
if (!groupNames) {
const schedule = await this.getSourceSchedule();
const names: Array<string> = [];
for (const groupName in schedule.groups) names.push(groupName);
groupNames = { names };
await this.cacheManager.set(
"groupNames",
instanceToPlain(groupNames),
24 * 60 * 60 * 1000,
);
}
return groupNames;
}
}

View File

@@ -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<ClientUserDto> {
const userDto = await this.authService.decodeUserToken(token);
if (!userDto)
throw new NotFoundException("Не удалось найти пользователя!");
return ClientUserDto.fromUserDto(userDto);
}
}

View File

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

View File

@@ -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<user | null> {
return this.prismaService.user.findUnique({ where: where });
private static convertToDto = (user: UserDto | null) =>
user as UserDto | null;
async findUnique(
where: Prisma.UserWhereUniqueInput,
): Promise<UserDto | null> {
return this.prismaService.user
.findUnique({ where: where })
.then(UsersService.convertToDto);
}
async update(params: {
where: Prisma.userWhereUniqueInput;
data: Prisma.userUpdateInput;
}): Promise<user | null> {
return this.prismaService.user.update(params);
where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput;
}): Promise<UserDto | null> {
return this.prismaService.user
.update(params)
.then(UsersService.convertToDto);
}
async create(data: Prisma.userCreateInput): Promise<user> {
return this.prismaService.user.create({ data });
async create(data: Prisma.UserCreateInput): Promise<UserDto> {
return this.prismaService.user
.create({ data })
.then(UsersService.convertToDto);
}
async contains(where: Prisma.userWhereUniqueInput): Promise<boolean> {
return (await this.prismaService.user.count({ where })) > 0;
async contains(where: Prisma.UserWhereUniqueInput): Promise<boolean> {
return this.prismaService.user
.count({ where })
.then((count) => count > 0);
}
}

16
src/utility/cache.util.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Cache } from "@nestjs/cache-manager";
import { instanceToPlain } from "class-transformer";
export async function cacheGetOrFill<T>(
cache: Cache,
key: string,
onMiss: () => Promise<T>,
): Promise<T> {
const value: Record<string, any> | undefined = await cache.get(key);
if (value !== undefined) return value as T;
const newValue = await onMiss();
await cache.set(key, instanceToPlain<T>(newValue));
return newValue;
}

View File

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