├── .github └── FUNDING.yml ├── .env.sample ├── .prettierrc ├── rest ├── auth.json ├── http-client.env.json ├── menu.rest └── auth.rest ├── nest-cli.json ├── src ├── modules │ ├── admin │ │ ├── admin.module.ts │ │ ├── model │ │ │ └── admin.entity.ts │ │ └── db │ │ │ └── migrations │ │ │ └── 1593223368900-CreateFirstAdmin.ts │ ├── admin-menu │ │ ├── responses │ │ │ └── nested-tree-node.ts │ │ ├── admin-menu.module.ts │ │ ├── menus │ │ │ ├── menu-node.ts │ │ │ ├── menu.service.ts │ │ │ └── menu.service.spec.ts │ │ └── controllers │ │ │ └── menu.controller.ts │ └── auth │ │ ├── service │ │ ├── local.strategy.ts │ │ ├── jwt.strategy.ts │ │ └── auth.service.ts │ │ ├── auth.module.ts │ │ └── controller │ │ └── auth.controller.ts ├── app.service.ts ├── main.ts ├── app.controller.ts ├── app.module.ts └── app.controller.spec.ts ├── tsconfig.build.json ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── tsconfig.json ├── ormconfig.sample.json ├── .gitignore ├── .eslintrc.js ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | JWT_SECRET=secret 2 | JWT_EXPIRES_IN=15m 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /rest/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "login": "admin", 3 | "password": "secret" 4 | } 5 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /rest/http-client.env.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": { 3 | "host": "http://localhost:3000/" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/admin/admin.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | @Module({}) 4 | export class AdminModule {} 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/admin-menu/responses/nested-tree-node.ts: -------------------------------------------------------------------------------- 1 | export interface NestedTreeNode { 2 | name: string; 3 | href?: string; 4 | icon?: string; 5 | children?: NestedTreeNode[]; 6 | } 7 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /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, {cors: true}); 6 | await app.listen(3000); 7 | } 8 | bootstrap(); 9 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/admin-menu/admin-menu.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MenuController } from './controllers/menu.controller'; 3 | import { MenuService } from './menus/menu.service'; 4 | 5 | @Module({ 6 | controllers: [ 7 | MenuController, 8 | ], 9 | providers: [ 10 | MenuService, 11 | ] 12 | }) 13 | export class AdminMenuModule {} 14 | -------------------------------------------------------------------------------- /rest/menu.rest: -------------------------------------------------------------------------------- 1 | GET {{host}}menu 2 | Authorization: Bearer {{auth_token}} 3 | 4 | > {% 5 | client.test("Request executed successfully", function() { 6 | client.assert(response.status === 200, "Response status is not 200"); 7 | }); 8 | 9 | client.test("Request is array", function() { 10 | client.assert(Array.isArray(response.body), "Request is not array."); 11 | }); 12 | %} 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/admin-menu/menus/menu-node.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_MENU_NODE_ID = 'root'; 2 | export interface MenuNode { 3 | name: string; 4 | href?: string; 5 | icon?: string; 6 | children?: MenuNode[]; 7 | id: string; 8 | parentId: string; 9 | sortOrder: number; 10 | removed?: boolean; 11 | } 12 | 13 | export type PatchMenuNode = Pick 14 | & Partial> 15 | -------------------------------------------------------------------------------- /ormconfig.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "mysql", 3 | "host": "localhost", 4 | "port": 3306, 5 | "username": "root", 6 | "password": "", 7 | "database": "blog", 8 | "entities": ["dist/**/*.entity{.ts,.js}"], 9 | "dropSchema": false, 10 | "synchronize": true, 11 | "migrationsRun": false, 12 | "logging": true, 13 | "migrations": ["dist/modules/**/db/migrations/*{.ts,.js}"], 14 | "cli": { 15 | "migrationsDir": "db/migrations" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /rest/auth.rest: -------------------------------------------------------------------------------- 1 | POST {{host}}auth/login 2 | Content-Type: application/json 3 | 4 | < ./auth.json 5 | 6 | > {% 7 | client.global.set("auth_token", response.body.accessToken); 8 | client.test("Request executed successfully", function() { 9 | client.assert(response.status === 201, "Response status is not 201"); 10 | }); 11 | 12 | client.test("Request contains accessToken", function() { 13 | client.assert(!!response.body.accessToken, "Request does not contain accessToken"); 14 | }); 15 | %} 16 | -------------------------------------------------------------------------------- /src/modules/admin/model/admin.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Admin { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column({ nullable: false }) 9 | login: string; 10 | 11 | @Column({ nullable: false }) 12 | passwordHash?: string; 13 | 14 | @Column({ nullable: false }) 15 | nickName: string; 16 | 17 | @CreateDateColumn() 18 | createdAt: Date; 19 | 20 | @UpdateDateColumn() 21 | updatedAt: Date; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/admin-menu/controllers/menu.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, UseGuards, Get } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { NestedTreeNode } from '../responses/nested-tree-node'; 4 | import { MenuService } from '../menus/menu.service'; 5 | 6 | @Controller('menu') 7 | export class MenuController { 8 | 9 | constructor(private menuService: MenuService) { 10 | } 11 | 12 | @UseGuards(AuthGuard('jwt')) 13 | @Get() 14 | getProfile(): NestedTreeNode[] { 15 | return this.menuService.getMenu(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | ormconfig.json 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { AuthModule } from './modules/auth/auth.module'; 6 | import { ConfigModule } from '@nestjs/config'; 7 | import { AdminMenuModule } from './modules/admin-menu/admin-menu.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forRoot(), 12 | AuthModule, 13 | ConfigModule.forRoot(), 14 | AdminMenuModule, 15 | ], 16 | controllers: [AppController], 17 | providers: [AppService], 18 | }) 19 | export class AppModule {} 20 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/app.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 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/auth/service/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport'; 2 | import { Strategy } from 'passport-local'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from './auth.service'; 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private authService: AuthService) { 9 | super({ 10 | usernameField: 'login', 11 | }); 12 | } 13 | 14 | async validate(login: string, password: string): Promise { 15 | const admin = await this.authService.validateAdmin(login, password); 16 | 17 | if (!admin) { 18 | throw new UnauthorizedException(); 19 | } 20 | return admin; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/auth/service/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport'; 2 | import { ExtractJwt, Strategy } from 'passport-jwt'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { AuthService } from './auth.service'; 5 | import { ConfigService } from '@nestjs/config'; 6 | 7 | @Injectable() 8 | export class JwtStrategy extends PassportStrategy(Strategy) { 9 | constructor( 10 | private authService: AuthService, 11 | private configService: ConfigService 12 | ) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 15 | ignoreExpiration: false, 16 | secretOrKey: configService.get('JWT_SECRET'), 17 | }); 18 | } 19 | 20 | async validate(payload: any): Promise { 21 | const {iat, exp, ...res} = payload; 22 | return res; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AdminModule } from '../admin/admin.module'; 3 | import { AuthService } from './service/auth.service'; 4 | import { LocalStrategy } from './service/local.strategy'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { AuthController } from './controller/auth.controller'; 7 | import { JwtModule } from '@nestjs/jwt'; 8 | import { ConfigModule, ConfigService } from '@nestjs/config'; 9 | import { JwtStrategy } from './service/jwt.strategy'; 10 | 11 | @Module({ 12 | imports: [ 13 | AdminModule, 14 | PassportModule, 15 | ConfigModule, 16 | JwtModule.registerAsync({ 17 | imports: [ConfigModule], 18 | useFactory: (configService: ConfigService) => ({ 19 | secret: configService.get('JWT_SECRET'), 20 | signOptions: { expiresIn: configService.get('JWT_EXPIRES_IN') } 21 | }), 22 | inject: [ConfigService] 23 | }) 24 | ], 25 | controllers: [AuthController], 26 | providers: [AuthService, LocalStrategy, JwtStrategy] 27 | }) 28 | export class AuthModule {} 29 | -------------------------------------------------------------------------------- /src/modules/auth/service/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Admin } from '../../admin/model/admin.entity'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { Connection, Repository } from 'typeorm'; 5 | import * as bcrypt from 'bcrypt'; 6 | 7 | @Injectable() 8 | export class AuthService { 9 | private adminRepository: Repository; 10 | constructor( 11 | private jwtService: JwtService, 12 | private connection: Connection 13 | ) { 14 | this.adminRepository = this.connection.getRepository(Admin); 15 | } 16 | 17 | async validateAdmin(login: string, pass: string): Promise { 18 | const admin: Admin = await this.adminRepository.findOne({where: {login}}); 19 | 20 | if (admin && await bcrypt.compare(pass, admin.passwordHash)) { 21 | const {passwordHash, ...secureAdmin} = admin; 22 | return secureAdmin; 23 | } 24 | 25 | return null; 26 | } 27 | 28 | async login(admin: Admin) { 29 | const payload = { id: admin.id }; 30 | return { 31 | accessToken: this.jwtService.sign(payload) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/modules/auth/controller/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Request, Post, UseGuards, Get } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { AuthService } from '../service/auth.service'; 4 | import { Connection, Repository } from 'typeorm'; 5 | import { Admin } from '../../admin/model/admin.entity'; 6 | 7 | @Controller('auth') 8 | export class AuthController { 9 | private adminRepository: Repository; 10 | constructor( 11 | private authService: AuthService, 12 | private connection: Connection 13 | ) { 14 | this.adminRepository = this.connection.getRepository(Admin); 15 | } 16 | 17 | @UseGuards(AuthGuard('local')) 18 | @Post('login') 19 | async login(@Request() req) { 20 | return this.authService.login(req.user); 21 | } 22 | 23 | @UseGuards(AuthGuard('jwt')) 24 | @Get('profile') 25 | getProfile(@Request() req) { 26 | return req.user; 27 | } 28 | 29 | @UseGuards(AuthGuard('jwt')) 30 | @Post('refresh') 31 | async refresh(@Request() req) { 32 | const admin = await this.adminRepository.findOne(req.user.id); 33 | return this.authService.login(admin); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/admin/db/migrations/1593223368900-CreateFirstAdmin.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Repository } from 'typeorm'; 2 | import { Admin } from '../../model/admin.entity'; 3 | import * as bcrypt from 'bcrypt'; 4 | 5 | export class CreateFirstAdmin1593223368900 implements MigrationInterface { 6 | 7 | public async up(queryRunner: QueryRunner): Promise { 8 | const adminRepository: Repository = queryRunner.connection.getRepository(Admin); 9 | 10 | if (await adminRepository.findOne({where: {login: 'admin'}})) { 11 | return; 12 | } 13 | 14 | const admin: Admin = adminRepository.create({ 15 | login: 'admin', 16 | passwordHash: await bcrypt.hash('secret', 10), 17 | nickName: 'GromMax' 18 | }); 19 | 20 | await adminRepository.insert(admin); 21 | } 22 | 23 | public async down(queryRunner: QueryRunner): Promise { 24 | const adminRepository: Repository = queryRunner.connection.getRepository(Admin); 25 | const admin: Admin = await adminRepository.findOne({where: {login: 'admin'}}); 26 | if (!admin) { 27 | return; 28 | } 29 | 30 | await adminRepository.remove(admin); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json", 21 | "typeorm:migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:create -n ", 22 | "typeorm:migration:run": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:run" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^6.10.14", 26 | "@nestjs/config": "^0.5.0", 27 | "@nestjs/core": "^6.10.14", 28 | "@nestjs/jwt": "^7.0.0", 29 | "@nestjs/passport": "^7.0.0", 30 | "@nestjs/platform-express": "^6.10.14", 31 | "@nestjs/typeorm": "^7.1.0", 32 | "bcrypt": "^5.0.0", 33 | "mysql": "^2.18.1", 34 | "passport": "^0.4.1", 35 | "passport-jwt": "^4.0.0", 36 | "passport-local": "^1.0.0", 37 | "reflect-metadata": "^0.1.13", 38 | "rimraf": "^3.0.0", 39 | "rxjs": "^6.5.4", 40 | "typeorm": "^0.2.25" 41 | }, 42 | "devDependencies": { 43 | "@nestjs/cli": "^6.13.2", 44 | "@nestjs/schematics": "^6.8.1", 45 | "@nestjs/testing": "^6.11.11", 46 | "@types/express": "^4.17.2", 47 | "@types/jest": "25.1.2", 48 | "@types/node": "^13.1.6", 49 | "@types/passport-jwt": "^3.0.3", 50 | "@types/passport-local": "^1.0.33", 51 | "@types/supertest": "^2.0.8", 52 | "@typescript-eslint/eslint-plugin": "^2.12.0", 53 | "@typescript-eslint/parser": "^2.12.0", 54 | "eslint": "^6.7.2", 55 | "eslint-config-prettier": "^6.7.0", 56 | "eslint-plugin-import": "^2.19.1", 57 | "jest": "^24.9.0", 58 | "prettier": "^1.18.2", 59 | "supertest": "^4.0.2", 60 | "ts-jest": "25.2.0", 61 | "ts-loader": "^6.2.1", 62 | "ts-node": "^8.6.0", 63 | "tsconfig-paths": "^3.9.0", 64 | "typescript": "^3.7.4" 65 | }, 66 | "jest": { 67 | "moduleFileExtensions": [ 68 | "js", 69 | "json", 70 | "ts" 71 | ], 72 | "rootDir": "src", 73 | "testRegex": ".spec.ts$", 74 | "transform": { 75 | "^.+\\.(t|j)s$": "ts-jest" 76 | }, 77 | "coverageDirectory": "../coverage", 78 | "testEnvironment": "node" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/modules/admin-menu/menus/menu.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { MenuNode, PatchMenuNode, ROOT_MENU_NODE_ID } from './menu-node'; 3 | 4 | @Injectable() 5 | export class MenuService { 6 | private nodes: {[id: string]: MenuNode} = {}; 7 | private patches: PatchMenuNode[] = []; 8 | /** 9 | * Получить древовидное меню. 10 | * 11 | * Если меню не найдено, то вернется пустой массив. 12 | * Все меню строится от корня. 13 | * Листовые узлы обязаны иметь ссылку или будут удалены. 14 | * Ветки не могут иметь ссылку, она будет очищена. 15 | */ 16 | getMenu(): MenuNode[] { 17 | const nodeMap: {[id: string]: MenuNode} = {}; 18 | let src = Object.values(this.nodes).map(node => { 19 | const copy = {...node}; 20 | nodeMap[copy.id] = copy; 21 | return copy; 22 | }); 23 | 24 | this.patches.forEach(patch => { 25 | if (nodeMap[patch.id]) { 26 | Object.assign(nodeMap[patch.id], patch); 27 | } 28 | }) 29 | 30 | src = src.filter(node => !node.removed); 31 | // отсортировать (нет теста) 32 | 33 | return this.getMenuForNode(ROOT_MENU_NODE_ID, src); 34 | } 35 | 36 | private getMenuForNode(id: string, src: MenuNode[]): MenuNode[] { 37 | const res = src.filter(node => node.parentId === id) 38 | .map(({href, ...node}) => { 39 | const children = this.getMenuForNode(node.id, src); 40 | 41 | if (children.length > 0) { 42 | return { 43 | ...node, 44 | children 45 | }; 46 | } 47 | 48 | return { 49 | ...node, 50 | href, 51 | removed: !href, 52 | children: [] 53 | }; 54 | }).filter(node => !node.removed); 55 | 56 | res.sort((a, b) => a.sortOrder - b.sortOrder); 57 | 58 | return res; 59 | } 60 | 61 | /** 62 | * Добавить конфигурацию узла 63 | * 64 | * Дочерние узлы будут связаны с родителем даже если 65 | * они имеют свой признак parentId 66 | */ 67 | add(...nodes: MenuNode[]): void { 68 | nodes.forEach(node => { 69 | const sanitizedChildren = node.children?.map(child => ({ 70 | ...child, 71 | parentId: node.id 72 | })) || []; 73 | 74 | const {children, ...sanitizedNode} = node; 75 | this.add(...sanitizedChildren); 76 | 77 | this.nodes[node.id] = sanitizedNode; 78 | }); 79 | } 80 | 81 | /** 82 | * Модифицировать конфигурацию узла 83 | * 84 | * Древовидная модификация не поддерживается 85 | */ 86 | patch(...patches: PatchMenuNode[]): void { 87 | this.patches = [ 88 | ...this.patches, 89 | ...patches 90 | ]; 91 | } 92 | 93 | /** 94 | * По ID удаляется узел из дерева 95 | * 96 | * При потере ссылки на корень все дети также не попадут в результат 97 | */ 98 | remove(...ids: string[]): void { 99 | this.patch(...ids.map(id => ({ 100 | id, 101 | removed: true 102 | }))); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [travis-image]: https://api.travis-ci.org/nestjs/nest.svg?branch=master 6 | [travis-url]: https://travis-ci.org/nestjs/nest 7 | [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux 8 | [linux-url]: https://travis-ci.org/nestjs/nest 9 | 10 |

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

