Browse Source

Initial commit

main
Un-dev 6 years ago
commit
1cc5f609db
  1. 175
      .circleci/config.yml
  2. 7
      .editorconfig
  3. 243
      .gitignore
  4. 4
      .prettierrc
  5. 10
      Dockerfile
  6. 29
      README.md
  7. 6
      compodoc.server.json
  8. 11
      docker/postgresql.yml
  9. 13
      exemple.env
  10. 24
      internals/generator/index.js
  11. 0
      internals/generator/module/constants.ts.hbs
  12. 0
      internals/generator/module/controller.spec.ts.hbs
  13. 89
      internals/generator/module/controller.ts.hbs
  14. 7
      internals/generator/module/dto.ts.hbs
  15. 9
      internals/generator/module/entity.ts.hbs
  16. 203
      internals/generator/module/index.js
  17. 16
      internals/generator/module/module.ts.hbs
  18. 12
      internals/generator/module/repository.ts.hbs
  19. 5
      internals/generator/module/routing.ts.hbs
  20. 0
      internals/generator/module/service.spec.ts.hbs
  21. 54
      internals/generator/module/service.ts.hbs
  22. 20
      internals/generator/utils/moduleExists.js
  23. 5
      nest-cli.json
  24. 6
      nodemon-debug.json
  25. 6
      nodemon.json
  26. 109
      package.json
  27. 22
      src/app.controller.spec.ts
  28. 12
      src/app.controller.ts
  29. 43
      src/app.module.ts
  30. 22
      src/app.routes.ts
  31. 8
      src/app.service.ts
  32. 8
      src/decorators/currentUser.decorator.ts
  33. 3
      src/decorators/roles.decorator.ts
  34. 0
      src/exceptions/.gitkeep
  35. 0
      src/filters/.gitkeep
  36. 22
      src/guards/roles.guard.ts
  37. 14
      src/interceptors/transform.interceptor.ts
  38. 0
      src/interfaces/.gitkeep
  39. 62
      src/main.ts
  40. 0
      src/middlewares/.gitkeep
  41. 39
      src/modules/core/auth/auth.controller.ts
  42. 17
      src/modules/core/auth/auth.dto.ts
  43. 33
      src/modules/core/auth/auth.module.ts
  44. 30
      src/modules/core/auth/auth.service.ts
  45. 22
      src/modules/core/auth/jwt-auth.guard.ts
  46. 26
      src/modules/core/auth/jwt.strategy.ts
  47. 9
      src/modules/core/config/config.module.ts
  48. 75
      src/modules/core/config/config.service.ts
  49. 8
      src/modules/core/crypto/crypto.module.ts
  50. 15
      src/modules/core/crypto/crypto.service.spec.ts
  51. 28
      src/modules/core/crypto/crypto.service.ts
  52. 46
      src/modules/core/logger/logger-exception.interceptor.ts
  53. 9
      src/modules/core/logger/logger.constants.ts
  54. 11
      src/modules/core/logger/logger.module.ts
  55. 33
      src/modules/core/logger/logger.providers.ts
  56. 18
      src/modules/core/logger/logger.service.spec.ts
  57. 46
      src/modules/core/logger/logger.service.ts
  58. 10
      src/modules/core/pagination/pagination.decorator.ts
  59. 14
      src/modules/core/pagination/pagination.validation.ts
  60. 15
      src/modules/core/validation/validation.exception.ts
  61. 59
      src/modules/core/validation/validation.util.ts
  62. 24
      src/modules/core/validation/validator.pipe.ts
  63. 2
      src/modules/roles/roles.constants.ts
  64. 52
      src/modules/roles/roles.controller.ts
  65. 5
      src/modules/roles/roles.dto.ts
  66. 10
      src/modules/roles/roles.entity.ts
  67. 14
      src/modules/roles/roles.module.ts
  68. 15
      src/modules/roles/roles.repository.ts
  69. 64
      src/modules/roles/roles.service.ts
  70. 0
      src/modules/user/user.constants.ts
  71. 111
      src/modules/user/user.controller.ts
  72. 54
      src/modules/user/user.dto.ts
  73. 22
      src/modules/user/user.entity.ts
  74. 15
      src/modules/user/user.module.ts
  75. 20
      src/modules/user/user.repository.ts
  76. 104
      src/modules/user/user.service.ts
  77. 27
      src/utils/dbmodel.model.ts
  78. 28
      test/app.e2e-spec.ts
  79. 9
      test/jest-e2e.json
  80. 19
      tsconfig.json
  81. 7
      tsconfig.spec.json
  82. 29
      tslint.json
  83. 34
      webpack.config.js
  84. 9723
      yarn.lock

175
.circleci/config.yml

@ -0,0 +1,175 @@
version: 2
jobs:
build:
docker:
- image: circleci/node:dubnium-browsers
working_directory: ~/repo
steps:
- checkout
- restore_cache:
keys:
- dependencies-{{ checksum "package.json" }}-{{ .Environment.CACHE_VERSION }}
# fallback to using the latest cache if no exact match is found
- dependencies-
- run: yarn global add node-gyp && yarn install
- save_cache:
paths:
- node_modules
key: dependencies-{{ checksum "package.json" }}-{{ .Environment.CACHE_VERSION }}
lint:
docker:
- image: circleci/node:dubnium-browsers
working_directory: ~/repo
steps:
- checkout
- restore_cache:
key: dependencies-{{ checksum "package.json" }}-{{ .Environment.CACHE_VERSION }}
- run: yarn lint
- run: yarn format:check
server-doc-build:
docker:
- image: circleci/node:dubnium-browsers
working_directory: ~/repo
steps:
- checkout
- restore_cache:
key: dependencies-{{ checksum "package.json" }}-{{ .Environment.CACHE_VERSION }}
- run: yarn doc:build
- run:
name: Copy deployment artifacts to workspace
command: |
cp doc-server/ /tmp/server-doc -r
- store_artifacts:
path: /tmp/server-doc
- persist_to_workspace:
root: /tmp
paths:
- server-doc
server-doc-deploy:
docker:
- image: circleci/node:dubnium-browsers
working_directory: ~/deploy-doc-server
steps:
- attach_workspace:
at: /tmp
- run:
name: Deploy app
command: echo todo && false
back-test-unit:
docker:
- image: circleci/node:dubnium-browsers
working_directory: ~/repo
steps:
- checkout
- restore_cache:
key: dependencies-{{ checksum "package.json" }}-{{ .Environment.CACHE_VERSION }}
- run:
command: yarn test:ci
environment:
DATABASE_URL: postgres://psqluer:psqlpassword@localhost:5432/psqluer
JWT_SECRET: aaaa
API_PORT: 3000
API_HOST: localhost
API_PROTOCOL: http
- run: yarn add codecov && yarn codecov
back-test-e2e:
docker:
- image: circleci/node:dubnium-browsers
- image: circleci/postgres:9.6.5
environment:
POSTGRES_DB: psqluer
POSTGRES_USER: psqluer
POSTGRES_PASSWORD: psqlpassword
working_directory: ~/repo
steps:
- checkout
- restore_cache:
key: dependencies-{{ checksum "package.json" }}-{{ .Environment.CACHE_VERSION }}
- run:
command: yarn test:e2e
environment:
DATABASE_URL: postgres://psqluer:psqlpassword@localhost:5432/psqluer
JWT_SECRET: aaaa
API_PORT: 3000
API_HOST: localhost
API_PROTOCOL: http
docker-build-and-push:
working_directory: /dockerapp
docker:
- image: docker:17.05.0-ce-git
steps:
- checkout
- setup_remote_docker
- run:
name: Build application Docker image
command: |
docker build --cache-from=app -t app .
- deploy:
name: Publish application to docker hub
command: |
docker login -e $DOCKER_HUB_EMAIL -u $DOCKER_HUB_USER_ID -p $DOCKER_HUB_PWD
docker tag app $DOCKER_HUB_USER_ID/my-awesome-ci-expr:$CIRCLE_BUILD_NUM
docker tag app $DOCKER_HUB_USER_ID/my-awesome-ci-expr:latest
docker push $DOCKER_HUB_USER_ID/my-awesome-ci-expr:$CIRCLE_BUILD_NUM
docker push $DOCKER_HUB_USER_ID/my-awesome-ci-expr:latest
back-deploy-heroku:
docker:
- image: buildpack-deps:trusty
steps:
- checkout
- run:
name: Heroku Deploy
command: echo todo && false
- run:
name: Smoke Test
command: echo todo && false
workflows:
version: 2
build-test-and-lint:
jobs:
- build
- back-test-unit:
requires:
- build
- back-test-e2e:
requires:
- build
- lint:
requires:
- build
- docker-build-and-push:
requires:
- build
- lint
- back-test-e2e
- back-test-unit
filters:
branches:
only: main
- back-deploy-heroku:
requires:
- build
- lint
- back-test-e2e
- back-test-unit
filters:
branches:
only: main
- server-doc-build:
requires:
- build
- lint
- back-test-e2e
- back-test-unit
filters:
branches:
only: main
- server-doc-deploy:
requires:
- server-doc-build
filters:
branches:
only: main

