Много

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) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 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", "version": "1.0.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
@@ -17,6 +18,7 @@
"@prisma/client": "^5.19.1", "@prisma/client": "^5.19.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cache-manager": "^5.7.6",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
@@ -1626,6 +1628,17 @@
"sparse-bitfield": "^3.0.3" "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": { "node_modules/@nestjs/cli": {
"version": "10.4.5", "version": "10.4.5",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz",
@@ -3370,6 +3383,25 @@
"node": ">= 0.8" "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": { "node_modules/call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -4539,6 +4571,11 @@
"node": ">= 0.6" "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": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" "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": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "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" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs/cache-manager": "^2.2.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
@@ -28,6 +29,7 @@
"@prisma/client": "^5.19.1", "@prisma/client": "^5.19.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cache-manager": "^5.7.6",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

View File

@@ -13,13 +13,22 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model user { enum UserRole {
id String @id @map("_id") @db.ObjectId STUDENT
TEACHER
ADMIN
}
model User {
id String @id @map("_id") @db.ObjectId
// //
username String @unique username String @unique
// //
salt String salt String
password String password String
// //
accessToken String @unique 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 { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { ScheduleModule } from "./schedule/schedule.module"; import { ScheduleModule } from "./schedule/schedule.module";
import { CacheModule } from "@nestjs/cache-manager";
@Module({ @Module({
imports: [AuthModule, UsersModule, ScheduleModule], imports: [
AuthModule,
UsersModule,
ScheduleModule,
CacheModule.register({ ttl: 5 * 60 * 1000, isGlobal: true }),
],
controllers: [], controllers: [],
providers: [], 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 { AuthService } from "./auth.service";
import { import {
ApiBody, ApiBody,
@@ -12,52 +19,66 @@ import {
refs, refs,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { import {
SignInDto, SignInReqDto,
SignInResultDto, SignInResDto,
SignUpDto, SignUpReqDto,
SignUpResultDto, SignUpResDto,
UpdateTokenDto, UpdateTokenDto,
UpdateTokenResultDto, UpdateTokenResultDto,
} from "../dto/auth.dto"; } from "../dto/auth.dto";
import { ResultDto } from "../utility/validation/class-validator.interceptor"; import { ResultDto } from "../utility/validation/class-validator.interceptor";
import { ScheduleService } from "../schedule/schedule.service";
@Controller("api/v1/auth") @Controller("api/v1/auth")
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(
private readonly scheduleService: ScheduleService,
private readonly authService: AuthService,
) {}
@ApiExtraModels(SignInDto) @ApiExtraModels(SignInReqDto)
@ApiExtraModels(SignInResultDto) @ApiExtraModels(SignInResDto)
@ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] }) @ApiOperation({ summary: "Авторизация по логину и паролю", tags: ["auth"] })
@ApiBody({ schema: refs(SignInDto)[0] }) @ApiBody({ schema: refs(SignInReqDto)[0] })
@ApiOkResponse({ @ApiOkResponse({
description: "Авторизация прошла успешно", description: "Авторизация прошла успешно",
schema: refs(SignInResultDto)[0], schema: refs(SignInResDto)[0],
}) })
@ApiUnauthorizedResponse({ @ApiUnauthorizedResponse({
description: "Некорректное имя пользователя или пароль", description: "Некорректное имя пользователя или пароль",
}) })
@ResultDto(SignInResultDto) @ResultDto(SignInResDto)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post("signIn") @Post("signIn")
signIn(@Body() signInDto: SignInDto) { signIn(@Body() signInDto: SignInReqDto) {
return this.authService.signIn(signInDto); return this.authService.signIn(signInDto);
} }
@ApiExtraModels(SignUpDto) @ApiExtraModels(SignUpReqDto)
@ApiExtraModels(SignUpResultDto) @ApiExtraModels(SignUpResDto)
@ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] }) @ApiOperation({ summary: "Регистрация по логину и паролю", tags: ["auth"] })
@ApiBody({ schema: refs(SignUpDto)[0] }) @ApiBody({ schema: refs(SignUpReqDto)[0] })
@ApiCreatedResponse({ @ApiCreatedResponse({
description: "Регистрация прошла успешно", description: "Регистрация прошла успешно",
schema: refs(SignUpResultDto)[0], schema: refs(SignUpResDto)[0],
}) })
@ApiConflictResponse({ @ApiConflictResponse({
description: "Такой пользователь уже существует", description: "Такой пользователь уже существует",
}) })
@ResultDto(SignUpResultDto) @ResultDto(SignUpResDto)
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@Post("signUp") @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); return this.authService.signUp(signUpDto);
} }

View File

@@ -3,7 +3,7 @@ import { AuthGuard } from "./auth.guard";
// TODO: Найти применение этой функции // TODO: Найти применение этой функции
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
export const UserId = createParamDecorator((_, context: ExecutionContext) => { export const UserToken = createParamDecorator((_, context: ExecutionContext) => {
return AuthGuard.extractTokenFromRequest( return AuthGuard.extractTokenFromRequest(
context.switchToHttp().getRequest(), context.switchToHttp().getRequest(),
); );

View File

@@ -6,6 +6,7 @@ import { AuthController } from "./auth.controller";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { ScheduleService } from "../schedule/schedule.service";
@Module({ @Module({
imports: [ imports: [
@@ -16,7 +17,7 @@ import { PrismaService } from "../prisma/prisma.service";
signOptions: { expiresIn: "720h" }, signOptions: { expiresIn: "720h" },
}), }),
], ],
providers: [AuthService, UsersService, PrismaService], providers: [AuthService, UsersService, PrismaService, ScheduleService],
controllers: [AuthController], controllers: [AuthController],
exports: [AuthService], exports: [AuthService],
}) })

View File

@@ -4,8 +4,8 @@ import {
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { user } from "@prisma/client";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { UserDto } from "../dto/user.dto";
@Injectable() @Injectable()
export class UserFromTokenPipe implements PipeTransform { export class UserFromTokenPipe implements PipeTransform {
@@ -14,7 +14,7 @@ export class UserFromTokenPipe implements PipeTransform {
private readonly usersService: UsersService, 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); const jwtUser: { id: string } = await this.jwtService.decode(token);
if (!jwtUser) if (!jwtUser)
@@ -24,6 +24,6 @@ export class UserFromTokenPipe implements PipeTransform {
if (!user) if (!user)
throw new UnauthorizedException("Передан некорректный токен!"); throw new UnauthorizedException("Передан некорректный токен!");
return user; return user as UserDto;
} }
} }

View File

@@ -1,22 +1,24 @@
import { import {
ConflictException, ConflictException,
Injectable, Injectable,
NotAcceptableException,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { import {
SignInDto, SignInReqDto,
SignInResultDto, SignInResDto,
SignUpDto, SignUpReqDto,
SignUpResultDto, SignUpResDto,
UpdateTokenDto, UpdateTokenDto,
UpdateTokenResultDto, UpdateTokenResultDto,
} from "../dto/auth.dto"; } from "../dto/auth.dto";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { genSalt, hash } from "bcrypt"; import { genSalt, hash } from "bcrypt";
import { Prisma } from "@prisma/client"; import { Prisma, UserRole } from "@prisma/client";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { UserDto, UserRoleDto } from "../dto/user.dto";
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -25,16 +27,34 @@ export class AuthService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
) {} ) {}
async signUp(signUpDto: SignUpDto): Promise<SignUpResultDto> { async decodeUserToken(token: string): Promise<UserDto | null> {
if (await this.usersService.contains({ username: signUpDto.username })) 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( throw new ConflictException(
"Пользователь с таким именем уже существует!", "Пользователь с таким именем уже существует!",
); );
}
const salt = await genSalt(8); const salt = await genSalt(8);
const id = new Types.ObjectId().toString("hex"); const id = new Types.ObjectId().toString("hex");
const input: Prisma.userCreateInput = { const input: Prisma.UserCreateInput = {
id: id, id: id,
username: signUpDto.username, username: signUpDto.username,
salt: salt, salt: salt,
@@ -42,6 +62,8 @@ export class AuthService {
accessToken: await this.jwtService.signAsync({ accessToken: await this.jwtService.signAsync({
id: id, id: id,
}), }),
role: signUpDto.role as UserRole,
group: signUpDto.group,
}; };
return this.usersService.create(input).then((user) => { 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({ const user = await this.usersService.findUnique({
username: signInDto.username, username: signInDto.username,
}); });

View File

@@ -1,6 +1,12 @@
import { configDotenv } from "dotenv"; import { configDotenv } from "dotenv";
configDotenv(); configDotenv();
export const jwtConstants = { export const jwtConstants = {
secret: process.env.JWT_SECRET!, secret: process.env.JWT_SECRET!,
}; };
export const httpsConstants = {
certPath: process.env.CERT_PEM_PATH!,
keyPath: process.env.KEY_PEM_PATH!,
};

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 { UserDto } from "./user.dto";
import { IsString } from "class-validator"; import { IsString } from "class-validator";
export class SignInDto extends PickType(UserDto, ["username"]) { // SignIn
export class SignInReqDto extends PickType(UserDto, ["username"]) {
@ApiProperty({ description: "Пароль в исходном виде" }) @ApiProperty({ description: "Пароль в исходном виде" })
@IsString() @IsString()
password: string; 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 UpdateTokenDto extends PickType(UserDto, ["accessToken"]) {}
export class UpdateTokenResultDto extends UpdateTokenDto {} export class UpdateTokenResultDto extends UpdateTokenDto {}

View File

@@ -8,8 +8,8 @@ import {
IsString, IsString,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { ApiProperty } from "@nestjs/swagger"; import { ApiProperty, OmitType, PickType } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Transform, Type } from "class-transformer";
export class LessonTimeDto { export class LessonTimeDto {
@ApiProperty({ @ApiProperty({
@@ -49,8 +49,7 @@ export class LessonTimeDto {
} }
export enum LessonTypeDto { export enum LessonTypeDto {
NONE = 0, DEFAULT = 0,
DEFAULT,
CUSTOM, CUSTOM,
} }
@@ -138,8 +137,9 @@ export class DayDto {
@ApiProperty({ example: [], description: "Занятия" }) @ApiProperty({ example: [], description: "Занятия" })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@IsOptional()
@Type(() => LessonDto) @Type(() => LessonDto)
lessons: Array<LessonDto>; lessons: Array<LessonDto | null>;
constructor(name: string) { constructor(name: string) {
this.name = name; this.name = name;
@@ -160,7 +160,7 @@ export class DayDto {
const lessonIdx = Number.parseInt(lessonRawIdx); const lessonIdx = Number.parseInt(lessonRawIdx);
const lesson = this.lessons[lessonIdx]; const lesson = this.lessons[lessonIdx];
if (lesson.type === LessonTypeDto.NONE) continue; if (lesson === null) continue;
this.nonNullIndices.push(lessonIdx); this.nonNullIndices.push(lessonIdx);
@@ -183,8 +183,9 @@ export class GroupDto {
@ApiProperty({ example: [], description: "Дни недели" }) @ApiProperty({ example: [], description: "Дни недели" })
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@IsOptional()
@Type(() => DayDto) @Type(() => DayDto)
days: Array<DayDto>; days: Array<DayDto | null>;
constructor(name: string) { constructor(name: string) {
this.name = name; this.name = name;
@@ -208,9 +209,48 @@ export class ScheduleDto {
@IsString() @IsString()
etag: string; 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: "Расписание группы" }) @ApiProperty({ description: "Расписание группы" })
@IsObject() @IsObject()
data: GroupDto; group: GroupDto;
@ApiProperty({ @ApiProperty({
example: [5, 6], example: [5, 6],

View File

@@ -1,36 +1,79 @@
import { ApiProperty, OmitType } from "@nestjs/swagger"; import { ApiProperty, OmitType } from "@nestjs/swagger";
import { import {
IsEnum,
IsJWT, IsJWT,
IsMongoId, IsMongoId,
IsString, IsString,
MaxLength, MaxLength,
MinLength, MinLength,
} from "class-validator"; } from "class-validator";
import { Expose, plainToClass } from "class-transformer";
export enum UserRoleDto {
STUDENT = "STUDENT",
TEACHER = "TEACHER",
ADMIN = "ADMIN",
}
export class UserDto { export class UserDto {
@ApiProperty({ description: "Идентификатор (ObjectId)" }) @ApiProperty({ description: "Идентификатор (ObjectId)" })
@IsMongoId() @IsMongoId()
@Expose()
id: string; id: string;
@ApiProperty({ example: "n08i40k", description: "Имя" }) @ApiProperty({ example: "n08i40k", description: "Имя" })
@IsString() @IsString()
@MinLength(4) @MinLength(4)
@MaxLength(10) @MaxLength(10)
@Expose()
username: string; username: string;
@ApiProperty({ description: "Соль пароля" })
@ApiProperty({
example: "$2b$08$34xwFv1WVJpvpVi3tZZuv.",
description: "Соль пароля",
})
@IsString() @IsString()
@Expose()
salt: string; salt: string;
@ApiProperty({ description: "Хеш пароля" })
@ApiProperty({
example: "$2b$08$34xwFv1WVJpvpVi3tZZuv...",
description: "Хеш пароля",
})
@IsString() @IsString()
@Expose()
password: string; password: string;
@ApiProperty({ description: "Последний токен доступа" })
@ApiProperty({
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
description: "Последний токен доступа",
})
@IsJWT() @IsJWT()
@Expose()
accessToken: string; 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, [ export class ClientUserDto extends OmitType(UserDto, [
"password", "password",
"salt", "salt",
"accessToken", "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 { PartialValidationPipe } from "./utility/validation/partial-validation.pipe";
import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor"; import { ClassValidatorInterceptor } from "./utility/validation/class-validator.interceptor";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; 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() { 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 = { const validatorOptions: ValidatorOptions = {
enableDebugMessages: true, enableDebugMessages: true,
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

View File

@@ -1,7 +1,6 @@
import { import {
XlsDownloaderBase, XlsDownloaderBase,
XlsDownloaderCacheMode, XlsDownloaderCacheMode,
XlsDownloaderResult,
} from "../xls-downloader/xls-downloader.base"; } from "../xls-downloader/xls-downloader.base";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
@@ -17,11 +16,11 @@ import { trimAll } from "../../../utility/string.util";
type InternalId = { row: number; column: number; name: string }; type InternalId = { row: number; column: number; name: string };
type InternalDay = InternalId & { lessons: Array<InternalId> }; type InternalDay = InternalId & { lessons: Array<InternalId> };
export type ScheduleParseResult = { export class ScheduleParseResult {
etag: string; etag: string;
group: GroupDto; groups: Array<GroupDto>;
affectedDays: Array<number>; affectedDays: Array<Array<number>>;
}; }
export class ScheduleParser { export class ScheduleParser {
private lastResult: ScheduleParseResult | null = null; private lastResult: ScheduleParseResult | null = null;
@@ -72,13 +71,13 @@ export class ScheduleParser {
} }
parseSkeleton(worksheet: XLSX.Sheet): { parseSkeleton(worksheet: XLSX.Sheet): {
groupSkeleton: InternalId; groupSkeletons: Array<InternalId>;
daySkeletons: Array<InternalDay>; daySkeletons: Array<InternalDay>;
} { } {
const range = XLSX.utils.decode_range(worksheet["!ref"] || ""); const range = XLSX.utils.decode_range(worksheet["!ref"] || "");
let isHeaderParsed: boolean = false; let isHeaderParsed: boolean = false;
let group: InternalId = null; const groups: Array<InternalId> = [];
const days: Array<InternalDay> = []; const days: Array<InternalDay> = [];
for (let row = range.s.r + 1; row <= range.e.r; ++row) { for (let row = range.s.r + 1; row <= range.e.r; ++row) {
@@ -99,10 +98,9 @@ export class ScheduleParser {
row, row,
column, column,
); );
if (!groupName || this.group !== groupName) continue; if (!groupName) continue;
group = { row: row, column: column, name: groupName }; groups.push({ row: row, column: column, name: groupName });
break;
} }
++row; ++row;
} }
@@ -116,33 +114,27 @@ export class ScheduleParser {
break; break;
} }
return { daySkeletons: days, groupSkeleton: group }; return { daySkeletons: days, groupSkeletons: groups };
} }
async getSchedule( async getSchedule(
forceCached: boolean = false, forceCached: boolean = false,
): Promise<ScheduleParseResult> { ): Promise<ScheduleParseResult> {
let downloadData: XlsDownloaderResult; if (forceCached && this.lastResult !== null) return this.lastResult;
const downloadData = await this.xlsDownloader.downloadXLS();
if ( if (
!forceCached || !downloadData.new &&
(downloadData = await this.xlsDownloader.getCachedXLS()) === null this.lastResult &&
this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE
) { ) {
console.debug("Обновление кеша..."); console.debug(
downloadData = await this.xlsDownloader.downloadXLS(); "Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...",
);
console.debug("будет возвращён предыдущий результат.");
if ( return this.lastResult;
!downloadData.new &&
this.lastResult &&
this.xlsDownloader.getCacheMode() != XlsDownloaderCacheMode.NONE
) {
console.debug(
"Так как скачанный XLS не новый, присутствует уже готовый результат и кеширование не отключено...",
);
console.debug("будет возвращён предыдущий результат.");
return this.lastResult;
}
} }
console.debug("Чтение кешированного XLS документа..."); console.debug("Чтение кешированного XLS документа...");
@@ -150,102 +142,115 @@ export class ScheduleParser {
const workBook = XLSX.read(downloadData.fileData); const workBook = XLSX.read(downloadData.fileData);
const workSheet = workBook.Sheets[workBook.SheetNames[0]]; 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) { for (const groupSkeleton of groupSkeletons) {
const daySkeleton = daySkeletons[dayIdx]; const group = new GroupDto(groupSkeleton.name);
const day = new DayDto(daySkeleton.name);
const lessonTimeColumn = daySkeletons[0].column + 1; for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
const rowDistance = daySkeletons[dayIdx + 1].row - daySkeleton.row; const daySkeleton = daySkeletons[dayIdx];
const day = new DayDto(daySkeleton.name);
for ( const lessonTimeColumn = daySkeletons[0].column + 1;
let row = daySkeleton.row; const rowDistance =
row < daySkeleton.row + rowDistance; daySkeletons[dayIdx + 1].row - daySkeleton.row;
++row
) {
const time = ScheduleParser.getCellName(
workSheet,
row,
lessonTimeColumn,
)?.replaceAll(" ", "");
if (!time || typeof time !== "string") continue;
const rawName = ScheduleParser.getCellName( for (
workSheet, let row = daySkeleton.row;
row, row < daySkeleton.row + rowDistance;
groupSkeleton.column, ++row
); ) {
const cabinets: Array<string> = []; const time = ScheduleParser.getCellName(
const rawCabinets = String(
ScheduleParser.getCellName(
workSheet, workSheet,
row, row,
groupSkeleton.column + 1, lessonTimeColumn,
), )?.replaceAll(" ", "");
); if (!time || typeof time !== "string") continue;
if (rawCabinets !== "null") {
const rawLessonCabinetParts = rawCabinets.split(/(\n|\s)/g);
for (const cabinet of rawLessonCabinetParts) { const rawName = ScheduleParser.getCellName(
if ( workSheet,
cabinet.length === 0 || row,
cabinet === " " || groupSkeleton.column,
cabinet === "\n" );
) const cabinets: Array<string> = [];
continue;
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 = day.fillIndices();
!rawName || rawName.length === 0
? LessonTypeDto.NONE
: time?.includes("пара")
? LessonTypeDto.DEFAULT
: LessonTypeDto.CUSTOM;
const { name, teacherFullNames } = this.parseTeacherFullNames( if (day.nonNullIndices.length == 0) group.days.push(null);
trimAll(rawName?.replace("\n", "") ?? ""), else group.days.push(day);
);
day.lessons.push(
new LessonDto(
type,
LessonTimeDto.fromString(
type === LessonTypeDto.DEFAULT
? time.substring(5)
: time,
),
name,
cabinets,
teacherFullNames,
),
);
} }
day.fillIndices(); groups[group.name] = group;
group.days.push(day);
} }
return (this.lastResult = { return (this.lastResult = {
etag: downloadData.etag, etag: downloadData.etag,
group: group, groups: groups,
affectedDays: this.getAffectedDays(this.lastResult?.group, group), affectedDays: this.getAffectedDays(this.lastResult?.groups, groups),
}); });
} }
private getAffectedDays( private getAffectedDays(
cachedGroup: GroupDto | null, cachedGroups: Array<GroupDto> | null,
group: GroupDto, groups: Array<GroupDto>,
): Array<number> { ): Array<Array<number>> {
const affectedDays: Array<number> = []; const affectedDays: Array<Array<number>> = [];
if (!cachedGroup) return affectedDays; if (!cachedGroups) return affectedDays;
// noinspection SpellCheckingInspection // noinspection SpellCheckingInspection
const dayEquals = (lday: DayDto | null, rday: DayDto): boolean => { const dayEquals = (lday: DayDto | null, rday: DayDto): boolean => {
@@ -276,14 +281,23 @@ export class ScheduleParser {
return true; return true;
}; };
for (const dayIdx in group.days) { for (const groupName in cachedGroups) {
// noinspection SpellCheckingInspection const cachedGroup = cachedGroups[groupName];
const lday = group.days[dayIdx]; const group = groups[groupName];
// noinspection SpellCheckingInspection
const rday = cachedGroup.days[dayIdx];
if (!dayEquals(lday, rday)) const affectedGroupDays: Array<number> = [];
affectedDays.push(Number.parseInt(dayIdx));
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; return affectedDays;

View File

@@ -1,16 +1,23 @@
import { import {
Body,
Controller, Controller,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus, Post,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard"; import { AuthGuard } from "../auth/auth.guard";
import { ScheduleService } from "./schedule.service"; 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 { ResultDto } from "../utility/validation/class-validator.interceptor";
import { import {
ApiExtraModels, ApiExtraModels,
ApiNotFoundResponse,
ApiOkResponse, ApiOkResponse,
ApiOperation, ApiOperation,
refs, refs,
@@ -19,7 +26,7 @@ import {
@Controller("api/v1/schedule") @Controller("api/v1/schedule")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class ScheduleController { export class ScheduleController {
constructor(private scheduleService: ScheduleService) {} constructor(private readonly scheduleService: ScheduleService) {}
@ApiExtraModels(ScheduleDto) @ApiExtraModels(ScheduleDto)
@ApiOperation({ summary: "Получение расписания", tags: ["schedule"] }) @ApiOperation({ summary: "Получение расписания", tags: ["schedule"] })
@@ -33,4 +40,40 @@ export class ScheduleController {
getSchedule(): Promise<ScheduleDto> { getSchedule(): Promise<ScheduleDto> {
return this.scheduleService.getSchedule(); 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"; import { PrismaService } from "../prisma/prisma.service";
@Module({ @Module({
imports: [],
providers: [ScheduleService, UsersService, PrismaService], providers: [ScheduleService, UsersService, PrismaService],
controllers: [ScheduleController], controllers: [ScheduleController],
exports: [ScheduleService], exports: [ScheduleService],

View File

@@ -1,8 +1,19 @@
import { Injectable } from "@nestjs/common"; import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ScheduleParser } from "./internal/schedule-parser/schedule-parser"; import {
ScheduleParser,
ScheduleParseResult,
} from "./internal/schedule-parser/schedule-parser";
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader"; import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
import { XlsDownloaderCacheMode } from "./internal/xls-downloader/xls-downloader.base"; 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() @Injectable()
export class ScheduleService { export class ScheduleService {
@@ -15,26 +26,86 @@ export class ScheduleService {
); );
private lastCacheUpdate: Date = new Date(0); 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> { async getSchedule(): Promise<ScheduleDto> {
const now = new Date(); return cacheGetOrFill(this.cacheManager, "schedule", async () => {
const cacheExpired = const sourceSchedule = await this.getSourceSchedule();
(this.lastCacheUpdate.valueOf() - now.valueOf()) / 1000 / 60 > 5;
if (cacheExpired) this.lastCacheUpdate = now; for (const groupName in sourceSchedule.affectedDays) {
const affectedDays = sourceSchedule.affectedDays[groupName];
const schedule = await this.scheduleParser.getSchedule(!cacheExpired); if (affectedDays?.length !== 0)
if (schedule.affectedDays.length !== 0) this.lastChangedDays[groupName] = affectedDays;
this.lastChangedDays = schedule.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 { return {
updatedAt: this.lastCacheUpdate, updatedAt: this.lastCacheUpdate,
data: schedule.group, group: schedule.groups[group],
etag: schedule.etag, 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;
}
} }

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 { Module } from "@nestjs/common";
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { UsersController } from "./users.controller";
import { AuthService } from "../auth/auth.service";
@Module({ @Module({
providers: [PrismaService, UsersService], providers: [PrismaService, UsersService, AuthService],
exports: [UsersService], exports: [UsersService],
controllers: [UsersController],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -1,27 +1,41 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { Prisma, user } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { UserDto } from "../dto/user.dto";
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(private readonly prismaService: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
async findUnique(where: Prisma.userWhereUniqueInput): Promise<user | null> { private static convertToDto = (user: UserDto | null) =>
return this.prismaService.user.findUnique({ where: where }); 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: { async update(params: {
where: Prisma.userWhereUniqueInput; where: Prisma.UserWhereUniqueInput;
data: Prisma.userUpdateInput; data: Prisma.UserUpdateInput;
}): Promise<user | null> { }): Promise<UserDto | null> {
return this.prismaService.user.update(params); return this.prismaService.user
.update(params)
.then(UsersService.convertToDto);
} }
async create(data: Prisma.userCreateInput): Promise<user> { async create(data: Prisma.UserCreateInput): Promise<UserDto> {
return this.prismaService.user.create({ data }); return this.prismaService.user
.create({ data })
.then(UsersService.convertToDto);
} }
async contains(where: Prisma.userWhereUniqueInput): Promise<boolean> { async contains(where: Prisma.UserWhereUniqueInput): Promise<boolean> {
return (await this.prismaService.user.count({ where })) > 0; 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;
},
},
});
};
}