11 |

12 | NPM Version 13 | Package License 14 | NPM Downloads 15 | Travis 16 | Linux 17 | Coverage 18 | Gitter 19 | Backers on Open Collective 20 | Sponsors on Open Collective 21 | 22 | 23 |

24 | 26 | 27 | ## Description 28 | 29 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 30 | 31 | ## Installation 32 | 33 | ```bash 34 | $ npm install 35 | ``` 36 | 37 | ## Running the app 38 | 39 | ```bash 40 | # development 41 | $ npm run start 42 | 43 | # watch mode 44 | $ npm run start:dev 45 | 46 | # production mode 47 | $ npm run start:prod 48 | ``` 49 | 50 | ## Test 51 | 52 | ```bash 53 | # unit tests 54 | $ npm run test 55 | 56 | # e2e tests 57 | $ npm run test:e2e 58 | 59 | # test coverage 60 | $ npm run test:cov 61 | ``` 62 | 63 | ## Support 64 | 65 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 66 | 67 | ## Stay in touch 68 | 69 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 70 | - Website - [https://nestjs.com](https://nestjs.com/) 71 | - Twitter - [@nestframework](https://twitter.com/nestframework) 72 | 73 | ## License 74 | 75 | Nest is [MIT licensed](LICENSE). 76 | -------------------------------------------------------------------------------- /src/modules/admin-menu/menus/menu.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { MenuService } from './menu.service'; 2 | import { ROOT_MENU_NODE_ID } from './menu-node'; 3 | 4 | describe('MenuService Unit Test', () => { 5 | let menuService: MenuService 6 | beforeEach(() => { 7 | menuService = new MenuService(); 8 | }); 9 | 10 | describe('Add Node', () => { 11 | it('Пустой сервис вернет пустой массив', () => { 12 | expect(menuService.getMenu()).toHaveLength(0); 13 | }); 14 | 15 | it('Узел без связи к корню не вернется', () => { 16 | menuService.add({ 17 | id: 'foo', 18 | parentId: 'bar', 19 | sortOrder: 10, 20 | name: 'foo', 21 | href: 'https://foo.com' 22 | }); 23 | expect(menuService.getMenu()).toHaveLength(0); 24 | }); 25 | 26 | it('Узел со связью к корню должен быть добавлен', () => { 27 | menuService.add({ 28 | id: 'foo', 29 | parentId: ROOT_MENU_NODE_ID, 30 | sortOrder: 10, 31 | name: 'foo', 32 | href: 'https://foo.com' 33 | }); 34 | expect(menuService.getMenu()).toHaveLength(1); 35 | }); 36 | 37 | it('Листовой узел без ссылки должен быть удален', () => { 38 | menuService.add({ 39 | id: 'foo', 40 | parentId: ROOT_MENU_NODE_ID, 41 | sortOrder: 10, 42 | name: 'foo', 43 | }); 44 | expect(menuService.getMenu()).toHaveLength(0); 45 | }); 46 | 47 | it('Узлы с одинаковым ID перетирают друг дурга', () => { 48 | menuService.add({ 49 | id: 'foo', 50 | parentId: ROOT_MENU_NODE_ID, 51 | sortOrder: 10, 52 | name: 'foo', 53 | href: 'https://foo.com' 54 | }); 55 | 56 | menuService.add({ 57 | id: 'foo', 58 | parentId: ROOT_MENU_NODE_ID, 59 | sortOrder: 20, 60 | name: 'foo', 61 | href: 'https://foo.com' 62 | }); 63 | const menu = menuService.getMenu(); 64 | expect(menu).toHaveLength(1); 65 | expect(menu[0].sortOrder).toBe(20); 66 | }); 67 | 68 | it('Узлы выстраиваются в иерархию', () => { 69 | menuService.add({ 70 | id: 'bar', 71 | parentId: 'foo', 72 | sortOrder: 10, 73 | name: 'bar', 74 | href: 'https://bar.com' 75 | }); 76 | 77 | menuService.add({ 78 | id: 'foo', 79 | parentId: ROOT_MENU_NODE_ID, 80 | sortOrder: 20, 81 | name: 'foo', 82 | href: 'https://foo.com' 83 | }); 84 | const menu = menuService.getMenu(); 85 | expect(menu).toHaveLength(1); 86 | expect(menu[0].id).toBe('foo'); 87 | expect(menu[0].children).toHaveLength(1); 88 | expect(menu[0].children[0].id).toBe('bar'); 89 | }); 90 | 91 | it('Вложенные узлы добавляются с привязкой к родителю', () => { 92 | menuService.add({ 93 | id: 'foo', 94 | parentId: ROOT_MENU_NODE_ID, 95 | sortOrder: 20, 96 | name: 'foo', 97 | href: 'https://foo.com', 98 | children: [ 99 | { 100 | id: 'bar', 101 | parentId: ROOT_MENU_NODE_ID, 102 | sortOrder: 10, 103 | name: 'bar', 104 | href: 'https://bar.com' 105 | } 106 | ] 107 | }); 108 | 109 | const menu = menuService.getMenu(); 110 | expect(menu).toHaveLength(1); 111 | expect(menu[0].id).toBe('foo'); 112 | expect(menu[0].children).toHaveLength(1); 113 | expect(menu[0].children[0].id).toBe('bar'); 114 | }); 115 | }); 116 | 117 | describe('Patch node', () => { 118 | it('Добавленный узел можно переписать' + 119 | ' независимо от очередности вызова', () => { 120 | menuService.patch({ 121 | id: 'foo', 122 | sortOrder: 77 123 | }); 124 | menuService.add({ 125 | id: 'foo', 126 | parentId: ROOT_MENU_NODE_ID, 127 | sortOrder: 20, 128 | name: 'foo', 129 | href: 'https://foo.com', 130 | }); 131 | 132 | const menu = menuService.getMenu(); 133 | expect(menu[0].sortOrder).toBe(77); 134 | expect(menu[0].name).toBe('foo'); 135 | }); 136 | 137 | it('Patch не добавляет узел, а лишь модифицирует', () => { 138 | menuService.patch({ 139 | id: 'foo', 140 | sortOrder: 77 141 | }); 142 | 143 | const menu = menuService.getMenu(); 144 | expect(menu).toHaveLength(0); 145 | }); 146 | 147 | it('Можно иметь более 1 патча', () => { 148 | menuService.patch({ 149 | id: 'foo', 150 | sortOrder: 77, 151 | href: 'https://bar.com' 152 | }); 153 | menuService.add({ 154 | id: 'foo', 155 | parentId: ROOT_MENU_NODE_ID, 156 | sortOrder: 20, 157 | name: 'foo', 158 | href: 'https://foo.com', 159 | }); 160 | menuService.patch({ 161 | id: 'foo', 162 | sortOrder: 13, 163 | name: 'bar' 164 | }); 165 | 166 | const menu = menuService.getMenu(); 167 | expect(menu[0].sortOrder).toBe(13); 168 | expect(menu[0].href).toBe('https://bar.com'); 169 | expect(menu[0].name).toBe('bar'); 170 | }); 171 | }); 172 | 173 | describe('Remove node', () => { 174 | it('Узел может быть удален по его ID', () => { 175 | menuService.remove('foo', 'bar'); 176 | menuService.add({ 177 | id: 'foo', 178 | parentId: ROOT_MENU_NODE_ID, 179 | sortOrder: 20, 180 | name: 'foo', 181 | href: 'https://foo.com', 182 | }); 183 | 184 | expect(menuService.getMenu()).toHaveLength(0); 185 | }); 186 | 187 | it('Patch может отменить удаление', () => { 188 | menuService.add({ 189 | id: 'foo', 190 | parentId: ROOT_MENU_NODE_ID, 191 | sortOrder: 20, 192 | name: 'foo', 193 | href: 'https://foo.com', 194 | }); 195 | menuService.remove('foo', 'bar'); 196 | menuService.patch({ 197 | id: 'foo', removed: false 198 | }, { 199 | id: 'bar', removed: false 200 | }); 201 | 202 | expect(menuService.getMenu()).toHaveLength(1); 203 | }); 204 | 205 | it('Patch === remove, зависят от очереди вызова', () => { 206 | menuService.add({ 207 | id: 'foo', 208 | parentId: ROOT_MENU_NODE_ID, 209 | sortOrder: 20, 210 | name: 'foo', 211 | href: 'https://foo.com', 212 | }); 213 | menuService.patch({ 214 | id: 'foo', removed: false 215 | }, { 216 | id: 'bar', removed: false 217 | }); 218 | menuService.remove('foo', 'bar'); 219 | 220 | expect(menuService.getMenu()).toHaveLength(0); 221 | }); 222 | }); 223 | 224 | describe('Sorting', () => { 225 | it('Узлы должны сортироваться', () => { 226 | menuService.add({ 227 | id: 'bar', 228 | parentId: ROOT_MENU_NODE_ID, 229 | sortOrder: 20, 230 | name: 'bar', 231 | href: 'https://bar.com', 232 | }); 233 | menuService.add({ 234 | id: 'foo', 235 | parentId: ROOT_MENU_NODE_ID, 236 | sortOrder: 10, 237 | name: 'foo', 238 | href: 'https://foo.com', 239 | }); 240 | const menu = menuService.getMenu(); 241 | expect(menu[0].id).toBe('foo'); 242 | }) 243 | }) 244 | }); 245 | --------------------------------------------------------------------------------