7
.editorconfig

@ -0,0 +1,7 @@
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2

243
.gitignore

@ -0,0 +1,243 @@
# Created by https://www.gitignore.io/api/code,node,linux,windows,intellij,sublimetext
### Code ###
# Visual Studio Code - https://code.visualstudio.com/
.settings/
.vscode/
jsconfig.json
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
.idea/sonarlint
.idea/
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
### SublimeText ###
# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# Workspace files are user-specific
*.sublime-workspace
# Project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
# SFTP configuration file
sftp-config.json
# Package control specific files
Package Control.last-run
Package Control.ca-list
Package Control.ca-bundle
Package Control.system-ca-bundle
Package Control.cache/
Package Control.ca-certs/
Package Control.merged-ca-bundle
Package Control.user-ca-bundle
oscrypto-ca-bundle.crt
bh_unicode_properties.cache
# Sublime-github package stores a github token in this file
# https://packagecontrol.io/packages/sublime-github
GitHub.sublime-settings
### Windows ###
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.gitignore.io/api/code,node,linux,windows,intellij,sublimetext
.env
doc-server
.netlify

4
.prettierrc

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

10
Dockerfile

@ -0,0 +1,10 @@
FROM node:dubnium
WORKDIR /nest-server
COPY . .
RUN yarn install
CMD ["yarn", "start"]

29
README.md

