├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── Dockerfile ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── auth │ ├── auth.constants.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── dto │ │ └── auth.dto.ts │ ├── guards │ │ └── jwt.guard.ts │ ├── strategies │ │ └── jwt.stratagy.ts │ └── user.model.ts ├── configs │ ├── jwt.config.ts │ ├── mongo.config.ts │ └── telegram.config.ts ├── decorators │ └── user-email.decorator.ts ├── files │ ├── dto │ │ └── file-element.reposonse.ts │ ├── files.controller.ts │ ├── files.module.ts │ ├── files.service.ts │ └── mfile.class.ts ├── hh │ ├── hh.constants.ts │ ├── hh.models.ts │ ├── hh.module.ts │ └── hh.service.ts ├── main.ts ├── pipes │ ├── ad-validation.constants.ts │ └── ad-validation.pipe.ts ├── product │ ├── dto │ │ ├── create-product.dto.ts │ │ └── find-product.dto.ts │ ├── product.constants.ts │ ├── product.controller.ts │ ├── product.model.ts │ ├── product.module.ts │ └── product.service.ts ├── review │ ├── dto │ │ └── create-review.dto.ts │ ├── review.constants.ts │ ├── review.controller.ts │ ├── review.model.ts │ ├── review.module.ts │ ├── review.service.spec.ts │ └── review.service.ts ├── sitemap │ ├── sitemap.constants.ts │ ├── sitemap.controller.ts │ └── sitemap.module.ts ├── telegram │ ├── telegram.constants.ts │ ├── telegram.interface.ts │ ├── telegram.module.ts │ └── telegram.service.ts └── top-page │ ├── dto │ ├── create-top-page.dto.ts │ └── find-top-page.dto.ts │ ├── top-page.constants.ts │ ├── top-page.controller.ts │ ├── top-page.model.ts │ ├── top-page.module.ts │ └── top-page.service.ts ├── test ├── auth.e2e-spec.ts ├── jest-e2e.json └── review.e2e-spec.ts ├── tsconfig.build.json ├── tsconfig.json └── tslint.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - name: Publish to registy 13 | uses: elgohr/Publish-Docker-Github-Action@master 14 | with: 15 | registry: docker.pkg.github.com 16 | name: docker.pkg.github.com/AlariCode/top-api/top-api 17 | username: ${{ secrets.DOCKER_USERNAME }} 18 | password: ${{ secrets.DOCKER_PASSWORD }} 19 | tags: "develop" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .env 5 | /uploads 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Debug NestJS", 11 | "port": 9229 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | WORKDIR /opt/app 3 | ADD package.json package.json 4 | RUN npm install 5 | ADD . . 6 | RUN npm run build 7 | RUN npm prune --production 8 | CMD ["node", "./dist/main.js"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Демо проект для курса по NestJS 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | top.api: 4 | image: docker.pkg.github.com/alaricode/top-api-demo/top-api-demo:develop 5 | container_name: top-api 6 | restart: always 7 | ports: 8 | - 3000:3000 9 | volumes: 10 | - ./.env:/opt/app/.env -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "top-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "Anton Larichev", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "tslint --fix --project .", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/axios": "^0.0.4", 25 | "@nestjs/common": "^8.2.5", 26 | "@nestjs/config": "^1.1.6", 27 | "@nestjs/core": "^8.2.5", 28 | "@nestjs/jwt": "^8.0.0", 29 | "@nestjs/passport": "^8.1.0", 30 | "@nestjs/platform-express": "^8.2.5", 31 | "@nestjs/schedule": "^1.0.2", 32 | "@nestjs/serve-static": "^2.2.2", 33 | "@typegoose/typegoose": "^9.5.0", 34 | "app-root-path": "^3.0.0", 35 | "bcryptjs": "^2.4.3", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.13.1", 38 | "date-fns": "^2.28.0", 39 | "fs-extra": "^9.1.0", 40 | "mongoose": "6.1.7", 41 | "nestjs-typegoose": "^7.1.38", 42 | "passport": "^0.5.2", 43 | "passport-jwt": "^4.0.0", 44 | "reflect-metadata": "^0.1.13", 45 | "rimraf": "^3.0.2", 46 | "rxjs": "^7.5.2", 47 | "sharp": "^0.27.2", 48 | "telegraf": "^4.3.0", 49 | "xml2js": "^0.4.23" 50 | }, 51 | "devDependencies": { 52 | "@nestjs/cli": "^8.2.0", 53 | "@nestjs/schematics": "^8.0.5", 54 | "@nestjs/testing": "^8.2.5", 55 | "@types/app-root-path": "^1.2.4", 56 | "@types/bcryptjs": "^2.4.2", 57 | "@types/cron": "^1.7.3", 58 | "@types/express": "^4.17.13", 59 | "@types/fs-extra": "^9.0.13", 60 | "@types/jest": "^27.4.0", 61 | "@types/multer": "^1.4.5", 62 | "@types/node": "^14.14.6", 63 | "@types/passport-jwt": "^3.0.5", 64 | "@types/sharp": "^0.27.1", 65 | "@types/supertest": "^2.0.10", 66 | "@types/xml2js": "^0.4.8", 67 | "jest": "^26.6.3", 68 | "prettier": "^2.1.2", 69 | "supertest": "^6.0.0", 70 | "ts-jest": "^26.4.3", 71 | "ts-loader": "^8.0.8", 72 | "ts-node": "^10.4.0", 73 | "tsconfig-paths": "^3.9.0", 74 | "tslint": "^6.1.3", 75 | "typescript": "^4.5.0" 76 | }, 77 | "jest": { 78 | "moduleFileExtensions": [ 79 | "js", 80 | "json", 81 | "ts" 82 | ], 83 | "rootDir": "src", 84 | "testRegex": ".*\\.spec\\.ts$", 85 | "transform": { 86 | "^.+\\.(t|j)s$": "ts-jest" 87 | }, 88 | "collectCoverageFrom": [ 89 | "**/*.(t|j)s" 90 | ], 91 | "coverageDirectory": "../coverage", 92 | "testEnvironment": "node" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from './auth/auth.module'; 3 | import { TopPageModule } from './top-page/top-page.module'; 4 | import { ProductModule } from './product/product.module'; 5 | import { ReviewModule } from './review/review.module'; 6 | import { ConfigModule, ConfigService } from '@nestjs/config'; 7 | import { TypegooseModule } from 'nestjs-typegoose'; 8 | import { getMongoConfig } from './configs/mongo.config'; 9 | import { FilesModule } from './files/files.module'; 10 | import { TelegramModule } from './telegram/telegram.module'; 11 | import { getTelegramConfig } from './configs/telegram.config'; 12 | import { HhModule } from './hh/hh.module'; 13 | import { ScheduleModule } from '@nestjs/schedule'; 14 | import { SitemapModule } from './sitemap/sitemap.module'; 15 | 16 | @Module({ 17 | imports: [ 18 | ScheduleModule.forRoot(), 19 | ConfigModule.forRoot(), 20 | TypegooseModule.forRootAsync({ 21 | imports: [ConfigModule], 22 | inject: [ConfigService], 23 | useFactory: getMongoConfig 24 | }), 25 | AuthModule, 26 | TopPageModule, 27 | ProductModule, 28 | ReviewModule, 29 | FilesModule, 30 | TelegramModule.forRootAsync({ 31 | imports: [ConfigModule], 32 | inject: [ConfigService], 33 | useFactory: getTelegramConfig 34 | }), 35 | HhModule, 36 | SitemapModule 37 | ] 38 | }) 39 | export class AppModule { } 40 | -------------------------------------------------------------------------------- /src/auth/auth.constants.ts: -------------------------------------------------------------------------------- 1 | export const ALREADY_REGISTERED_ERROR = 'Такой пользователь уже был зарегистрирован'; 2 | export const USER_NOT_FOUND_ERROR = 'Пользователь с таким email не найден'; 3 | export const WRONG_PASSWORD_ERROR = 'Неверный пароль'; -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Body, Controller, HttpCode, Post, UsePipes, ValidationPipe } from '@nestjs/common'; 2 | import { ALREADY_REGISTERED_ERROR } from './auth.constants'; 3 | import { AuthService } from './auth.service'; 4 | import { AuthDto } from './dto/auth.dto'; 5 | 6 | @Controller('auth') 7 | export class AuthController { 8 | constructor(private readonly authService: AuthService) { } 9 | 10 | @UsePipes(new ValidationPipe()) 11 | @Post('register') 12 | async register(@Body() dto: AuthDto) { 13 | const oldUser = await this.authService.findUser(dto.login); 14 | if (oldUser) { 15 | throw new BadRequestException(ALREADY_REGISTERED_ERROR); 16 | } 17 | return this.authService.createUser(dto); 18 | } 19 | 20 | @UsePipes(new ValidationPipe()) 21 | @HttpCode(200) 22 | @Post('login') 23 | async login(@Body() { login, password }: AuthDto) { 24 | const { email } = await this.authService.validateUser(login, password); 25 | return this.authService.login(email); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypegooseModule } from 'nestjs-typegoose'; 3 | import { AuthController } from './auth.controller'; 4 | import { UserModel } from './user.model'; 5 | import { AuthService } from './auth.service'; 6 | import { JwtModule } from '@nestjs/jwt'; 7 | import { ConfigModule, ConfigService } from '@nestjs/config'; 8 | import { getJWTConfig } from '../configs/jwt.config'; 9 | import { PassportModule } from '@nestjs/passport'; 10 | import { JwtStratagy } from './strategies/jwt.stratagy'; 11 | 12 | @Module({ 13 | controllers: [AuthController], 14 | imports: [ 15 | TypegooseModule.forFeature([ 16 | { 17 | typegooseClass: UserModel, 18 | schemaOptions: { 19 | collection: 'User' 20 | } 21 | } 22 | ]), 23 | ConfigModule, 24 | JwtModule.registerAsync({ 25 | imports: [ConfigModule], 26 | inject: [ConfigService], 27 | useFactory: getJWTConfig 28 | }), 29 | PassportModule 30 | ], 31 | providers: [AuthService, JwtStratagy] 32 | }) 33 | export class AuthModule { } 34 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ModelType } from '@typegoose/typegoose/lib/types'; 3 | import { InjectModel } from 'nestjs-typegoose'; 4 | import { AuthDto } from './dto/auth.dto'; 5 | import { UserModel } from './user.model'; 6 | import { genSalt, hash, compare } from 'bcryptjs'; 7 | import { USER_NOT_FOUND_ERROR, WRONG_PASSWORD_ERROR } from './auth.constants'; 8 | import { JwtService } from '@nestjs/jwt'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | constructor( 13 | @InjectModel(UserModel) private readonly userModel: ModelType, 14 | private readonly jwtService: JwtService 15 | ) { } 16 | 17 | async createUser(dto: AuthDto) { 18 | const salt = await genSalt(10); 19 | const newUser = new this.userModel({ 20 | email: dto.login, 21 | passwordHash: await hash(dto.password, salt) 22 | }); 23 | return newUser.save(); 24 | } 25 | 26 | async findUser(email: string) { 27 | return this.userModel.findOne({ email }).exec(); 28 | } 29 | 30 | async validateUser(email: string, password: string): Promise> { 31 | const user = await this.findUser(email); 32 | if (!user) { 33 | throw new UnauthorizedException(USER_NOT_FOUND_ERROR); 34 | } 35 | const isCorrectPassword = await compare(password, user.passwordHash); 36 | if (!isCorrectPassword) { 37 | throw new UnauthorizedException(WRONG_PASSWORD_ERROR); 38 | } 39 | return { email: user.email }; 40 | } 41 | 42 | async login(email: string) { 43 | const payload = { email }; 44 | return { 45 | access_token: await this.jwtService.signAsync(payload) 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/auth/dto/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class AuthDto { 4 | @IsString() 5 | login: string; 6 | 7 | @IsString() 8 | password: string; 9 | } -------------------------------------------------------------------------------- /src/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '@nestjs/passport'; 2 | 3 | export class JwtAuthGuard extends AuthGuard('jwt') { } -------------------------------------------------------------------------------- /src/auth/strategies/jwt.stratagy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { UserModel } from '../user.model'; 6 | 7 | @Injectable() 8 | export class JwtStratagy extends PassportStrategy(Strategy) { 9 | constructor(private readonly configService: ConfigService) { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | ignoreExpiration: true, 13 | secretOrKey: configService.get('JWT_SECRET') 14 | }); 15 | } 16 | 17 | async validate({ email }: Pick) { 18 | return email; 19 | } 20 | } -------------------------------------------------------------------------------- /src/auth/user.model.ts: -------------------------------------------------------------------------------- 1 | import { prop } from '@typegoose/typegoose'; 2 | import { TimeStamps, Base } from '@typegoose/typegoose/lib/defaultClasses'; 3 | 4 | export interface UserModel extends Base { } 5 | export class UserModel extends TimeStamps { 6 | @prop({ unique: true }) 7 | email: string; 8 | 9 | @prop() 10 | passwordHash: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/configs/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { JwtModuleOptions } from '@nestjs/jwt'; 3 | 4 | export const getJWTConfig = async (configService: ConfigService): Promise => { 5 | return { 6 | secret: configService.get('JWT_SECRET') 7 | }; 8 | }; -------------------------------------------------------------------------------- /src/configs/mongo.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { TypegooseModuleOptions } from 'nestjs-typegoose'; 3 | 4 | export const getMongoConfig = async (configService: ConfigService): Promise => { 5 | return { 6 | uri: getMongoString(configService), 7 | ...getMongoOptions() 8 | }; 9 | }; 10 | 11 | const getMongoString = (configService: ConfigService) => 12 | 'mongodb://' + 13 | configService.get('MONGO_LOGIN') + 14 | ':' + 15 | configService.get('MONGO_PASSWORD') + 16 | '@' + 17 | configService.get('MONGO_HOST') + 18 | ':' + 19 | configService.get('MONGO_PORT') + 20 | '/' + 21 | configService.get('MONGO_AUTHDATABASE'); 22 | 23 | const getMongoOptions = () => ({ 24 | useNewUrlParser: true, 25 | useUnifiedTopology: true 26 | }); -------------------------------------------------------------------------------- /src/configs/telegram.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { ITelegramOptions } from 'src/telegram/telegram.interface'; 3 | 4 | export const getTelegramConfig = (configService: ConfigService): ITelegramOptions => { 5 | const token = configService.get('TELEGRAM_TOKEN'); 6 | if (!token) { 7 | throw new Error('TELEGRAM_TOKEN не задан') 8 | } 9 | return { 10 | token, 11 | chatId: configService.get('CHAT_ID') ?? '' 12 | }; 13 | }; -------------------------------------------------------------------------------- /src/decorators/user-email.decorator.ts: -------------------------------------------------------------------------------- 1 | import { from } from 'rxjs'; 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | 4 | export const UserEmail = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext) => { 6 | const request = ctx.switchToHttp().getRequest(); 7 | return request.user; 8 | } 9 | ); -------------------------------------------------------------------------------- /src/files/dto/file-element.reposonse.ts: -------------------------------------------------------------------------------- 1 | export class FileElementResponse { 2 | url: string; 3 | name: string; 4 | } -------------------------------------------------------------------------------- /src/files/files.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, HttpCode, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; 2 | import { FileInterceptor } from '@nestjs/platform-express'; 3 | import { JwtAuthGuard } from 'src/auth/guards/jwt.guard'; 4 | import { FileElementResponse } from './dto/file-element.reposonse'; 5 | import { FilesService } from './files.service'; 6 | import { MFile } from './mfile.class'; 7 | 8 | @Controller('files') 9 | export class FilesController { 10 | constructor( 11 | private readonly filesService: FilesService 12 | ) { } 13 | 14 | @Post('upload') 15 | @HttpCode(200) 16 | @UseGuards(JwtAuthGuard) 17 | @UseInterceptors(FileInterceptor('file')) 18 | async uploadFile(@UploadedFile() file: Express.Multer.File): Promise { 19 | const saveArray: MFile[] = [new MFile(file)]; 20 | if (file.mimetype.includes('image')) { 21 | const buffer = await this.filesService.convertToWebP(file.buffer); 22 | saveArray.push(new MFile({ 23 | originalname: `${file.originalname.split('.')[0]}.webp`, 24 | buffer 25 | })); 26 | } 27 | return this.filesService.saveFiles(saveArray); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/files/files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FilesController } from './files.controller'; 3 | import { FilesService } from './files.service'; 4 | import { ServeStaticModule } from '@nestjs/serve-static'; 5 | import { path } from 'app-root-path'; 6 | 7 | @Module({ 8 | imports: [ServeStaticModule.forRoot({ 9 | rootPath: `${path}/uploads`, 10 | serveRoot: '/uploads' 11 | })], 12 | controllers: [FilesController], 13 | providers: [FilesService] 14 | }) 15 | export class FilesModule { } 16 | -------------------------------------------------------------------------------- /src/files/files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { FileElementResponse } from './dto/file-element.reposonse'; 3 | import { format } from 'date-fns'; 4 | import { path } from 'app-root-path'; 5 | import { ensureDir, writeFile } from 'fs-extra'; 6 | import * as sharp from 'sharp'; 7 | import { MFile } from './mfile.class'; 8 | 9 | @Injectable() 10 | export class FilesService { 11 | 12 | async saveFiles(files: MFile[]): Promise { 13 | const dateFolder = format(new Date(), 'yyyy-MM-dd'); 14 | const uploadFolder = `${path}/uploads/${dateFolder}`; 15 | await ensureDir(uploadFolder); 16 | const res: FileElementResponse[] = []; 17 | for (const file of files) { 18 | await writeFile(`${uploadFolder}/${file.originalname}`, file.buffer); 19 | res.push({ url: `/uploads/${dateFolder}/${file.originalname}`, name: file.originalname }); 20 | } 21 | return res; 22 | } 23 | 24 | convertToWebP(file: Buffer): Promise { 25 | return sharp(file) 26 | .webp() 27 | .toBuffer(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/files/mfile.class.ts: -------------------------------------------------------------------------------- 1 | export class MFile { 2 | originalname: string; 3 | buffer: Buffer; 4 | 5 | constructor(file: Express.Multer.File | MFile) { 6 | this.originalname = file.originalname; 7 | this.buffer = file.buffer; 8 | } 9 | } -------------------------------------------------------------------------------- /src/hh/hh.constants.ts: -------------------------------------------------------------------------------- 1 | const API_ROOT_API = 'https://api.hh.ru'; 2 | 3 | export const API_URL = { 4 | vacancies: API_ROOT_API + '/vacancies' 5 | } 6 | 7 | export const SALARY_CLUSTER_ID = 'salary'; 8 | 9 | export const CLUSTER_FIND_ERROR = 'Не найден кластер Salary'; -------------------------------------------------------------------------------- /src/hh/hh.models.ts: -------------------------------------------------------------------------------- 1 | export interface HhResponse { 2 | items: Vacancy[]; 3 | found: number; 4 | pages: number; 5 | per_page: number; 6 | page: number; 7 | clusters: Cluster[]; 8 | arguments?: any; 9 | alternate_url: string; 10 | } 11 | 12 | export interface Cluster { 13 | name: string; 14 | id: string; 15 | items: ClusterElement[]; 16 | } 17 | 18 | export interface ClusterElement { 19 | name: string; 20 | url: string; 21 | count: number; 22 | } 23 | 24 | export interface Vacancy { 25 | id: string; 26 | premium: boolean; 27 | name: string; 28 | department?: any; 29 | has_test: boolean; 30 | response_letter_required: boolean; 31 | area: Area; 32 | salary?: Salary; 33 | type: Type; 34 | address?: Address; 35 | response_url?: any; 36 | sort_point_distance?: any; 37 | published_at: string; 38 | created_at: string; 39 | archived: boolean; 40 | apply_alternate_url: string; 41 | insider_interview?: any; 42 | url: string; 43 | alternate_url: string; 44 | relations: any[]; 45 | employer: Employer; 46 | snippet: Snippet; 47 | contacts?: Contact; 48 | schedule: Type; 49 | working_days: any[]; 50 | working_time_intervals: any[]; 51 | working_time_modes: any[]; 52 | accept_temporary: boolean; 53 | } 54 | 55 | export interface Contact { 56 | name: string; 57 | email: string; 58 | phones: Phone[]; 59 | } 60 | 61 | export interface Phone { 62 | comment?: any; 63 | city: string; 64 | number: string; 65 | country: string; 66 | } 67 | 68 | export interface Snippet { 69 | requirement?: string; 70 | responsibility?: string; 71 | } 72 | 73 | export interface Employer { 74 | id: string; 75 | name: string; 76 | url: string; 77 | alternate_url: string; 78 | logo_urls?: Logourl; 79 | vacancies_url: string; 80 | trusted: boolean; 81 | } 82 | 83 | export interface Logourl { 84 | '90': string; 85 | '240': string; 86 | original: string; 87 | } 88 | 89 | export interface Address { 90 | city?: string; 91 | street?: string; 92 | building?: string; 93 | description?: any; 94 | lat?: number; 95 | lng?: number; 96 | raw?: string; 97 | metro?: Metro; 98 | metro_stations: Metro[]; 99 | id: string; 100 | } 101 | 102 | export interface Metro { 103 | station_name: string; 104 | line_name: string; 105 | station_id: string; 106 | line_id: string; 107 | lat: number; 108 | lng: number; 109 | } 110 | 111 | export interface Type { 112 | id: string; 113 | name: string; 114 | } 115 | 116 | export interface Salary { 117 | from: number; 118 | to?: number; 119 | currency: string; 120 | gross: boolean; 121 | } 122 | 123 | export interface Area { 124 | id: string; 125 | name: string; 126 | url: string; 127 | } -------------------------------------------------------------------------------- /src/hh/hh.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { HhService } from './hh.service'; 5 | 6 | @Module({ 7 | providers: [HhService], 8 | imports: [ConfigModule, HttpModule], 9 | exports: [HhService] 10 | }) 11 | export class HhModule { } 12 | -------------------------------------------------------------------------------- /src/hh/hh.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { lastValueFrom } from 'rxjs'; 5 | import { HhData } from 'src/top-page/top-page.model'; 6 | import { API_URL, CLUSTER_FIND_ERROR, SALARY_CLUSTER_ID } from './hh.constants'; 7 | import { HhResponse } from './hh.models'; 8 | 9 | @Injectable() 10 | export class HhService { 11 | private token: string; 12 | 13 | constructor( 14 | private readonly configService: ConfigService, 15 | private readonly httpService: HttpService 16 | ) { 17 | this.token = this.configService.get('HH_TOKEN') ?? ''; 18 | } 19 | 20 | async getData(text: string) { 21 | try { 22 | const { data } = await lastValueFrom(this.httpService.get(API_URL.vacancies, { 23 | params: { 24 | text, 25 | clusters: true 26 | }, 27 | headers: { 28 | 'User-Agent': 'OwlTop/1.0 (antonlarichev@gmail.com)', 29 | Authorization: 'Bearer ' + this.token 30 | } 31 | })); 32 | return this.parseData(data); 33 | } catch (e) { 34 | Logger.error(e); 35 | } 36 | } 37 | 38 | private parseData(data: HhResponse): HhData { 39 | const salaryCluster = data.clusters.find(c => c.id == SALARY_CLUSTER_ID); 40 | if (!salaryCluster) { 41 | throw new Error(CLUSTER_FIND_ERROR) 42 | } 43 | const juniorSalary = this.getSalaryFromString(salaryCluster.items[1].name); 44 | const middleSalary = this.getSalaryFromString(salaryCluster.items[Math.ceil(salaryCluster.items.length / 2)].name); 45 | const seniorSalary = this.getSalaryFromString(salaryCluster.items[salaryCluster.items.length - 1].name); 46 | 47 | return { 48 | count: data.found, 49 | juniorSalary, 50 | middleSalary, 51 | seniorSalary, 52 | updatedAt: new Date() 53 | } 54 | } 55 | 56 | private getSalaryFromString(s: string): number { 57 | const numberRegExp = /(\d+)/g; 58 | const res = s.match(numberRegExp); 59 | if (!res) { 60 | return 0; 61 | } 62 | return Number(res[0]); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | app.setGlobalPrefix('api'); 7 | app.enableCors(); 8 | await app.listen(3000); 9 | } 10 | bootstrap(); 11 | -------------------------------------------------------------------------------- /src/pipes/ad-validation.constants.ts: -------------------------------------------------------------------------------- 1 | export const ID_VALIDATION_ERROR = 'Неверный формат id'; -------------------------------------------------------------------------------- /src/pipes/ad-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | import { Types } from 'mongoose'; 3 | import { ID_VALIDATION_ERROR } from './ad-validation.constants'; 4 | 5 | @Injectable() 6 | export class IdValidationPipe implements PipeTransform { 7 | transform(value: string, metadata: ArgumentMetadata) { 8 | if (metadata.type != 'param') { 9 | return value; 10 | } 11 | if (!Types.ObjectId.isValid(value)) { 12 | throw new BadRequestException(ID_VALIDATION_ERROR); 13 | } 14 | return value; 15 | } 16 | } -------------------------------------------------------------------------------- /src/product/dto/create-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsString, IsOptional, ValidateNested, IsArray, Max, Min } from 'class-validator'; 3 | 4 | class ProductCharacteristicDto { 5 | @IsString() 6 | name: string; 7 | 8 | @IsString() 9 | value: string; 10 | } 11 | 12 | export class CreateProductDto { 13 | @IsString() 14 | image: string; 15 | 16 | @IsString() 17 | title: string; 18 | 19 | @IsString() 20 | link: string; 21 | 22 | @Max(5) 23 | @Min(1) 24 | @IsNumber() 25 | initialRating: number; 26 | 27 | @IsNumber() 28 | price: number; 29 | 30 | @IsOptional() 31 | @IsNumber() 32 | oldPrice?: number; 33 | 34 | @IsNumber() 35 | credit: number; 36 | 37 | @IsString() 38 | description: string; 39 | 40 | @IsString() 41 | advantages: string; 42 | 43 | @IsOptional() 44 | @IsString() 45 | disAdvantages?: string; 46 | 47 | @IsArray() 48 | @IsString({ each: true }) 49 | categories: string[]; 50 | 51 | @IsArray() 52 | @IsString({ each: true }) 53 | tags: string[]; 54 | 55 | @IsArray() 56 | @ValidateNested() 57 | @Type(() => ProductCharacteristicDto) 58 | characteristics: ProductCharacteristicDto[]; 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/product/dto/find-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class FindProductDto { 4 | @IsString() 5 | category: string; 6 | 7 | @IsNumber() 8 | limit: number; 9 | } -------------------------------------------------------------------------------- /src/product/product.constants.ts: -------------------------------------------------------------------------------- 1 | export const PRODUCT_NOT_FOUND_ERROR = 'Продукт с таким ID не найден'; -------------------------------------------------------------------------------- /src/product/product.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | NotFoundException, 8 | Param, 9 | Patch, 10 | Post, 11 | UseGuards, 12 | UsePipes, 13 | ValidationPipe 14 | } from '@nestjs/common'; 15 | import { JwtAuthGuard } from 'src/auth/guards/jwt.guard'; 16 | import { IdValidationPipe } from 'src/pipes/ad-validation.pipe'; 17 | import { CreateProductDto } from './dto/create-product.dto'; 18 | import { FindProductDto } from './dto/find-product.dto'; 19 | import { PRODUCT_NOT_FOUND_ERROR } from './product.constants'; 20 | import { ProductModel } from './product.model'; 21 | import { ProductService } from './product.service'; 22 | 23 | @Controller('product') 24 | export class ProductController { 25 | constructor(private readonly productService: ProductService) { } 26 | 27 | @UseGuards(JwtAuthGuard) 28 | @Post('create') 29 | async create(@Body() dto: CreateProductDto) { 30 | return this.productService.create(dto); 31 | } 32 | 33 | @UseGuards(JwtAuthGuard) 34 | @Get(':id') 35 | async get(@Param('id', IdValidationPipe) id: string) { 36 | const product = await this.productService.findById(id); 37 | if (!product) { 38 | throw new NotFoundException(PRODUCT_NOT_FOUND_ERROR); 39 | } 40 | return product; 41 | } 42 | 43 | @UseGuards(JwtAuthGuard) 44 | @Delete(':id') 45 | async delete(@Param('id', IdValidationPipe) id: string) { 46 | const deletedProduct = await this.productService.deleteById(id); 47 | if (!deletedProduct) { 48 | throw new NotFoundException(PRODUCT_NOT_FOUND_ERROR); 49 | } 50 | } 51 | 52 | @UseGuards(JwtAuthGuard) 53 | @Patch(':id') 54 | async patch(@Param('id', IdValidationPipe) id: string, @Body() dto: ProductModel) { 55 | const updatedProduct = await this.productService.updateById(id, dto); 56 | if (!updatedProduct) { 57 | throw new NotFoundException(PRODUCT_NOT_FOUND_ERROR); 58 | } 59 | return updatedProduct; 60 | } 61 | 62 | @UsePipes(new ValidationPipe()) 63 | @HttpCode(200) 64 | @Post('find') 65 | async find(@Body() dto: FindProductDto) { 66 | return this.productService.findWithReviews(dto); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/product/product.model.ts: -------------------------------------------------------------------------------- 1 | import { prop } from '@typegoose/typegoose'; 2 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 3 | 4 | class ProductCharacteristic { 5 | @prop() 6 | name: string; 7 | 8 | @prop() 9 | value: string; 10 | } 11 | 12 | export interface ProductModel extends Base { } 13 | export class ProductModel extends TimeStamps { 14 | @prop() 15 | image: string; 16 | 17 | @prop() 18 | title: string; 19 | 20 | @prop() 21 | link: string; 22 | 23 | @prop() 24 | initialRating: number; 25 | 26 | @prop() 27 | price: number; 28 | 29 | @prop() 30 | oldPrice?: number; 31 | 32 | @prop() 33 | credit: number; 34 | 35 | @prop() 36 | description: string; 37 | 38 | @prop() 39 | advantages: string; 40 | 41 | @prop() 42 | disAdvantages?: string; 43 | 44 | @prop({ type: () => [String] }) 45 | categories: string[]; 46 | 47 | @prop({ type: () => [String] }) 48 | tags: string[]; 49 | 50 | @prop({ type: () => [ProductCharacteristic], _id: false }) 51 | characteristics: ProductCharacteristic[]; 52 | } 53 | -------------------------------------------------------------------------------- /src/product/product.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypegooseModule } from 'nestjs-typegoose'; 3 | import { ProductController } from './product.controller'; 4 | import { ProductModel } from './product.model'; 5 | import { ProductService } from './product.service'; 6 | 7 | @Module({ 8 | controllers: [ProductController], 9 | imports: [ 10 | TypegooseModule.forFeature([ 11 | { 12 | typegooseClass: ProductModel, 13 | schemaOptions: { 14 | collection: 'Product' 15 | } 16 | } 17 | ]) 18 | ], 19 | providers: [ProductService] 20 | }) 21 | export class ProductModule { } 22 | -------------------------------------------------------------------------------- /src/product/product.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ModelType } from '@typegoose/typegoose/lib/types'; 3 | import { InjectModel } from 'nestjs-typegoose'; 4 | import { ReviewModel } from 'src/review/review.model'; 5 | import { CreateProductDto } from './dto/create-product.dto'; 6 | import { FindProductDto } from './dto/find-product.dto'; 7 | import { ProductModel } from './product.model'; 8 | 9 | @Injectable() 10 | export class ProductService { 11 | constructor(@InjectModel(ProductModel) private readonly productModel: ModelType) { } 12 | 13 | async create(dto: CreateProductDto) { 14 | return this.productModel.create(dto); 15 | } 16 | 17 | async findById(id: string) { 18 | return this.productModel.findById(id).exec(); 19 | } 20 | 21 | async deleteById(id: string) { 22 | return this.productModel.findByIdAndDelete(id).exec(); 23 | } 24 | 25 | async updateById(id: string, dto: CreateProductDto) { 26 | return this.productModel.findByIdAndUpdate(id, dto, { new: true }).exec(); 27 | } 28 | 29 | async findWithReviews(dto: FindProductDto) { 30 | return this.productModel.aggregate([ 31 | { 32 | $match: { 33 | categories: dto.category 34 | } 35 | }, 36 | { 37 | $sort: { 38 | _id: 1 39 | } 40 | }, 41 | { 42 | $limit: dto.limit 43 | }, 44 | { 45 | $lookup: { 46 | from: 'Review', 47 | localField: '_id', 48 | foreignField: 'productId', 49 | as: 'reviews' 50 | } 51 | }, 52 | { 53 | $addFields: { 54 | reviewCount: { $size: '$reviews' }, 55 | reviewAvg: { $avg: '$reviews.rating' }, 56 | reviews: { 57 | $function: { 58 | body: `function (reviews) { 59 | reviews.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) 60 | return reviews; 61 | }`, 62 | args: ['$reviews'], 63 | lang: 'js' 64 | } 65 | } 66 | } 67 | } 68 | ]).exec(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/review/dto/create-review.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString, Max, Min } from 'class-validator'; 2 | 3 | export class CreateReviewDto { 4 | @IsString() 5 | name: string; 6 | 7 | @IsString() 8 | title: string; 9 | 10 | @IsString() 11 | description: string; 12 | 13 | @Max(5) 14 | @Min(1, { message: 'Рейтинг не может быть менее 1' }) 15 | @IsNumber() 16 | rating: number; 17 | 18 | @IsString() 19 | productId: string; 20 | } -------------------------------------------------------------------------------- /src/review/review.constants.ts: -------------------------------------------------------------------------------- 1 | export const REVIEW_NOT_FOUND = 'Отзыв с таким id не найден'; -------------------------------------------------------------------------------- /src/review/review.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpException, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | UseGuards, 11 | UsePipes, 12 | ValidationPipe 13 | } from '@nestjs/common'; 14 | import { IdValidationPipe } from 'src/pipes/ad-validation.pipe'; 15 | import { TelegramService } from 'src/telegram/telegram.service'; 16 | import { JwtAuthGuard } from '../auth/guards/jwt.guard'; 17 | import { CreateReviewDto } from './dto/create-review.dto'; 18 | import { REVIEW_NOT_FOUND } from './review.constants'; 19 | import { ReviewService } from './review.service'; 20 | 21 | @Controller('review') 22 | export class ReviewController { 23 | constructor( 24 | private readonly reviewService: ReviewService, 25 | private readonly telegramService: TelegramService 26 | ) { } 27 | 28 | @UsePipes(new ValidationPipe()) 29 | @Post('create') 30 | async create(@Body() dto: CreateReviewDto) { 31 | return this.reviewService.create(dto); 32 | } 33 | 34 | @UsePipes(new ValidationPipe()) 35 | @Post('notify') 36 | async notify(@Body() dto: CreateReviewDto) { 37 | const message = `Имя: ${dto.name}\n` 38 | + `Заголовок: ${dto.title}\n` 39 | + `Описание: ${dto.description}\n` 40 | + `Рейтинг: ${dto.rating}\n` 41 | + `ID Продукта: ${dto.productId}`; 42 | return this.telegramService.sendMessage(message); 43 | } 44 | 45 | @UseGuards(JwtAuthGuard) 46 | @Delete(':id') 47 | async delete(@Param('id', IdValidationPipe) id: string) { 48 | const deletedDoc = await this.reviewService.delete(id); 49 | if (!deletedDoc) { 50 | throw new HttpException(REVIEW_NOT_FOUND, HttpStatus.NOT_FOUND); 51 | } 52 | } 53 | 54 | @Get('byProduct/:productId') 55 | async getByProduct(@Param('productId', IdValidationPipe) productId: string) { 56 | return this.reviewService.findByProductId(productId); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/review/review.model.ts: -------------------------------------------------------------------------------- 1 | import { prop } from '@typegoose/typegoose'; 2 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 3 | import { Types } from 'mongoose'; 4 | 5 | export interface ReviewModel extends Base { } 6 | export class ReviewModel extends TimeStamps { 7 | @prop() 8 | name: string; 9 | 10 | @prop() 11 | title: string; 12 | 13 | @prop() 14 | description: string; 15 | 16 | @prop() 17 | rating: number; 18 | 19 | @prop() 20 | productId: Types.ObjectId; 21 | } 22 | -------------------------------------------------------------------------------- /src/review/review.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypegooseModule } from 'nestjs-typegoose'; 3 | import { TelegramModule } from 'src/telegram/telegram.module'; 4 | import { ReviewController } from './review.controller'; 5 | import { ReviewModel } from './review.model'; 6 | import { ReviewService } from './review.service'; 7 | 8 | @Module({ 9 | controllers: [ReviewController], 10 | imports: [ 11 | TypegooseModule.forFeature([ 12 | { 13 | typegooseClass: ReviewModel, 14 | schemaOptions: { 15 | collection: 'Review' 16 | } 17 | } 18 | ]), 19 | TelegramModule 20 | ], 21 | providers: [ReviewService] 22 | }) 23 | export class ReviewModule { } 24 | -------------------------------------------------------------------------------- /src/review/review.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Types } from 'mongoose'; 3 | import { getModelToken } from 'nestjs-typegoose'; 4 | import { ReviewService } from './review.service'; 5 | 6 | describe('ReviewService', () => { 7 | let service: ReviewService; 8 | 9 | const exec = { exec: jest.fn() }; 10 | const reviewRepositoryFactory = () => ({ 11 | find: () => exec 12 | }); 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [ 17 | ReviewService, 18 | { useFactory: reviewRepositoryFactory, provide: getModelToken('ReviewModel') } 19 | ], 20 | }).compile(); 21 | 22 | service = module.get(ReviewService); 23 | }); 24 | 25 | it('should be defined', () => { 26 | expect(service).toBeDefined(); 27 | }); 28 | 29 | it('findByProductId working', async () => { 30 | const id = new Types.ObjectId().toHexString(); 31 | reviewRepositoryFactory().find().exec.mockReturnValueOnce([{ productId: id }]); 32 | const res = await service.findByProductId(id); 33 | expect(res[0].productId).toBe(id); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/review/review.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ModelType, DocumentType } from '@typegoose/typegoose/lib/types'; 3 | import { Types } from 'mongoose'; 4 | import { InjectModel } from 'nestjs-typegoose'; 5 | import { CreateReviewDto } from './dto/create-review.dto'; 6 | import { ReviewModel } from './review.model'; 7 | 8 | @Injectable() 9 | export class ReviewService { 10 | constructor(@InjectModel(ReviewModel) private readonly reviewModel: ModelType) { } 11 | 12 | async create(dto: CreateReviewDto): Promise> { 13 | return this.reviewModel.create(dto); 14 | } 15 | 16 | async delete(id: string): Promise | null> { 17 | return this.reviewModel.findByIdAndDelete(id).exec(); 18 | } 19 | 20 | async findByProductId(productId: string): Promise[]> { 21 | return this.reviewModel.find({ productId: new Types.ObjectId(productId) }).exec(); 22 | } 23 | 24 | async deleteByProductId(productId: string) { 25 | return this.reviewModel.deleteMany({ productId: new Types.ObjectId(productId) }).exec(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/sitemap/sitemap.constants.ts: -------------------------------------------------------------------------------- 1 | import { TopLevelCategory } from 'src/top-page/top-page.model'; 2 | 3 | type routeMapType = Record; 4 | 5 | export const CATEGORY_URL: routeMapType = { 6 | 0: '/courses', 7 | 1: '/services', 8 | 2: '/books', 9 | 3: '/products' 10 | } -------------------------------------------------------------------------------- /src/sitemap/sitemap.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Header } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { TopPageService } from 'src/top-page/top-page.service'; 4 | import { subDays, format } from 'date-fns'; 5 | import { Builder } from 'xml2js'; 6 | import { CATEGORY_URL } from './sitemap.constants'; 7 | 8 | @Controller('sitemap') 9 | export class SitemapController { 10 | domain: string; 11 | 12 | constructor( 13 | private readonly topPageService: TopPageService, 14 | private readonly configService: ConfigService 15 | ) { 16 | this.domain = this.configService.get('DOMAIN') ?? ''; 17 | } 18 | 19 | @Get('xml') 20 | @Header('content-type', 'text/xml') 21 | async sitemap() { 22 | const formatString = 'yyyy-MM-dd\'T\'HH:mm:00.000xxx'; 23 | let res = [{ 24 | loc: this.domain, 25 | lastmod: format(subDays(new Date(), 1), formatString), 26 | changefreq: 'daily', 27 | priority: '1.0' 28 | }, { 29 | loc: `${this.domain}/courses`, 30 | lastmod: format(subDays(new Date(), 1), formatString), 31 | changefreq: 'daily', 32 | priority: '1.0' 33 | }]; 34 | const pages = await this.topPageService.findAll(); 35 | res = res.concat(pages.map(page => { 36 | return { 37 | loc: `${this.domain}${CATEGORY_URL[page.firstCategory]}/${page.alias}`, 38 | lastmod: format(new Date(page.updatedAt ?? new Date()), formatString), 39 | changefreq: 'weekly', 40 | priority: '0.7' 41 | } 42 | })) 43 | const builder = new Builder({ 44 | xmldec: { version: '1.0', encoding: 'UTF-8' } 45 | }) 46 | return builder.buildObject({ 47 | urlset: { 48 | $: { 49 | xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' 50 | }, 51 | url: res 52 | } 53 | }) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/sitemap/sitemap.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TopPageModule } from 'src/top-page/top-page.module'; 4 | import { SitemapController } from './sitemap.controller'; 5 | 6 | @Module({ 7 | controllers: [SitemapController], 8 | imports: [TopPageModule, ConfigModule] 9 | }) 10 | export class SitemapModule { } 11 | -------------------------------------------------------------------------------- /src/telegram/telegram.constants.ts: -------------------------------------------------------------------------------- 1 | export const TELEGRAM_MODULE_OPTIONS = 'TELEGRAM_MODULE_OPTIONS'; -------------------------------------------------------------------------------- /src/telegram/telegram.interface.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata } from '@nestjs/common'; 2 | 3 | export interface ITelegramOptions { 4 | chatId: string; 5 | token: string; 6 | } 7 | 8 | export interface ITelegramModuleAsyncOptions extends Pick { 9 | useFactory: (...args: any[]) => Promise | ITelegramOptions; 10 | inject?: any[]; 11 | } -------------------------------------------------------------------------------- /src/telegram/telegram.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module, Provider } from '@nestjs/common'; 2 | import { TELEGRAM_MODULE_OPTIONS } from './telegram.constants'; 3 | import { ITelegramModuleAsyncOptions } from './telegram.interface'; 4 | import { TelegramService } from './telegram.service'; 5 | 6 | @Global() 7 | @Module({}) 8 | export class TelegramModule { 9 | static forRootAsync(options: ITelegramModuleAsyncOptions): DynamicModule { 10 | const asyncOptions = this.createAsyncOptionsProvider(options); 11 | return { 12 | module: TelegramModule, 13 | imports: options.imports, 14 | providers: [TelegramService, asyncOptions], 15 | exports: [TelegramService] 16 | } 17 | } 18 | 19 | private static createAsyncOptionsProvider(options: ITelegramModuleAsyncOptions): Provider { 20 | return { 21 | provide: TELEGRAM_MODULE_OPTIONS, 22 | useFactory: async (...args: any[]) => { 23 | const config = await options.useFactory(...args); 24 | return config; 25 | }, 26 | inject: options.inject || [] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/telegram/telegram.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Telegraf } from 'telegraf'; 3 | import { TELEGRAM_MODULE_OPTIONS } from './telegram.constants'; 4 | import { ITelegramOptions } from './telegram.interface'; 5 | 6 | @Injectable() 7 | export class TelegramService { 8 | bot: Telegraf; 9 | options: ITelegramOptions; 10 | 11 | constructor( 12 | @Inject(TELEGRAM_MODULE_OPTIONS) options: ITelegramOptions 13 | ) { 14 | this.bot = new Telegraf(options.token); 15 | this.options = options; 16 | } 17 | 18 | async sendMessage(message: string, chatId: string = this.options.chatId) { 19 | await this.bot.telegram.sendMessage(chatId, message); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/top-page/dto/create-top-page.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsArray, IsDate, IsEnum, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; 3 | import { TopLevelCategory } from '../top-page.model'; 4 | 5 | export class HhDataDto { 6 | @IsNumber() 7 | count: number; 8 | 9 | @IsNumber() 10 | juniorSalary: number; 11 | 12 | @IsNumber() 13 | middleSalary: number; 14 | 15 | @IsNumber() 16 | seniorSalary: number; 17 | 18 | @IsString() 19 | updatedAt: Date; 20 | } 21 | 22 | export class TopPageAdvantageDto { 23 | @IsString() 24 | title: string; 25 | 26 | @IsString() 27 | description: string; 28 | } 29 | 30 | export class CreateTopPageDto { 31 | @IsEnum(TopLevelCategory) 32 | firstCategory: TopLevelCategory; 33 | 34 | @IsString() 35 | secondCategory: string; 36 | 37 | @IsString() 38 | alias: string; 39 | 40 | @IsString() 41 | title: string; 42 | 43 | @IsString() 44 | metaTitle: string; 45 | 46 | @IsString() 47 | metaDescription: string; 48 | 49 | @IsString() 50 | category: string; 51 | 52 | @IsOptional() 53 | @ValidateNested() 54 | @Type(() => HhDataDto) 55 | hh?: HhDataDto; 56 | 57 | @IsArray() 58 | @IsOptional() 59 | @ValidateNested() 60 | @Type(() => TopPageAdvantageDto) 61 | advantages?: TopPageAdvantageDto[]; 62 | 63 | @IsString() 64 | @IsOptional() 65 | seoText?: string; 66 | 67 | @IsString() 68 | tagsTitle: string; 69 | 70 | @IsArray() 71 | @IsString({ each: true }) 72 | tags: string[]; 73 | } -------------------------------------------------------------------------------- /src/top-page/dto/find-top-page.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum } from 'class-validator'; 2 | import { TopLevelCategory } from '../top-page.model'; 3 | 4 | export class FindTopPageDto { 5 | @IsEnum(TopLevelCategory) 6 | firstCategory: TopLevelCategory; 7 | } -------------------------------------------------------------------------------- /src/top-page/top-page.constants.ts: -------------------------------------------------------------------------------- 1 | export const NOT_FOUND_TOP_PAGE_ERROR = 'Страница с таким ID не найдена'; -------------------------------------------------------------------------------- /src/top-page/top-page.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | Logger, 8 | NotFoundException, 9 | Param, 10 | Patch, 11 | Post, 12 | UseGuards, 13 | UsePipes, 14 | ValidationPipe 15 | } from '@nestjs/common'; 16 | import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule'; 17 | import { JwtAuthGuard } from 'src/auth/guards/jwt.guard'; 18 | import { HhService } from 'src/hh/hh.service'; 19 | import { IdValidationPipe } from 'src/pipes/ad-validation.pipe'; 20 | import { CreateTopPageDto } from './dto/create-top-page.dto'; 21 | import { FindTopPageDto } from './dto/find-top-page.dto'; 22 | import { NOT_FOUND_TOP_PAGE_ERROR } from './top-page.constants'; 23 | import { TopPageService } from './top-page.service'; 24 | 25 | @Controller('top-page') 26 | export class TopPageController { 27 | constructor( 28 | private readonly topPageService: TopPageService, 29 | private readonly hhService: HhService, 30 | private readonly scheduleRegistry: SchedulerRegistry 31 | ) { } 32 | 33 | @UseGuards(JwtAuthGuard) 34 | @UsePipes(new ValidationPipe()) 35 | @Post('create') 36 | async create(@Body() dto: CreateTopPageDto) { 37 | return this.topPageService.create(dto); 38 | } 39 | 40 | @UseGuards(JwtAuthGuard) 41 | @Get(':id') 42 | async get(@Param('id', IdValidationPipe) id: string) { 43 | const page = await this.topPageService.findById(id); 44 | if (!page) { 45 | throw new NotFoundException(NOT_FOUND_TOP_PAGE_ERROR); 46 | } 47 | return page; 48 | } 49 | 50 | @Get('byAlias/:alias') 51 | async getByAlias(@Param('alias') alias: string) { 52 | const page = await this.topPageService.findByAlias(alias); 53 | if (!page) { 54 | throw new NotFoundException(NOT_FOUND_TOP_PAGE_ERROR); 55 | } 56 | return page; 57 | } 58 | 59 | @UseGuards(JwtAuthGuard) 60 | @Delete(':id') 61 | async delete(@Param('id') id: string) { 62 | const detetedPage = await this.topPageService.deleteById(id); 63 | if (!detetedPage) { 64 | throw new NotFoundException(NOT_FOUND_TOP_PAGE_ERROR); 65 | } 66 | } 67 | 68 | @UseGuards(JwtAuthGuard) 69 | @UsePipes(new ValidationPipe()) 70 | @Patch(':id') 71 | async patch(@Param('id') id: string, @Body() dto: CreateTopPageDto) { 72 | const updatedPage = await this.topPageService.updateById(id, dto); 73 | if (!updatedPage) { 74 | throw new NotFoundException(NOT_FOUND_TOP_PAGE_ERROR); 75 | } 76 | return updatedPage; 77 | } 78 | 79 | @UsePipes(new ValidationPipe()) 80 | @HttpCode(200) 81 | @Post('find') 82 | async find(@Body() dto: FindTopPageDto) { 83 | return this.topPageService.findByCategory(dto.firstCategory); 84 | } 85 | 86 | @Get('textSearch/:text') 87 | async textSearch(@Param('text') text: string) { 88 | return this.topPageService.findByText(text); 89 | } 90 | 91 | @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) 92 | async test() { 93 | const data = await this.topPageService.findForHhUpdate(new Date()); 94 | for (let page of data) { 95 | const hhData = await this.hhService.getData(page.category); 96 | page.hh = hhData; 97 | await this.topPageService.updateById(page._id, page); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/top-page/top-page.model.ts: -------------------------------------------------------------------------------- 1 | import { prop, index } from '@typegoose/typegoose'; 2 | import { Base, TimeStamps } from '@typegoose/typegoose/lib/defaultClasses'; 3 | 4 | export enum TopLevelCategory { 5 | Courses, 6 | Services, 7 | Books, 8 | Products 9 | } 10 | 11 | export class HhData { 12 | @prop() 13 | count: number; 14 | 15 | @prop() 16 | juniorSalary: number; 17 | 18 | @prop() 19 | middleSalary: number; 20 | 21 | @prop() 22 | seniorSalary: number; 23 | 24 | @prop() 25 | updatedAt: Date; 26 | } 27 | 28 | export class TopPageAdvantage { 29 | @prop() 30 | title: string; 31 | 32 | @prop() 33 | description: string; 34 | } 35 | 36 | export interface TopPageModel extends Base { } 37 | 38 | @index({ '$**': 'text' }) 39 | export class TopPageModel extends TimeStamps { 40 | @prop({ enum: TopLevelCategory }) 41 | firstCategory: TopLevelCategory; 42 | 43 | @prop() 44 | secondCategory: string; 45 | 46 | @prop({ unique: true }) 47 | alias: string; 48 | 49 | @prop() 50 | title: string; 51 | 52 | @prop() 53 | metaTitle: string; 54 | 55 | @prop() 56 | metaDescription: string; 57 | 58 | @prop() 59 | category: string; 60 | 61 | @prop({ type: () => HhData }) 62 | hh?: HhData; 63 | 64 | @prop({ type: () => [TopPageAdvantage] }) 65 | advantages?: TopPageAdvantage[]; 66 | 67 | @prop() 68 | seoText?: string; 69 | 70 | @prop() 71 | tagsTitle: string; 72 | 73 | @prop({ type: () => [String] }) 74 | tags: string[]; 75 | } 76 | -------------------------------------------------------------------------------- /src/top-page/top-page.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypegooseModule } from 'nestjs-typegoose'; 3 | import { HhModule } from 'src/hh/hh.module'; 4 | import { TopPageController } from './top-page.controller'; 5 | import { TopPageModel } from './top-page.model'; 6 | import { TopPageService } from './top-page.service'; 7 | 8 | @Module({ 9 | controllers: [TopPageController], 10 | imports: [ 11 | TypegooseModule.forFeature([ 12 | { 13 | typegooseClass: TopPageModel, 14 | schemaOptions: { 15 | collection: 'TopPage' 16 | } 17 | } 18 | ]), 19 | HhModule 20 | ], 21 | providers: [TopPageService], 22 | exports: [TopPageService] 23 | }) 24 | export class TopPageModule { } 25 | -------------------------------------------------------------------------------- /src/top-page/top-page.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ModelType } from '@typegoose/typegoose/lib/types'; 3 | import { InjectModel } from 'nestjs-typegoose'; 4 | import { CreateTopPageDto } from './dto/create-top-page.dto'; 5 | import { TopLevelCategory, TopPageModel } from './top-page.model'; 6 | import { addDays } from 'date-fns'; 7 | import { Types } from 'mongoose'; 8 | 9 | @Injectable() 10 | export class TopPageService { 11 | constructor(@InjectModel(TopPageModel) private readonly topPageModel: ModelType) { } 12 | 13 | async create(dto: CreateTopPageDto) { 14 | return this.topPageModel.create(dto); 15 | } 16 | 17 | async findById(id: string) { 18 | return this.topPageModel.findById(id).exec(); 19 | } 20 | 21 | async findByAlias(alias: string) { 22 | return this.topPageModel.findOne({ alias }).exec(); 23 | } 24 | 25 | async findAll() { 26 | return this.topPageModel.find({}).exec(); 27 | } 28 | 29 | async findByCategory(firstCategory: TopLevelCategory) { 30 | return this.topPageModel 31 | .aggregate() 32 | .match({ 33 | firstCategory 34 | }) 35 | .group({ 36 | _id: { secondCategory: '$secondCategory' }, 37 | pages: { $push: { alias: '$alias', title: '$title', _id: '$_id', category: '$category' } } 38 | }).exec(); 39 | } 40 | 41 | async findByText(text: string) { 42 | return this.topPageModel.find({ $text: { $search: text, $caseSensitive: false } }).exec(); 43 | } 44 | 45 | async deleteById(id: string) { 46 | return this.topPageModel.findByIdAndRemove(id).exec(); 47 | } 48 | 49 | async updateById(id: string | Types.ObjectId, dto: CreateTopPageDto) { 50 | return this.topPageModel.findByIdAndUpdate(id, dto, { new: true }).exec(); 51 | } 52 | 53 | async findForHhUpdate(date: Date) { 54 | return this.topPageModel.find({ 55 | firstCategory: 0, 56 | $or: [ 57 | { 'hh.updatedAt': { $lt: addDays(date, -1) } }, 58 | { 'hh.updatedAt': { $exists: false } } 59 | ] 60 | }).exec(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { disconnect } from 'mongoose'; 6 | import { AuthDto } from 'src/auth/dto/auth.dto'; 7 | 8 | const loginDto: AuthDto = { 9 | login: 'a@a.ru', 10 | password: '1' 11 | }; 12 | 13 | describe('AuthController (e2e)', () => { 14 | let app: INestApplication; 15 | let createdId: string; 16 | let token: string; 17 | 18 | beforeEach(async () => { 19 | const moduleFixture: TestingModule = await Test.createTestingModule({ 20 | imports: [AppModule], 21 | }).compile(); 22 | 23 | app = moduleFixture.createNestApplication(); 24 | await app.init(); 25 | }); 26 | 27 | it('/auth/login (POST) - success', async (done) => { 28 | return request(app.getHttpServer()) 29 | .post('/auth/login') 30 | .send(loginDto) 31 | .expect(200) 32 | .then(({ body }: request.Response) => { 33 | expect(body.access_token).toBeDefined(); 34 | done(); 35 | }); 36 | }); 37 | 38 | it('/auth/login (POST) - fail password', () => { 39 | return request(app.getHttpServer()) 40 | .post('/auth/login') 41 | .send({ ...loginDto, password: '2' }) 42 | .expect(401, { 43 | statusCode: 401, 44 | message: "Неверный пароль", 45 | error: "Unauthorized" 46 | }); 47 | }); 48 | 49 | it('/auth/login (POST) - fail password', () => { 50 | return request(app.getHttpServer()) 51 | .post('/auth/login') 52 | .send({ ...loginDto, login: 'aaa@a.ru' }) 53 | .expect(401, { 54 | statusCode: 401, 55 | message: "Пользователь с таким email не найден", 56 | error: "Unauthorized" 57 | }); 58 | }); 59 | 60 | afterAll(() => { 61 | disconnect(); 62 | }); 63 | }); -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": [ 3 | "js", 4 | "json", 5 | "ts" 6 | ], 7 | "rootDir": ".", 8 | "testEnvironment": "node", 9 | "testRegex": ".e2e-spec.ts$", 10 | "transform": { 11 | "^.+\\.(t|j)s$": "ts-jest" 12 | } 13 | } -------------------------------------------------------------------------------- /test/review.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { CreateReviewDto } from '../src/review/dto/create-review.dto'; 6 | import { Types, disconnect } from 'mongoose'; 7 | import { REVIEW_NOT_FOUND } from '../src/review/review.constants'; 8 | import { AuthDto } from 'src/auth/dto/auth.dto'; 9 | 10 | const productId = new Types.ObjectId().toHexString(); 11 | 12 | const loginDto: AuthDto = { 13 | login: 'a@a.ru', 14 | password: '1' 15 | }; 16 | 17 | const testDto: CreateReviewDto = { 18 | name: 'Тест', 19 | title: 'Заголовок', 20 | description: 'Описание тестовое', 21 | rating: 5, 22 | productId 23 | }; 24 | 25 | describe('AppController (e2e)', () => { 26 | let app: INestApplication; 27 | let createdId: string; 28 | let token: string; 29 | 30 | beforeEach(async () => { 31 | const moduleFixture: TestingModule = await Test.createTestingModule({ 32 | imports: [AppModule], 33 | }).compile(); 34 | 35 | app = moduleFixture.createNestApplication(); 36 | await app.init(); 37 | 38 | const { body } = await request(app.getHttpServer()) 39 | .post('/auth/login') 40 | .send(loginDto); 41 | token = body.access_token; 42 | }); 43 | 44 | it('/review/create (POST) - success', async (done) => { 45 | return request(app.getHttpServer()) 46 | .post('/review/create') 47 | .send(testDto) 48 | .expect(201) 49 | .then(({ body }: request.Response) => { 50 | createdId = body._id; 51 | expect(createdId).toBeDefined(); 52 | done(); 53 | }); 54 | }); 55 | 56 | it('/review/create (POST) - fail', async (done) => { 57 | return request(app.getHttpServer()) 58 | .post('/review/create') 59 | .send({ ...testDto, rating: 0 }) 60 | .expect(400) 61 | .then(({ body }: request.Response) => { 62 | done(); 63 | }); 64 | }); 65 | 66 | it('/review/byProduct/:productId (GET) - success', async (done) => { 67 | return request(app.getHttpServer()) 68 | .get('/review/byProduct/' + productId) 69 | .expect(200) 70 | .then(({ body }: request.Response) => { 71 | expect(body.length).toBe(1); 72 | done(); 73 | }); 74 | }); 75 | 76 | it('/review/byProduct/:productId (GET) - fail', async (done) => { 77 | return request(app.getHttpServer()) 78 | .get('/review/byProduct/' + new Types.ObjectId().toHexString()) 79 | .expect(200) 80 | .then(({ body }: request.Response) => { 81 | expect(body.length).toBe(0); 82 | done(); 83 | }); 84 | }); 85 | 86 | it('/review/:id (DELETE) - success', () => { 87 | return request(app.getHttpServer()) 88 | .delete('/review/' + createdId) 89 | .set('Authorization', 'Bearer ' + token) 90 | .expect(200); 91 | }); 92 | 93 | it('/review/:id (DELETE) - fail', () => { 94 | return request(app.getHttpServer()) 95 | .delete('/review/' + new Types.ObjectId().toHexString()) 96 | .set('Authorization', 'Bearer ' + token) 97 | .expect(404, { 98 | statusCode: 404, 99 | message: REVIEW_NOT_FOUND 100 | }); 101 | }); 102 | 103 | afterAll(() => { 104 | disconnect(); 105 | }); 106 | }); -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "strict": true, 15 | "strictPropertyInitialization": false, 16 | "skipLibCheck": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-var-requires": true, 4 | "no-any": true, 5 | "promise-function-async": true, 6 | "await-promise": true, 7 | "curly": true, 8 | "prefer-for-of": true, 9 | "forin": true, 10 | "no-console": [ 11 | true, 12 | "log", 13 | "error" 14 | ], 15 | "no-debugger": true, 16 | "no-duplicate-super": true, 17 | "no-duplicate-switch-case": true, 18 | "no-invalid-template-strings": true, 19 | "no-misused-new": true, 20 | "no-return-await": true, 21 | "no-shadowed-variable": true, 22 | "no-switch-case-fall-through": true, 23 | "no-tautology-expression": true, 24 | "no-unused-variable": true, 25 | "no-var-keyword": true, 26 | "static-this": true, 27 | "switch-default": true, 28 | "triple-equals": false, 29 | "no-require-imports": false, 30 | "prefer-const": true, 31 | "arrow-return-shorthand": true, 32 | "class-name": true, 33 | "file-name-casing": [ 34 | true, 35 | "kebab-case" 36 | ], 37 | "prefer-switch": [ 38 | true, 39 | { 40 | "min-cases": 3 41 | } 42 | ], 43 | "switch-final-break": true, 44 | "import-spacing": true, 45 | "max-line-length": [ 46 | true, 47 | 120 48 | ], 49 | "no-trailing-whitespace": false, 50 | "quotemark": [ 51 | true, 52 | "single" 53 | ], 54 | "semicolon": [ 55 | true, 56 | "always" 57 | ], 58 | "trailing-comma": false, 59 | "indent": [ 60 | true, 61 | "tabs", 62 | 4 63 | ], 64 | "linterOptions": { 65 | "exclude": [ 66 | "./src/**/*.d.ts" 67 | ] 68 | } 69 | } 70 | } --------------------------------------------------------------------------------