mirror of
https://github.com/n08i40k/schedule-parser-next.git
synced 2025-12-06 17:57:45 +03:00
Много
This commit is contained in:
@@ -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: [],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
12
src/main.ts
12
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,
|
||||
|
||||
@@ -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,33 +114,27 @@ 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;
|
||||
|
||||
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<GroupDto> = [];
|
||||
|
||||
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<string> = [];
|
||||
|
||||
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<string> = [];
|
||||
|
||||
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<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,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<number> = [];
|
||||
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
data: schedule.group,
|
||||
group: schedule.groups[group],
|
||||
etag: schedule.etag,
|
||||
lastChangedDays: this.lastChangedDays,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/users/users.controller.ts
Normal file
30
src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
16
src/utility/cache.util.ts
Normal 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;
|
||||
}
|
||||
39
src/utility/validation/is-map.ts
Normal file
39
src/utility/validation/is-map.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user