@ -0,0 +1,29 @@
## Description
Ce projet est fait avec [Nest](https://github.com/nestjs/nest).
## Installation
```bash
$ yarn install
```
## Démarer l'application
```bash
# development
$ docker-compose -f docker/postgresql.yml up -d
$ yarn start
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
```
Il y a un dockerfile pour builder en docker, pour lancer la stack avec docker, un docker-compose est présent.

6
compodoc.server.json

@ -0,0 +1,6 @@
{
"port": 9911,
"name": "Nuit info server",
"output": "doc-server",
"tsconfig": "./tsconfig.json"
}

11
docker/postgresql.yml

@ -0,0 +1,11 @@
version: '2'
services:
ognion-boilerplate-postgresql:
image: postgres:9.6.5
# volumes:
# - ~/volumes/dashy/postgresql/:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=onionBoilerplate
- POSTGRES_PASSWORD=someNotSecurePassword
ports:
- 5432:5432

13
exemple.env

@ -0,0 +1,13 @@
## API
PORT=3000
API_HOST=localhost
API_PROTOCOL=http
## LOGGER
LOG_LEVEL=debug
## DB [TypeORM]
DATABASE_URL=postgres://onionBoilerplate:someNotSecurePassword@localhost:5432/onionBoilerplate
## AUTHENTICATION [JWT]
JWT_SECRET=bananana

24
internals/generator/index.js

@ -0,0 +1,24 @@
/**
* generator/index.js
*
* Exports the generators so plop knows them
*/
const fs = require('fs');
const path = require('path');
const moduleGenerator = require('./module/index.js');
module.exports = plop => {
plop.setGenerator('module', moduleGenerator);
/*
plop.addHelper('directory', (comp) => {
try {
fs.accessSync(path.join(__dirname, `../../app/containers/${comp}`), fs.F_OK);
return `containers/${comp}`;
} catch (e) {
return `components/${comp}`;
}
});
*/
plop.addHelper('curly', (object, open) => (open ? '{' : '}'));
};

0
internals/generator/module/constants.ts.hbs

0
internals/generator/module/controller.spec.ts.hbs

89
internals/generator/module/controller.ts.hbs

@ -0,0 +1,89 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { {{ properCase name }} } from './{{ kebabCase name }}.entity';
import { {{ properCase name }}Service } from './{{ kebabCase name }}.service';
import {
ApiBearerAuth,
ApiImplicitParam,
ApiResponse,
ApiUseTags,
} from '@nestjs/swagger';
import { {{ properCase name }}Dto } from './{{ kebabCase name }}.dto';
@ApiUseTags('{{ sentenceCase name }}')
@Controller()
// @ApiBearerAuth()
export class {{ properCase name }}Controller {
constructor(private readonly {{ camelCase name }}Service: {{ properCase name }}Service) {}
@Get()
@ApiResponse({
status: 200,
description: 'Get a list of all {{ sentenceCase name }}.',
type: {{ properCase name }},
isArray: true,
})
getAll(): Promise<{{ properCase name }}[]> {
return this.{{ camelCase name }}Service.getAll();
}
@Post()
@ApiResponse({
status: 201,
description: 'The {{ sentenceCase name }} has been created.',
type: {{ properCase name }},
})
saveNew(@Body() {{ camelCase name }}Dto: {{ properCase name }}Dto): Promise<{{ properCase name }}> {
return this.{{ camelCase name }}Service.saveNew({{ camelCase name }}Dto);
}
@Get(':id')
@ApiResponse({
status: 200,
description: 'The {{ sentenceCase name }} with the matching id',
type: {{ properCase name }},
})
@ApiResponse({ status: 404, description: 'Not found.' })
async findOne(
@Param('id', new ParseIntPipe()) id: number,
): Promise<{{ properCase name }}> {
return (await this.{{ camelCase name }}Service.getOneById(id)).orElseThrow(
() => new NotFoundException(),
);
}
@Put(':id')
@ApiResponse({
status: 200,
description: 'The updated {{ sentenceCase name }} with the matching id',
type: {{ properCase name }},
})
@ApiResponse({ status: 404, description: 'Not found.' })
async updateOne(
@Param('id', new ParseIntPipe()) id: number,
@Body() {{ camelCase name }}Dto: {{ properCase name }}Dto,
): Promise<{{ properCase name }}> {
return this.{{ camelCase name }}Service.update(id, {{ camelCase name }}Dto);
}
@Delete(':id')
@ApiResponse({
status: 200,
description: 'The {{ sentenceCase name }} with the matching id was deleted',
})
@ApiResponse({ status: 404, description: 'Not found.' })
async deleteOne(@Param('id', new ParseIntPipe()) id: number): Promise<void> {
await this.{{ camelCase name }}Service.deleteById(id);
}
}

7
internals/generator/module/dto.ts.hbs

@ -0,0 +1,7 @@
import { IsArray, IsOptional, IsString, Min, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiModelProperty } from '@nestjs/swagger';
export class {{ properCase name }}Dto {
}

9
internals/generator/module/entity.ts.hbs

@ -0,0 +1,9 @@
import { DbAuditModel } from '../../utils/dbmodel.model';
import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
import { ApiModelProperty } from '@nestjs/swagger';
@Entity()
export class {{ properCase name }} extends DbAuditModel {
}

203
internals/generator/module/index.js

@ -0,0 +1,203 @@
/**
* Component Generator
*/
'use strict';
const { moduleExists } = require('../utils/moduleExists');
module.exports = {
description: 'Add an unconnected component',
prompts: [
/*
{
type: 'list',
name: 'type',
message: 'Select the type of component',
default: 'Stateless Function',
choices: () => [
'Stateless Function',
'React.PureComponent',
'React.Component',
'Styled component',
],
},
*/
{
type: 'input',
name: 'name',
message: 'What is the name of the module ?',
default: 'User',
validate: value => {
if (/.+/.test(value)) {
return moduleExists(value)
? 'A component or container with this name already exists'
: true;
}
return 'The name is required';
},
},
/*
{
type: 'list',
name: 'sizeType',
message: 'Select the type of component for the atomic design',
default: 'atom',
choices: () => ['atom', 'molecule', 'organism', 'template', 'page', 'chart'],
},
{
type: 'confirm',
name: 'wantMessages',
default: true,
message: 'Do you want i18n messages (i.e. will this component use text)?',
},
{
type: 'confirm',
name: 'wantLoadable',
default: false,
message: 'Do you want to load the component asynchronously?',
},
*/
],
actions: data => {
// Generate index.js
let componentTemplate;
let paternImport;
/*
switch (data.type) {
case 'Stateless Function': {
componentTemplate = './component/stateless.js.hbs';
break;
}
case 'Styled component': {
componentTemplate = './component/styled.js.hbs';
break;
}
default: {
componentTemplate = './component/class.js.hbs';
}
}
switch (data.sizeType) {
// 'atom', 'molecule', 'organism', 'template', 'page'
case 'atom':
paternImport = /(\/\/ needle-import-atoms)/g;
break;
case 'molecule':
paternImport = /(\/\/ needle-import-molecules)/g;
break;
case 'organism':
paternImport = /(\/\/ needle-import-organisms)/g;
break;
case 'template':
paternImport = /(\/\/ needle-import-templates)/g;
break;
case 'chart':
paternImport = /(\/\/ needle-import-charts)/g;
break;
default:
paternImport = /(\/\/ needle-import-pages)/g;
}
*/
const actions = [
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.module.ts',
templateFile: './module/module.ts.hbs',
abortOnFail: true,
},
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.constants.ts',
templateFile: './module/constants.ts.hbs',
abortOnFail: true,
},
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.controller.ts',
templateFile: './module/controller.ts.hbs',
abortOnFail: true,
},
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.dto.ts',
templateFile: './module/dto.ts.hbs',
abortOnFail: true,
},
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.entity.ts',
templateFile: './module/entity.ts.hbs',
abortOnFail: true,
},
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.repository.ts',
templateFile: './module/repository.ts.hbs',
abortOnFail: true,
},
{
type: 'add',
path:
'../../src/modules/{{ kebabCase name }}/{{ kebabCase name }}.service.ts',
templateFile: './module/service.ts.hbs',
abortOnFail: true,
},
{
type: 'modify',
path: '../../src/app.module.ts',
pattern: /(\/\/ needle-module-import)/g,
template:
"import { {{properCase name}}Module } from './modules/{{ kebabCase name }}/{{ kebabCase name }}.module';\n$1",
},
{
type: 'modify',
path: '../../src/app.module.ts',
pattern: /(\/\/ needle-module-includes)/g,
template: '{{properCase name}}Module,\n$1',
},
{
type: 'modify',
path: '../../src/app.routes.ts',
pattern: /(\/\/ needle-module-import)/g,
template:
"import { {{properCase name}}Module } from './modules/{{ kebabCase name }}/{{ kebabCase name }}.module';\n$1",
},
{
type: 'modify',
path: '../../src/app.routes.ts',
pattern: /(\/\/ needle-modules-routes)/g,
templateFile: './module/routing.ts.hbs',
},
];
// If they want a i18n messages file
if (data.wantMessages) {
actions.push({
type: 'add',
path:
'../../src/components//{{sizeType}}s/{{properCase name}}/messages.js',
templateFile: './component/messages.js.hbs',
abortOnFail: true,
});
}
// If want Loadable.js to load the component asynchronously
if (data.wantLoadable) {
actions.push({
type: 'add',
path:
'../../src/components//{{sizeType}}s/{{properCase name}}/Loadable.js',
templateFile: './component/loadable.js.hbs',
abortOnFail: true,
});
}
return actions;
},
};

16
internals/generator/module/module.ts.hbs

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { {{ properCase name }}Controller } from './{{ kebabCase name }}.controller';
import { {{ properCase name }}Service } from './{{ kebabCase name }}.service';
import { {{ properCase name }} } from './{{ kebabCase name }}.entity';
import { {{ properCase name }}Repository } from './{{ kebabCase name }}.repository';
@Module({
imports: [
TypeOrmModule.forFeature([{{ properCase name }}, {{ properCase name }}Repository]),
],
controllers: [{{ properCase name }}Controller],
providers: [{{ properCase name }}Service],
exports: [{{ properCase name }}Service],
})
export class {{ properCase name }}Module {}

12
internals/generator/module/repository.ts.hbs

@ -0,0 +1,12 @@
import { EntityRepository, Repository } from 'typeorm';
import { {{ properCase name }} } from './{{ kebabCase name }}.entity';
import Optional from 'typescript-optional';
@EntityRepository({{ properCase name }})
export class {{ properCase name }}Repository extends Repository<{{ properCase name }}> {
async findOneById(id: number): Promise<Optional<{{ properCase name }}>> {
return Optional.ofNullable(
await this.findOne(id, {}),
);
}
}

5
internals/generator/module/routing.ts.hbs

@ -0,0 +1,5 @@
{
path: '/{{ kebabCase name }}s',
module: {{properCase name}}Module,
},
$1

0
internals/generator/module/service.spec.ts.hbs

54
internals/generator/module/service.ts.hbs

@ -0,0 +1,54 @@
import { {{ properCase name }} } from './{{ kebabCase name }}.entity';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { {{ properCase name }}Repository } from './{{ kebabCase name }}.repository';
import { } from './{{ kebabCase name }}.constants';
import { {{ properCase name }}Dto } from './{{ kebabCase name }}.dto';
import Optional from 'typescript-optional';
@Injectable()
export class {{ properCase name }}Service {
constructor(
@InjectRepository({{ properCase name }}Repository)
private readonly {{ camelCase name }}Repository: {{ properCase name }}Repository,
) { }
async getAll(): Promise<{{ properCase name }}[]> {
return this.{{ camelCase name }}Repository.find({});
}
async getOneById(id: number): Promise<Optional<{{ properCase name }}>> {
return this.{{ camelCase name }}Repository.findOneById(id);
}
async saveNew(body: {{ properCase name }}Dto): Promise<{{ properCase name }}> {
let {{ camelCase name }}New = new {{ properCase name }}();
// Complete with the mappings
{{ camelCase name }}New = await this.{{ camelCase name }}Repository.save({{ camelCase name }}New);
return {{ camelCase name }}New;
}
async update(id: number, body: {{ properCase name }}Dto): Promise<{{ properCase name }}> {
let {{ camelCase name }}Found = (await this.{{ camelCase name }}Repository.findOneById(id)).orElseThrow(
() => new NotFoundException(),
);
// Complete with the mappings
{{ camelCase name }}Found = await this.{{ camelCase name }}Repository.save({{ camelCase name }}Found);
return {{ camelCase name }}Found;
}
async deleteById(id: number): Promise<void> {
const {{ camelCase name }}Found = (await this.{{ camelCase name }}Repository.findOneById(
id,
)).orElseThrow(() => new NotFoundException());
await this.{{ camelCase name }}Repository.remove({{ camelCase name }}Found);
}
}

20
internals/generator/utils/moduleExists.js

@ -0,0 +1,20 @@
/**
* componentExists
*
* Check whether the given component exist in either the components or containers directory
*/
const fs = require('fs');
const path = require('path');
const pathModules = path.join(__dirname, '../../../src/modules');
const modules = fs.readdirSync(pathModules);
function moduleExists(store) {
return modules.indexOf(store) >= 0;
}
module.exports = {
moduleExists,
};

5
nest-cli.json

@ -0,0 +1,5 @@
{
"language": "ts",
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

6
nodemon-debug.json

@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --inspect-brk -r ts-node/register src/main.ts"
}

6
nodemon.json

@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts",
"ignore": ["src/**/*.spec.ts"],
"exec": "ts-node -r tsconfig-paths/register src/main.ts"
}

109
package.json

@ -0,0 +1,109 @@
{
"name": "nest-onion-boilerplate",
"version": "0.0.1",
"description": "a boilerplate for my projects",
"author": "Nicolas Beaussart <nic.beaussart@gmail.com>",
"license": "MIT",
"scripts": {
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --list-different \"src/**/*{.ts,.scss,.html}\"",
"start": "ts-node -r tsconfig-paths/register src/main.ts",
"start:dev": "nodemon",
"start:debug": "nodemon --config nodemon-debug.json",
"prestart:prod": "rimraf dist && tsc",
"start:prod": "node dist/main.js",
"start:hmr": "node dist/server",
"lint": "tslint -p tsconfig.json -c tslint.json",
"doc:build": "./node_modules/.bin/compodoc -c compodoc.server.json",
"doc": "./node_modules/.bin/compodoc -c compodoc.server.json -s -o -w",
"test": "jest",
"test:ci": "jest --runInBand --coverage",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "plop --plopfile internals/generator/index.js",
"webpack": "webpack --config webpack.config.js"
},
"dependencies": {
"@nestjs/common": "^5.1.0",
"@nestjs/core": "^5.1.0",
"@nestjs/jwt": "^0.2.0",
"@nestjs/passport": "^5.1.0",
"@nestjs/swagger": "^2.5.1",
"@nestjs/typeorm": "^5.2.2",
"argon2": "^0.20.0",
"class-sanitizer": "^0.0.5",
"class-transformer": "^0.3.1",
"class-validator": "^0.9.1",
"dotenv": "^6.1.0",
"joi": "^14.1.0",
"nest-router": "^1.0.7",
"passport": "^0.4.0",
"passport-jwt": "^4.0.0",
"pg": "^7.6.1",
"reflect-metadata": "^0.1.12",
"rxjs": "^6.2.2",
"typeorm": "^0.2.29",
"typescript": "^3.0.1",
"typescript-optional": "^1.8.0",
"winston": "^3.1.0"
},
"devDependencies": {
"@compodoc/compodoc": "^1.1.11",
"@nestjs/testing": "^5.1.0",
"@types/express": "^4.16.0",
"@types/jest": "^23.3.1",
"@types/joi": "^14.0.0",
"@types/node": "^10.7.1",
"@types/supertest": "^2.0.5",
"husky": "^1.0.0-rc.15",
"jest": "^23.5.0",
"lint-staged": "^8.1.0",
"lodash": "^4.17.20",
"node-plop": "^0.16.0",
"nodemon": "^1.18.3",
"plop": "^2.1.0",
"prettier": "^1.14.2",
"rimraf": "^2.6.2",
"supertest": "^3.1.0",
"ts-jest": "^23.1.3",
"ts-loader": "^5.3.1",
"ts-node": "^7.0.1",
"tsconfig-paths": "^3.5.0",
"tslint": "5.11.0",
"tslint-config-prettier": "^1.15.0",
"webpack": "^4.16.5",
"webpack-cli": "^3.1.0",
"webpack-node-externals": "^1.7.2"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.ts": [
"prettier --write",
"tslint -p tsconfig.json -c tslint.json",
"git add"
],
"*.{js,json,md}": [
"prettier --write",
"git add"
]
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

22
src/app.controller.spec.ts

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let app: TestingModule;
beforeAll(async () => {
app = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
});
describe('root', () => {
it('should return "Hello World!"', () => {
const appController = app.get<AppController>(AppController);
expect(appController.root()).toBe('Hello World!');
});
});
});

12
src/app.controller.ts

@ -0,0 +1,12 @@
import { Get, Controller } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
root(): string {
return this.appService.root();
}
}

43
src/app.module.ts

@ -0,0 +1,43 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LoggerModule } from './modules/core/logger/logger.module';
import { RouterModule } from 'nest-router';
import { appRoutes } from './app.routes';
import { ConfigModule } from './modules/core/config/config.module';
import { RolesGuard } from './guards/roles.guard';
import { UserModule } from './modules/user/user.module';
import { ConfigService } from './modules/core/config/config.service';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { RolesModule } from './modules/roles/roles.module';
import { AuthModule } from './modules/core/auth/auth.module';
// needle-module-import
@Module({
imports: [
ConfigModule, // Global
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (
configService: ConfigService,
): Promise<TypeOrmModuleOptions> => ({
type: 'postgres',
url: configService.databaseUrl,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
logging: configService.isLoggingDb ? 'all' : false,
}),
inject: [ConfigService],
}),
LoggerModule, // Global
RouterModule.forRoutes(appRoutes),
AuthModule,
UserModule,
RolesModule,
// needle-module-includes
],
controllers: [AppController],
providers: [AppService, RolesGuard],
})
export class AppModule {}

22
src/app.routes.ts

@ -0,0 +1,22 @@
import { Routes } from 'nest-router';
import { UserModule } from './modules/user/user.module';
import { RolesModule } from './modules/roles/roles.module';
import { AuthController } from './modules/core/auth/auth.controller';
// needle-module-import
export const appRoutes: Routes = [
{
path: '/users',
module: UserModule,
},
{
path: '/roles',
module: RolesModule,
},
{
path: '/auth',
module: AuthController,
},
// needle-modules-routes
];

8
src/app.service.ts

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
root(): string {
return 'Hello World!';
}
}

8
src/decorators/currentUser.decorator.ts

@ -0,0 +1,8 @@
import { createParamDecorator } from '@nestjs/common';
import { User } from '../modules/user/user.entity';
export const CurrentUser = createParamDecorator(
(data: any, req: any): User => {
return req.user.payload;
},
);

3
src/decorators/roles.decorator.ts

@ -0,0 +1,3 @@
import { ReflectMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => ReflectMetadata('roles', roles);

0
src/exceptions/.gitkeep

0
src/filters/.gitkeep

22
src/guards/roles.guard.ts

@ -0,0 +1,22 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
const hasRole = () =>
user.roles.some((role: string) => roles.includes(role));
return user && user.roles && hasRole();
}
}

14
src/interceptors/transform.interceptor.ts

@ -0,0 +1,14 @@
import { Injectable, NestInterceptor, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { classToPlain } from 'class-transformer';
@Injectable()
export class TransformInterceptor implements NestInterceptor<any, any> {
intercept(
context: ExecutionContext,
call$: Observable<any>,
): Observable<any> {
return call$.pipe(map(data => classToPlain(data)));
}
}

0
src/interfaces/.gitkeep

62
src/main.ts

@ -0,0 +1,62 @@
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { config } from 'dotenv';
import { AppModule } from './app.module';
import { LoggerExceptionInterceptor } from './modules/core/logger/logger-exception.interceptor';
import { LoggerModule } from './modules/core/logger/logger.module';
import { ValidatorPipe } from './modules/core/validation/validator.pipe';
import { LoggerService } from './modules/core/logger/logger.service';
import { RolesGuard } from './guards/roles.guard';
import { ConfigService } from './modules/core/config/config.service';
import { ConfigModule } from './modules/core/config/config.module';
import { TransformInterceptor } from './interceptors/transform.interceptor';
async function bootstrap() {
// Use .env to configure environment variables (process.env)
config();
const app = await NestFactory.create(AppModule);
// Set logger
app.useLogger(app.get(LoggerService));
// Enable cors
app.enableCors();
// Interceptors
const loggerInterceptor = app
.select(LoggerModule)
.get(LoggerExceptionInterceptor);
app.useGlobalInterceptors(
loggerInterceptor, // Log exceptions
new TransformInterceptor(),
);
const connfigService = app.select(ConfigModule).get(ConfigService);
// Guards
const rolesGuard = app.select(AppModule).get(RolesGuard);
app.useGlobalGuards(rolesGuard);
// Validators
app.useGlobalPipes(
new ValidatorPipe(), // Validate inputs
);
// Swagger
const options = new DocumentBuilder()
.setTitle('Boilerplate nest')
.setDescription('The boilerplate API description')
.setVersion('0.0.1')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('/docs', app, document);
const server = await app.listen(connfigService.port);
app
.get(LoggerService)
.info(`Application is listening on port ${connfigService.port}.`);
return server;
}
bootstrap();

0
src/middlewares/.gitkeep

39
src/modules/core/auth/auth.controller.ts

@ -0,0 +1,39 @@
import { ApiUseTags, ApiBearerAuth } from '@nestjs/swagger';
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { LoginDto, TokenDto } from './auth.dto';
import { AuthService } from './auth.service';
import { User } from '../../user/user.entity';
import { UserService } from '../../user/user.service';
import { UserDtoRegister } from '../../user/user.dto';
import { CurrentUser } from '../../../decorators/currentUser.decorator';
import { AuthGuard } from '@nestjs/passport';
@ApiUseTags('Auth')
@Controller()
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@Post('login')
async signIn(@Body() userLogin: LoginDto): Promise<TokenDto> {
const token = await this.authService.signIn(
userLogin.email,
userLogin.password,
);
return { token };
}
@Post('register')
async registerUser(@Body() userRegister: UserDtoRegister): Promise<User> {
return this.userService.saveNew(userRegister);
}
@Get('me')
@ApiBearerAuth()
@UseGuards(AuthGuard())
async getMe(@CurrentUser() loggedUser: User) {
return loggedUser;
}
}

17
src/modules/core/auth/auth.dto.ts

@ -0,0 +1,17 @@
import { IsEmail, MinLength } from 'class-validator';
import { ApiModelProperty } from '@nestjs/swagger';
export class TokenDto {
token: string;
}
export class LoginDto {
@MinLength(1)
@IsEmail()
@ApiModelProperty({ example: 'foo@bar.fr' })
email: string;
@MinLength(1)
@ApiModelProperty({ example: 'azerty' })
password: string;
}

33
src/modules/core/auth/auth.module.ts

@ -0,0 +1,33 @@
import { Global, Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from '../../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { ConfigModule } from '../config/config.module';
import { ConfigService } from '../config/config.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
@Global()
@Module({
imports: [
UserModule,
// ConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (
configService: ConfigService,
): Promise<JwtModuleOptions> => ({
secretOrPrivateKey: configService.jwtSecret,
signOptions: {
expiresIn: 3600,
},
}),
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
})
export class AuthModule {}

30
src/modules/core/auth/auth.service.ts

@ -0,0 +1,30 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UserService } from '../../user/user.service';
import { JwtService } from '@nestjs/jwt';
export interface JwtPayload {
idUser: number;
}
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UserService,
private readonly jwtService: JwtService,
) {}
async signIn(email: string, password: string): Promise<string> {
const userFound = (
await this.usersService.getOnWithEmail(email)
).orElseThrow(() => new UnauthorizedException());
if (!this.usersService.doPasswordMatch(userFound, password)) {
throw new UnauthorizedException();
}
const user: JwtPayload = { idUser: userFound.id };
return this.jwtService.sign(user);
}
async validateUser(payload: JwtPayload): Promise<any> {
return await this.usersService.getOneById(payload.idUser);
}
}

22
src/modules/core/auth/jwt-auth.guard.ts

@ -0,0 +1,22 @@
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
// Add your custom authentication logic here
// for example, call super.logIn(request) to establish a session.
return super.canActivate(context);
}
handleRequest(err, user, info) {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}

26
src/modules/core/auth/jwt.strategy.ts

@ -0,0 +1,26 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService, JwtPayload } from './auth.service';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '../config/config.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.jwtSecret,
});
}
async validate(payload: JwtPayload) {
const user = await this.authService.validateUser(payload);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

9
src/modules/core/config/config.module.ts

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}

75
src/modules/core/config/config.service.ts

@ -0,0 +1,75 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Joi from 'joi';
import { config as parseConfig } from 'dotenv';
interface EnvConfig {
[key: string]: any;
}
@Injectable()
export class ConfigService {
private readonly envConfig: EnvConfig;
constructor() {
try {
parseConfig();
} catch (e) {}
this.envConfig = this.validateInput(process.env);
}
/**
* Ensures all needed variables are set, and returns the validated JavaScript object
* including the applied default values.
*/
private validateInput(envConfig: EnvConfig): EnvConfig {
const envVarsSchema: Joi.ObjectSchema = Joi.object({
NODE_ENV: Joi.string()
.valid(['development', 'production', 'test', 'provision'])
.default('development'),
PORT: Joi.number().default(3000),
API_HOST: Joi.string().default('localhost'),
API_PROTOCOL: Joi.string().default('http'),
LOG_LEVEL: Joi.string()
.valid(['error', 'warning', 'info', 'debug', 'silly'])
.default('debug'),
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string()
.required()
.default('some_default_jwt_bad_practice_!!'),
LOG_SQL_REQUEST: Joi.boolean().default(false),
});
const { error, value: validatedEnvConfig } = Joi.validate(
envConfig,
envVarsSchema,
{
stripUnknown: true,
},
);
if (error) {
throw new Error(`Config validation error: ${error.message}`);
}
return validatedEnvConfig;
}
get databaseUrl(): string {
return this.envConfig.DATABASE_URL;
}
get jwtSecret(): string {
return this.envConfig.JWT_SECRET;
}
get isLoggingDb(): boolean {
return false;
}
get loggerLevel(): string {
return this.envConfig.LOG_LEVEL;
}
get port(): number {
return this.envConfig.PORT;
}
}

8
src/modules/core/crypto/crypto.module.ts

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CryptoService } from './crypto.service';
@Module({
providers: [CryptoService],
exports: [CryptoService],
})
export class CryptoModule {}

15
src/modules/core/crypto/crypto.service.spec.ts

@ -0,0 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CryptoService } from './crypto.service';
describe('CryptoService', () => {
let service: CryptoService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CryptoService],
}).compile();
service = module.get<CryptoService>(CryptoService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

28
src/modules/core/crypto/crypto.service.ts

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
@Injectable()
export class CryptoService {
private readonly type = argon2.argon2id;
constructor() {}
/**
* Compare hash
* @param {string} plain
* @param {string} hash
* @returns {Promise<boolean>}
*/
public async compare(plain: string, hash: string): Promise<boolean> {
return await argon2.verify(hash, plain);
}
/**
* Generate hash
* @param {string} plain
* @returns {Promise<string>}
*/
public async hash(plain: string): Promise<string> {
return await argon2.hash(plain, { type: this.type });
}
}

46
src/modules/core/logger/logger-exception.interceptor.ts

@ -0,0 +1,46 @@
import {
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LoggerService } from './logger.service';
@Injectable()
export class LoggerExceptionInterceptor implements NestInterceptor {
constructor(private loggerService: LoggerService) {}
intercept(
context: ExecutionContext,
call$: Observable<any>,
): Observable<any> {
const request = context.switchToHttp().getRequest();
return call$.pipe(
catchError(exception => {
if (exception instanceof HttpException) {
// If 500, log as error
if (500 <= exception.getStatus())
this.loggerService.error(
'HttpException ' + exception.getStatus(),
request.path,
exception.getResponse(),
);
// Else log as debug (we don't want 4xx errors in production)
else {
this.loggerService.debug(
'HttpException ' + exception.getStatus(),
request.path,
exception.getResponse(),
);
}
} else {
this.loggerService.error('Unexpected error', request.path, exception);
}
throw exception;
}),
);
}
}

9
src/modules/core/logger/logger.constants.ts

@ -0,0 +1,9 @@
export const LOGGER_WINSTON_PROVIDER = 'LOGGER_WINSTON_PROVIDER';
export enum LOGGER_LEVEL {
ERROR = 'error',
WARNING = 'warning',
INFO = 'info',
DEBUG = 'debug',
SILLY = 'silly',
}

11
src/modules/core/logger/logger.module.ts

@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { LoggerService } from './logger.service';
import { LoggerExceptionInterceptor } from './logger-exception.interceptor';
import { loggerProviders } from './logger.providers';
@Global()
@Module({
providers: [...loggerProviders, LoggerService, LoggerExceptionInterceptor],
exports: [LoggerService, LoggerExceptionInterceptor],
})
export class LoggerModule {}

33
src/modules/core/logger/logger.providers.ts

@ -0,0 +1,33 @@
import { LOGGER_LEVEL, LOGGER_WINSTON_PROVIDER } from './logger.constants';
import { createLogger, transports, format } from 'winston';
export const loggerProviders = [
{
provide: LOGGER_WINSTON_PROVIDER,
useFactory: () => {
const LOG_LEVEL = LOGGER_LEVEL.DEBUG;
const winstonTransports = [new transports.Console({})];
const winstonFormaters = format.combine(
format.colorize(),
format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss',
}),
format.align(),
format.printf(
info =>
`[Nest] ${process.pid} - ${info.timestamp} ${
info.level
}: ${info.message.trim()}`,
),
);
return createLogger({
level: LOG_LEVEL,
transports: winstonTransports,
format: winstonFormaters,
});
},
},
];

18
src/modules/core/logger/logger.service.spec.ts

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerService } from './logger.service';
import { ConfigModule } from '../config/config.module';
import { loggerProviders } from './logger.providers';
describe('LoggerService', () => {
let service: LoggerService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LoggerService, ...loggerProviders],
imports: [ConfigModule],
}).compile();
service = module.get<LoggerService>(LoggerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

46
src/modules/core/logger/logger.service.ts

@ -0,0 +1,46 @@
import {
Inject,
Injectable,
LoggerService as NestLoggerService,
} from '@nestjs/common';
import { LOGGER_LEVEL, LOGGER_WINSTON_PROVIDER } from './logger.constants';
import { Logger } from 'winston';
import { ConfigService } from '../config/config.service';
@Injectable()
export class LoggerService implements NestLoggerService {
constructor(
@Inject(LOGGER_WINSTON_PROVIDER) private readonly winston: Logger,
private readonly configService: ConfigService,
) {
winston.transports[0].level = this.configService.loggerLevel;
}
private logMessage(level: LOGGER_LEVEL, msg: string, ...meta): void {
this.winston.log(level, msg, ...meta);
}
public log(msg: string, ...meta): void {
this.logMessage(LOGGER_LEVEL.INFO, msg, ...meta);
}
public debug(msg: string, ...meta): void {
this.logMessage(LOGGER_LEVEL.DEBUG, msg, ...meta);
}
public error(msg: string, ...meta): void {
this.logMessage(LOGGER_LEVEL.ERROR, msg, ...meta);
}
public warn(msg: string, ...meta): void {
this.logMessage(LOGGER_LEVEL.WARNING, msg, ...meta);
}
public info(msg: string, ...meta): void {
this.logMessage(LOGGER_LEVEL.INFO, msg, ...meta);
}
public silly(msg: string, ...meta): void {
this.logMessage(LOGGER_LEVEL.SILLY, msg, ...meta);
}
}

10
src/modules/core/pagination/pagination.decorator.ts

@ -0,0 +1,10 @@
import { createParamDecorator } from '@nestjs/common';
import { Request } from 'express';
import { VPagination } from './pagination.validation';
import { validateSync } from '../validation/validation.util';
export const Pagination = createParamDecorator(
(data: any, req: Request): VPagination => {
return validateSync(VPagination, req.query);
},
);

14
src/modules/core/pagination/pagination.validation.ts

@ -0,0 +1,14 @@
import { IsNumber, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class VPagination {
@IsNumber()
@Min(0)
@Type(() => Number)
page: number = 0;
@IsNumber()
@Min(1)
@Type(() => Number)
limit: number = 10;
}

15
src/modules/core/validation/validation.exception.ts

@ -0,0 +1,15 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { createHttpExceptionBody } from '@nestjs/common/utils/http-exception-body.util';
export class ValidationException extends HttpException {
constructor(errors: string | object | any) {
super(
createHttpExceptionBody(
errors,
'VALIDATION_EXCEPTION',
HttpStatus.BAD_REQUEST,
),
HttpStatus.BAD_REQUEST,
);
}
}

59
src/modules/core/validation/validation.util.ts

@ -0,0 +1,59 @@
import { plainToClass } from 'class-transformer';
import { sanitize } from 'class-sanitizer';
import {
validate as classValidate,
validateSync as classValidateSync,
} from 'class-validator';
import { ValidationException } from './validation.exception';
import { ClassType } from 'class-transformer/ClassTransformer';
/**
* Validate value for validator
* @param {ClassType<T>} validation
* @param {object} value
* @return {Promise<T>}
*/
export const validate = async <T>(
validation: ClassType<T>,
value: object,
): Promise<T> => {
// Transform to class
const entity = plainToClass<T, object>(validation, value);
// Sanitize
sanitize(entity);
// Validate
const errors = await classValidate(entity, {
skipMissingProperties: true,
whitelist: true,
});
if (errors.length > 0) {
throw new ValidationException(errors);
}
return entity;
};
/**
* Validate value for validator without async validators
* @param {ClassType<T>} validation
* @param {object} value
* @return {T}
*/
export const validateSync = <T>(validation: ClassType<T>, value: object): T => {
// Transform to class
const entity = plainToClass<T, object>(validation, value);
// Sanitize
sanitize(entity);
// Validate
const errors = classValidateSync(entity, {
skipMissingProperties: true,
whitelist: true,
});
if (errors.length > 0) throw new ValidationException(errors);
return entity;
};

24
src/modules/core/validation/validator.pipe.ts

@ -0,0 +1,24 @@
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
import { isNil } from '@nestjs/common/utils/shared.utils';
import { validate } from './validation.util';
@Injectable()
export class ValidatorPipe implements PipeTransform<any> {
public async transform(value: any, metadata: ArgumentMetadata): Promise<any> {
const { metatype } = metadata;
if (!metatype || !this.toValidate(metadata)) {
return value;
}
return await validate(metatype, value);
}
private toValidate(metadata: ArgumentMetadata): boolean {
const { metatype, type } = metadata;
if (type === 'custom') {
return false;
}
const types = [String, Boolean, Number, Array, Object];
return !types.find(typeIn => metatype === typeIn) && !isNil(metatype);
}
}

2
src/modules/roles/roles.constants.ts

@ -0,0 +1,2 @@
export const USER_ROLE = 'USER_ROLE';
export const ADMIN_ROLE = 'ADMIN_ROLE';

52
src/modules/roles/roles.controller.ts

@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { Role } from './roles.entity';
import { RolesService } from './roles.service';
import {
ApiBearerAuth,
ApiImplicitParam,
ApiResponse,
ApiUseTags,
} from '@nestjs/swagger';
import { RolesDto } from './roles.dto';
@ApiUseTags('Role')
@Controller()
// @ApiBearerAuth()
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
@ApiResponse({
status: 200,
description: 'Get a list of all Role.',
type: Role,
isArray: true,
})
getAll(): Promise<Role[]> {
return this.rolesService.getAll();
}
@Get(':id')
@ApiResponse({
status: 200,
description: 'The Role with the matching id',
type: Role,
})
@ApiResponse({ status: 404, description: 'Not found.' })
async findOne(@Param('id', new ParseIntPipe()) id: number): Promise<Role> {
return (await this.rolesService.getOneById(id)).orElseThrow(
() => new NotFoundException(),
);
}
}

5
src/modules/roles/roles.dto.ts

@ -0,0 +1,5 @@
import { IsArray, IsOptional, IsString, Min, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiModelProperty } from '@nestjs/swagger';
export class RolesDto {}

10
src/modules/roles/roles.entity.ts

@ -0,0 +1,10 @@
import { DbAuditModel } from '../../utils/dbmodel.model';
import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
import { ApiModelProperty } from '@nestjs/swagger';
@Entity()
export class Role extends DbAuditModel {
@ApiModelProperty({ required: true, readOnly: true })
@Column({ unique: true })
name: string;
}

14
src/modules/roles/roles.module.ts

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
import { Role } from './roles.entity';
import { RolesRepository } from './roles.repository';
@Module({
imports: [TypeOrmModule.forFeature([Role, RolesRepository])],
controllers: [RolesController],
providers: [RolesService],
exports: [RolesService],
})
export class RolesModule {}

15
src/modules/roles/roles.repository.ts

@ -0,0 +1,15 @@
import { EntityRepository, Repository } from 'typeorm';
import { Role } from './roles.entity';
import Optional from 'typescript-optional';
import { ADMIN_ROLE, USER_ROLE } from './roles.constants';
@EntityRepository(Role)
export class RolesRepository extends Repository<Role> {
async findRoleByName(name: string): Promise<Optional<Role>> {
return Optional.ofNullable(await this.findOne({ name }));
}
async findOneById(id: number): Promise<Optional<Role>> {
return Optional.ofNullable(await this.findOne(id, {}));
}
}

64
src/modules/roles/roles.service.ts

@ -0,0 +1,64 @@
import { Role } from './roles.entity';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { RolesRepository } from './roles.repository';
import { USER_ROLE, ADMIN_ROLE } from './roles.constants';
import { RolesDto } from './roles.dto';
import Optional from 'typescript-optional';
@Injectable()
export class RolesService {
constructor(
@InjectRepository(RolesRepository)
private readonly rolesRepository: RolesRepository,
) {
this.init();
}
async init(): Promise<void> {
for (const role in [USER_ROLE, ADMIN_ROLE]) {
if ((await this.rolesRepository.findRoleByName(role)).isEmpty) {
const roleDb = new Role();
roleDb.name = role;
await this.rolesRepository.save(roleDb);
}
}
}
async getAll(): Promise<Role[]> {
return this.rolesRepository.find({});
}
async getOneById(id: number): Promise<Optional<Role>> {
return this.rolesRepository.findOneById(id);
}
async saveNew(body: RolesDto): Promise<Role> {
let rolesNew = new Role();
// Complete with the mappings
rolesNew = await this.rolesRepository.save(rolesNew);
return rolesNew;
}
async update(id: number, body: RolesDto): Promise<Role> {
let rolesFound = (await this.rolesRepository.findOneById(id)).orElseThrow(
() => new NotFoundException(),
);
// Complete with the mappings
rolesFound = await this.rolesRepository.save(rolesFound);
return rolesFound;
}
async deleteById(id: number): Promise<void> {
const rolesFound = (await this.rolesRepository.findOneById(id)).orElseThrow(
() => new NotFoundException(),
);
await this.rolesRepository.remove(rolesFound);
}
}

0
src/modules/user/user.constants.ts

111
src/modules/user/user.controller.ts

@ -0,0 +1,111 @@
import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
ParseIntPipe,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { User } from './user.entity';
import { UserService } from './user.service';
import {
ApiBearerAuth,
ApiImplicitParam,
ApiResponse,
ApiUseTags,
} from '@nestjs/swagger';
import {
UserDtoRegister,
UserDtoUpdateInfo,
UserDtoUpdatePassword,
} from './user.dto';
import { AuthGuard } from '@nestjs/passport';
import { Roles } from '../../decorators/roles.decorator';
import { ADMIN_ROLE } from '../roles/roles.constants';
@ApiUseTags('User')
@Controller()
@ApiBearerAuth()
@UseGuards(AuthGuard())
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiResponse({
status: 200,
description: 'Get a list of all User.',
type: User,
isArray: true,
})
@Roles(ADMIN_ROLE)
getAll(): Promise<User[]> {
return this.userService.getAll();
}
@Post()
@ApiResponse({
status: 201,
description: 'The User has been created.',
type: User,
})
@Roles(ADMIN_ROLE)
saveNew(@Body() userDto: UserDtoRegister): Promise<User> {
return this.userService.saveNew(userDto);
}
@Get(':id')
@ApiResponse({
status: 200,
description: 'The User with the matching id',
type: User,
})
@ApiResponse({ status: 404, description: 'Not found.' })
async findOne(@Param('id', new ParseIntPipe()) id: number): Promise<User> {
return (await this.userService.getOneById(id)).orElseThrow(
() => new NotFoundException(),
);
}
@Put(':id')
@ApiResponse({
status: 200,
description: 'The updated User with the matching id',
type: User,
})
@ApiResponse({ status: 404, description: 'Not found.' })
async updateOne(
@Param('id', new ParseIntPipe()) id: number,
@Body() userDto: UserDtoUpdateInfo,
): Promise<User> {
return this.userService.update(id, userDto);
}
@Put(':id/password')
@ApiResponse({
status: 200,
description: 'The updated User with the matching id',
type: User,
})
@ApiResponse({ status: 404, description: 'Not found.' })
async updateOnePassword(
@Param('id', new ParseIntPipe()) id: number,
@Body() userDto: UserDtoUpdatePassword,
): Promise<User> {
return this.userService.updatePassword(id, userDto);
}
@Delete(':id')
@ApiResponse({
status: 200,
description: 'The User with the matching id was deleted',
})
@ApiResponse({ status: 404, description: 'Not found.' })
async deleteOne(@Param('id', new ParseIntPipe()) id: number): Promise<void> {
await this.userService.deleteById(id);
}
}

54
src/modules/user/user.dto.ts

@ -0,0 +1,54 @@
import {
IsArray,
IsEmail,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiModelProperty } from '@nestjs/swagger';
export class UserDtoRegister {
@IsEmail()
@ApiModelProperty({ example: 'foo@bar.fr' })
email: string;
@IsString()
@ApiModelProperty({ example: 'foo' })
firstName: string;
@IsString()
@ApiModelProperty({ example: 'bar' })
lastName: string;
@IsString()
@MinLength(6)
@ApiModelProperty({ example: 'azerty', minLength: 6 })
password: string;
}
export class UserDtoUpdateInfo {
@IsString()
firstName: string;
@IsString()
lastName: string;
}
export class UserDtoUpdatePassword {
@IsString()
@MinLength(6)
@ApiModelProperty({ example: 'azerty', minLength: 6 })
oldPassword: string;
@IsString()
@MinLength(6)
@ApiModelProperty({ example: 'azerty', minLength: 6 })
newPassword: string;
@IsString()
@MinLength(6)
@ApiModelProperty({ example: 'azerty', minLength: 6 })
newPasswordBis: string;
}

22
src/modules/user/user.entity.ts

@ -0,0 +1,22 @@
import { DbAuditModel } from '../../utils/dbmodel.model';
import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
import { ApiModelProperty } from '@nestjs/swagger';
import { Exclude } from 'class-transformer';
@Entity()
export class User extends DbAuditModel {
@Column()
@ApiModelProperty({ example: 'foo@bar.fr' })
email: string;
@Column()
@ApiModelProperty()
firstName: string;
@Column()
@ApiModelProperty()
lastName: string;
@Column()
@Exclude()
password: string;
}

15
src/modules/user/user.module.ts

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
import { CryptoModule } from '../core/crypto/crypto.module';
@Module({
imports: [TypeOrmModule.forFeature([User, UserRepository]), CryptoModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}

20
src/modules/user/user.repository.ts

@ -0,0 +1,20 @@
import { EntityRepository, Repository } from 'typeorm';
import { User } from './user.entity';
import Optional from 'typescript-optional';
@EntityRepository(User)
export class UserRepository extends Repository<User> {
async findOneById(id: number): Promise<Optional<User>> {
return Optional.ofNullable(await this.findOne(id, {}));
}
async hasUserWithMatchingEmail(email: string): Promise<boolean> {
return (await this.count({ where: { email } })) === 1;
}
async findOneWithEmail(email: string): Promise<Optional<User>> {
return Optional.ofNullable(
await this.findOne({ email: email.toLowerCase() }),
);
}
}

104
src/modules/user/user.service.ts

@ -0,0 +1,104 @@
import { User } from './user.entity';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { UserRepository } from './user.repository';
// import { } from './user.constants';
import {
UserDtoRegister,
UserDtoUpdateInfo,
UserDtoUpdatePassword,
} from './user.dto';
import Optional from 'typescript-optional';
import { CryptoService } from '../core/crypto/crypto.service';
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserRepository)
private readonly userRepository: UserRepository,
private readonly cryptoService: CryptoService,
) {}
async getAll(): Promise<User[]> {
return this.userRepository.find({});
}
async getOneById(id: number): Promise<Optional<User>> {
return this.userRepository.findOneById(id);
}
async getOnWithEmail(email: string): Promise<Optional<User>> {
return await this.userRepository.findOneWithEmail(email);
}
async doPasswordMatch(user: User, password: string): Promise<boolean> {
return this.cryptoService.compare(password, user.password);
}
async saveNew(userRegister: UserDtoRegister): Promise<User> {
if (
await this.userRepository.hasUserWithMatchingEmail(
userRegister.email.toLowerCase(),
)
) {
throw new ConflictException('Email already taken');
}
let userNew = new User();
userNew.password = await this.cryptoService.hash(userRegister.password);
userNew.email = userRegister.email.toLowerCase();
userNew.lastName = userRegister.lastName;
userNew.firstName = userRegister.firstName;
userNew = await this.userRepository.save(userNew);
return userNew;
}
async update(id: number, body: UserDtoUpdateInfo): Promise<User> {
let userFound = (await this.userRepository.findOneById(id)).orElseThrow(
() => new NotFoundException(),
);
userFound.firstName = body.firstName;
userFound.lastName = body.lastName;
userFound = await this.userRepository.save(userFound);
return userFound;
}
async updatePassword(id: number, body: UserDtoUpdatePassword): Promise<User> {
let userFound = (await this.userRepository.findOneById(id)).orElseThrow(
() => new NotFoundException(),
);
if (!this.doPasswordMatch(userFound, body.oldPassword)) {
throw new BadRequestException('Old password do not match');
}
if (body.newPassword !== body.newPasswordBis) {
throw new BadRequestException('New passwords are not the same');
}
userFound.password = await this.cryptoService.hash(body.newPassword);
userFound = await this.userRepository.save(userFound);
return userFound;
}
async deleteById(id: number): Promise<void> {
const userFound = (await this.userRepository.findOneById(id)).orElseThrow(
() => new NotFoundException(),
);
await this.userRepository.remove(userFound);
}
}

27
src/utils/dbmodel.model.ts

@ -0,0 +1,27 @@
import { ApiModelProperty } from '@nestjs/swagger';
import {
CreateDateColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
VersionColumn,
} from 'typeorm';
import { Exclude } from 'class-transformer';
/**
* This model helps to have audits metrics to db models
*/
export abstract class DbAuditModel {
@ApiModelProperty()
@PrimaryGeneratedColumn()
id: number;
@CreateDateColumn()
creationDate: Date;
@UpdateDateColumn()
updateDate: Date;
@VersionColumn()
@Exclude()
version: number;
}

28
test/app.e2e-spec.ts

@ -0,0 +1,28 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterAll(async () => {
await app.close();
});
});

9
test/jest-e2e.json

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

19
tsconfig.json

@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"noImplicitAny": false,
"removeComments": true,
"noLib": false,
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"sourceMap": true,
"outDir": "./dist",
"lib": ["es2017", "es2015"],
"baseUrl": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

7
tsconfig.spec.json

@ -0,0 +1,7 @@
{
"extends": "tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["**/*.spec.ts", "**/*.d.ts"]
}

29
tslint.json

@ -0,0 +1,29 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended", "tslint-config-prettier"],
"jsRules": {
"no-unused-expression": true
},
"rules": {
"eofline": false,
"quotemark": [true, "single"],
"indent": false,
"member-access": [false],
"ordered-imports": [false],
"max-line-length": [true, 150],
"member-ordering": [false],
"curly": false,
"interface-name": [false],
"array-type": [false],
"no-empty-interface": false,
"no-empty": false,
"arrow-parens": false,
"object-literal-sort-keys": false,
"no-unused-expression": false,
"max-classes-per-file": [false],
"variable-name": [false],
"one-line": [false],
"one-variable-per-declaration": [false]
},
"rulesDirectory": []
}

34
webpack.config.js

@ -0,0 +1,34 @@
const webpack = require('webpack');
const path = require('path');
const nodeExternals = require('webpack-node-externals');
module.exports = {
entry: ['webpack/hot/poll?1000', './src/main.hmr.ts'],
watch: true,
target: 'node',
externals: [
nodeExternals({
whitelist: ['webpack/hot/poll?1000'],
}),
],
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
mode: "development",
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
],
output: {
path: path.join(__dirname, 'dist'),
filename: 'server.js',
},
};

9723
yarn.lock
File diff suppressed because it is too large
View File

Loading…
Cancel
Save