├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── License ├── README.md ├── docker-compose.yml ├── docker.env.example ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── src ├── app.controller.ts ├── app.module.ts ├── authentication │ ├── authentication.controller.ts │ ├── authentication.module.ts │ ├── authentication.service.ts │ ├── dto │ │ ├── login.dto.ts │ │ └── register.dto.ts │ ├── jwt-authentication.guard.ts │ ├── jwt-refresh-token.strategy.ts │ ├── jwt-refresh.guard.ts │ ├── jwt.strategy.ts │ ├── local-authentication.guard.ts │ ├── local.strategy.ts │ ├── request-with-user.interface.ts │ ├── tests │ │ ├── authentication.controller.integration.spec.ts │ │ ├── authentication.service.integration.spec.ts │ │ ├── authentication.service.spec.ts │ │ └── user.mock.ts │ ├── token-payload.interface.ts │ └── two-factor │ │ ├── dto │ │ └── two-factor-authentication-code.dto.ts │ │ ├── jwt-two-factor.guard.ts │ │ ├── jwt-two-factor.strategy.ts │ │ ├── two-factor-authentication.controller.ts │ │ └── two-factor-authentication.service.ts ├── authorization │ ├── authorization.controller.ts │ ├── authorization.module.ts │ ├── authorization.service.ts │ ├── dto │ │ ├── update-permissions.dto.ts │ │ └── update-role.dto.ts │ ├── permission.guard.ts │ ├── role.enum.ts │ ├── role.guard.ts │ └── types │ │ └── permission.type.ts ├── config │ ├── app.config.ts │ ├── aws.config.ts │ ├── bull.config.ts │ ├── database.config.ts │ ├── elastic-search.config.ts │ ├── email.config.ts │ ├── google.config.ts │ ├── jwt.config.ts │ ├── redis.config.ts │ └── typeorm.config.ts ├── database │ ├── database-logger.ts │ ├── database.module.ts │ └── postgres-error-codes.enum.ts ├── email │ ├── email.module.ts │ └── email.service.ts ├── env.validation.ts ├── features │ ├── categories │ │ ├── categories.controller.ts │ │ ├── categories.module.ts │ │ ├── categories.service.ts │ │ ├── dto │ │ │ ├── create-category.dto.ts │ │ │ └── update-category.dto.ts │ │ ├── entities │ │ │ └── category.entity.ts │ │ ├── enums │ │ │ └── categoriesPermission.enum.ts │ │ └── exceptions │ │ │ └── category-not-found.exception.ts │ ├── comments │ │ ├── commands │ │ │ ├── handlers │ │ │ │ └── create-comment.handler.ts │ │ │ └── implementations │ │ │ │ └── create-comment.command.ts │ │ ├── comments.controller.ts │ │ ├── comments.module.ts │ │ ├── comments.service.ts │ │ ├── dto │ │ │ ├── create-comment.dto.ts │ │ │ └── get-comments.dto.ts │ │ ├── entities │ │ │ └── comment.entity.ts │ │ └── queries │ │ │ ├── handlers │ │ │ └── get-comments.handler.ts │ │ │ └── implementations │ │ │ └── get-comments.query.ts │ ├── database-files │ │ ├── database-files.controller.ts │ │ ├── database-files.module.ts │ │ ├── database-files.service.ts │ │ └── entities │ │ │ └── database-file.entity.ts │ ├── email-confirmation │ │ ├── dto │ │ │ └── confirm-email.dto.ts │ │ ├── email-confirmation.controller.ts │ │ ├── email-confirmation.module.ts │ │ ├── email-confirmation.service.ts │ │ └── verification-token-payload.interface.ts │ ├── email-scheduling │ │ ├── dto │ │ │ └── email-schedule.dto.ts │ │ ├── email-scheduling.controller.ts │ │ ├── email-scheduling.module.ts │ │ └── email-scheduling.service.ts │ ├── files │ │ ├── entities │ │ │ ├── private-file.entity.ts │ │ │ └── public-file.entity.ts │ │ ├── exceptions │ │ │ └── file-not-found.exception.ts │ │ ├── files.module.ts │ │ └── files.service.ts │ ├── google-authentication │ │ ├── dto │ │ │ └── token-verification.dto.ts │ │ ├── google-authentication.controller.ts │ │ ├── google-authentication.module.ts │ │ └── google-authentication.service.ts │ ├── local-files │ │ ├── dto │ │ │ └── local-file.dto.ts │ │ ├── entities │ │ │ └── local-file.entity.ts │ │ ├── local-files.controller.ts │ │ ├── local-files.interceptor.ts │ │ ├── local-files.module.ts │ │ └── local-files.service.ts │ ├── optimize │ │ ├── dto │ │ │ └── images-upload.dto.ts │ │ ├── image.processor.ts │ │ ├── optimize.controller.ts │ │ └── optimize.module.ts │ ├── posts │ │ ├── dto │ │ │ ├── create-post.dto.ts │ │ │ └── update-post.dto.ts │ │ ├── entities │ │ │ └── post.entity.ts │ │ ├── exceptions │ │ │ └── post-not-found.exception.ts │ │ ├── posts-search.service.ts │ │ ├── posts.controller.ts │ │ ├── posts.module.ts │ │ ├── posts.service.ts │ │ └── types │ │ │ └── post-search-document.interface.ts │ ├── products │ │ ├── commands │ │ │ ├── handlers │ │ │ │ ├── create-product.handler.ts │ │ │ │ ├── delete-product.handler.ts │ │ │ │ └── update-product.handler.ts │ │ │ └── implementations │ │ │ │ ├── create-product.command.ts │ │ │ │ ├── delete-product.command.ts │ │ │ │ └── update-product.command.ts │ │ ├── dto │ │ │ ├── create-product.dto.ts │ │ │ ├── get-product.dto.ts │ │ │ └── update-product.dto.ts │ │ ├── entities │ │ │ └── product.entity.ts │ │ ├── enums │ │ │ └── productsPermission.enum.ts │ │ ├── exceptions │ │ │ └── file-not-found.exception.ts │ │ ├── products.controller.ts │ │ ├── products.module.ts │ │ ├── products.service.ts │ │ ├── queries │ │ │ ├── handlers │ │ │ │ ├── find-all-products.handler.ts │ │ │ │ └── find-product.handler.ts │ │ │ └── implementations │ │ │ │ ├── find-all-products.query.ts │ │ │ │ └── find-product.query.ts │ │ └── types │ │ │ ├── book-properties.interface.ts │ │ │ └── car-properties.interface.ts │ ├── search │ │ └── search.module.ts │ └── users │ │ ├── dto │ │ ├── create-user.dto.ts │ │ ├── file-response.dto.ts │ │ ├── file-upload-multiple.dto.ts │ │ ├── file-upload.dto.ts │ │ └── update-user.dto.ts │ │ ├── entities │ │ ├── address.entity.ts │ │ └── user.entity.ts │ │ ├── exceptions │ │ └── user-not-found.exception.ts │ │ ├── tests │ │ └── users.service.spec.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ └── users.service.ts ├── health │ ├── health.controller.ts │ └── health.module.ts ├── logger │ ├── custom-logger.ts │ ├── dto │ │ └── create-log.dto.ts │ ├── entities │ │ └── log.entity.ts │ ├── logger.module.ts │ └── logs.service.ts ├── main.ts └── utils │ ├── dto │ ├── find-one-params.dto.ts │ ├── object-with-id.dto.ts │ ├── paginated-result.dto.ts │ ├── pagination-with-start-id.dto.ts │ └── pagination.dto.ts │ ├── exceptions-logger.filter.ts │ ├── exclude-null.interceptor.ts │ ├── get-log-levels.ts │ ├── get-pagination-props.ts │ ├── http-cache.interceptor.ts │ ├── logs.middleware.ts │ ├── mocks │ ├── config.service.ts │ └── jwt.service.ts │ └── recursively-strip-null-values.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # local / production / staging / test 2 | APP_ENV=local 3 | APP_DEBUG=true 4 | APP_URL=localhost 5 | SUPPORT_EMAIL=support@${APP_URL} 6 | PORT=5000 7 | 8 | THROTTLE_TTL=60000 9 | THROTTLE_LIMIT=10000 10 | 11 | UPLOADED_FILES_DESTINATION=./uploadedFiles 12 | TWO_FACTOR_AUTHENTICATION_APP_NAME=Next_Starter_Template 13 | FRONTEND_URL=http://localhost:5001 14 | 15 | DATABASE_HOST=localhost 16 | DATABASE_PORT=5432 17 | DATABASE_USER=admin 18 | DATABASE_PASSWORD=admin 19 | DATABASE_NAME=nestjs 20 | 21 | TYPEORM_SYNCHRONIZE=true 22 | TYPEORM_LOGGING=true 23 | 24 | JWT_ACCESS_TOKEN_SECRET=fa420ba16c67af32d47d2bb8ac01a77d 25 | JWT_ACCESS_TOKEN_EXPIRATION_TIME=900 26 | JWT_REFRESH_TOKEN_SECRET=da420ba16e69af32d47d2dd8ac01a77d 27 | JWT_REFRESH_TOKEN_EXPIRATION_TIME=28800 28 | JWT_VERIFICATION_TOKEN_SECRET=7AnEd5epXmdaJfUrokkQ 29 | JWT_VERIFICATION_TOKEN_EXPIRATION_TIME=21600 30 | 31 | EMAIL_HOST=smtp.mailtrap.io 32 | EMAIL_PORT=2525 33 | EMAIL_IS_SECURE=false 34 | EMAIL_USER=user 35 | EMAIL_PASSWORD=password 36 | EMAIL_FROM=mailtrap@example.com 37 | EMAIL_CONFIRMATION_URL=http://localhost:5001/api/confirm-email 38 | 39 | REDIS_HOST=localhost 40 | REDIS_PORT=6379 41 | 42 | GOOGLE_AUTH_CLIENT_ID= 43 | GOOGLE_AUTH_CLIENT_SECRET= 44 | 45 | AWS_REGION= 46 | AWS_ACCESS_KEY_ID= 47 | AWS_SECRET_ACCESS_KEY= 48 | AWS_PUBLIC_BUCKET_NAME= 49 | 50 | ELASTICSEARCH_NODE=http://localhost:9200 51 | ELASTICSEARCH_USERNAME=elastic 52 | ELASTICSEARCH_PASSWORD=admin -------------------------------------------------------------------------------- /.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/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env 38 | nohup.out 39 | 40 | # Server uploaded files 41 | /uploadedFiles 42 | 43 | dump.rdb 44 | docker.env 45 | 46 | documentation -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /License: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shamim Hossain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Under Development: Do not use this repository for production purpose!** 2 | 3 | ## Description 4 | 5 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository with custom setup maintaining industry standard best practices. Just clone and start building without worrying about anything. Focus on the product not the setup. 6 | 7 | ## Overview 8 | 9 | - `config/` setup - separated segment for each config 10 | - `app.config.ts` 11 | - `aws.config.ts` 12 | - `database.config.ts` 13 | - `jwt.config.ts` 14 | - `typeorm.config.ts` 15 | - `authentication` module - JWT token based cookie authentication out of the box 16 | - `database` module - [Typeorm](https://typeorm.io/) 17 | - API validation and serialization 18 | - custom pagination (page, limit) - check the `src/features/comments` module 19 | - [CQRS](https://docs.nestjs.com/recipes/cqrs) - check the `src/features/comments` module 20 | - Out of the box file upload feature module to Amazon S3 (both private and public) 21 | - [CQRS](https://docs.nestjs.com/recipes/cqrs) + pagination - a complete module => `src/features/products` 22 | - Swagger Open API specification setup out of the box - check the `src/features/users/users.controller.ts` for examples 23 | - PostgreSQL database file storing support - check the avatar upload of `users.services.ts` and `src/features/database-files` 24 | - Soft Delete example - check the `src/features/categories` module 25 | - Two factor authentication setup out of the box - use `JwtTwoFactorGuard` when some specific endpoints need 2FA 26 | - Logging with built-in logger and TypeORM is configurable through `.env` - `TYPEORM_LOGGING=true` - specify false to disable database logging 27 | - `Health` module 28 | - Documentation with Compodoc and JSDoc - take a look on the `src/features/files/files.service.ts` for a detailed example. And for the generation command, run `pnpm run documentation:serve`. Or to customize it, check the `package.json`. 29 | - Sending scheduled emails with cron and Nodemailer - `src/features/email-scheduling` and check the email service in `src/email` 30 | - Out of the box Google authentication module - check out the `src/features/google-authentication` module 31 | - Roles and Permissions based authorization setup - check out the `src/authorization` module. For implementation, check the `src/features/categories` and `src/features/products` modules 32 | - In memory cache - check the `src/features/categories` module 33 | - Redis cache setup out of the box - check the `src/features/posts.module.ts` module 34 | - ElasticSearch setup out of the box with docker-compose - check the `/src/features/search.module.ts` module and `/src/features/posts.module.ts` for implementation details 35 | - Queue setup example - check the `/src/features/optimize` module 36 | 37 | ## Installation 38 | 39 | ```bash 40 | $ npm install 41 | ``` 42 | 43 | ## Running the app 44 | 45 | ```bash 46 | # development 47 | $ npm run start 48 | 49 | # watch mode 50 | $ npm run start:dev 51 | 52 | # production mode 53 | $ npm run start:prod 54 | ``` 55 | 56 | ## Test 57 | 58 | ```bash 59 | # unit tests 60 | $ npm run test 61 | 62 | # e2e tests 63 | $ npm run test:e2e 64 | 65 | # test coverage 66 | $ npm run test:cov 67 | ``` 68 | 69 | ## License 70 | 71 | Nest is [MIT licensed](License). 72 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | postgres: 4 | container_name: postgres 5 | image: postgres:latest 6 | ports: 7 | - "5432:5432" 8 | volumes: 9 | - /data/postgres:/data/postgres 10 | env_file: 11 | - docker.env 12 | networks: 13 | - postgres 14 | 15 | pgadmin: 16 | links: 17 | - postgres:postgres 18 | container_name: pgadmin 19 | image: dpage/pgadmin4 20 | ports: 21 | - "8080:80" 22 | volumes: 23 | - /data/pgadmin:/root/.pgadmin 24 | env_file: 25 | - docker.env 26 | networks: 27 | - postgres 28 | 29 | redis: 30 | image: "redis:alpine" 31 | ports: 32 | - "6379:6379" 33 | 34 | redis-commander: 35 | image: rediscommander/redis-commander:latest 36 | environment: 37 | - REDIS_HOSTS=local:redis:6379 38 | ports: 39 | - "8081:8081" 40 | depends_on: 41 | - redis 42 | 43 | es01: 44 | image: docker.elastic.co/elasticsearch/elasticsearch:8.2.1 45 | container_name: es01 46 | environment: 47 | - node.name=es01 48 | - cluster.name=es-docker-cluster 49 | - discovery.seed_hosts=es02,es03 50 | - cluster.initial_master_nodes=es01,es02,es03 51 | - bootstrap.memory_lock=true 52 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 53 | - xpack.security.enabled=false 54 | ulimits: 55 | memlock: 56 | soft: -1 57 | hard: -1 58 | volumes: 59 | - data01:/usr/share/elasticsearch/data 60 | ports: 61 | - 9200:9200 62 | networks: 63 | - elastic 64 | es02: 65 | image: docker.elastic.co/elasticsearch/elasticsearch:8.2.1 66 | container_name: es02 67 | environment: 68 | - node.name=es02 69 | - cluster.name=es-docker-cluster 70 | - discovery.seed_hosts=es01,es03 71 | - cluster.initial_master_nodes=es01,es02,es03 72 | - bootstrap.memory_lock=true 73 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 74 | - xpack.security.enabled=false 75 | ulimits: 76 | memlock: 77 | soft: -1 78 | hard: -1 79 | volumes: 80 | - data02:/usr/share/elasticsearch/data 81 | networks: 82 | - elastic 83 | es03: 84 | image: docker.elastic.co/elasticsearch/elasticsearch:8.2.1 85 | container_name: es03 86 | environment: 87 | - node.name=es03 88 | - cluster.name=es-docker-cluster 89 | - discovery.seed_hosts=es01,es02 90 | - cluster.initial_master_nodes=es01,es02,es03 91 | - bootstrap.memory_lock=true 92 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 93 | - xpack.security.enabled=false 94 | ulimits: 95 | memlock: 96 | soft: -1 97 | hard: -1 98 | volumes: 99 | - data03:/usr/share/elasticsearch/data 100 | networks: 101 | - elastic 102 | 103 | volumes: 104 | data01: 105 | driver: local 106 | data02: 107 | driver: local 108 | data03: 109 | driver: local 110 | 111 | networks: 112 | postgres: 113 | driver: bridge 114 | elastic: 115 | driver: bridge -------------------------------------------------------------------------------- /docker.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=admin 2 | POSTGRES_PASSWORD=admin 3 | POSTGRES_DB=nestjs 4 | PGADMIN_DEFAULT_EMAIL=admin@admin.com 5 | PGADMIN_DEFAULT_PASSWORD=admin 6 | 7 | ELASTIC_PASSWORD=admin -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": [ 6 | { 7 | "name": "@nestjs/swagger", 8 | "options": { 9 | "classValidatorShim": true, 10 | "introspectComments": false 11 | } 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-starter-template", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json", 22 | "documentation:serve": "npx @compodoc/compodoc -p tsconfig.json --serve", 23 | "deps:update": "taze major -I" 24 | }, 25 | "dependencies": { 26 | "@elastic/elasticsearch": "^8.6.0", 27 | "@nestjs/bull": "^0.6.3", 28 | "@nestjs/common": "^9.3.9", 29 | "@nestjs/config": "^2.3.1", 30 | "@nestjs/core": "^9.3.9", 31 | "@nestjs/cqrs": "^9.0.3", 32 | "@nestjs/elasticsearch": "^9.0.0", 33 | "@nestjs/jwt": "^10.0.2", 34 | "@nestjs/mapped-types": "^1.2.2", 35 | "@nestjs/passport": "^9.0.3", 36 | "@nestjs/platform-express": "^9.3.9", 37 | "@nestjs/schedule": "^2.2.0", 38 | "@nestjs/swagger": "^6.2.1", 39 | "@nestjs/terminus": "^9.2.1", 40 | "@nestjs/throttler": "^4.0.0", 41 | "@nestjs/typeorm": "^9.0.1", 42 | "aws-sdk": "^2.1329.0", 43 | "bcrypt": "^5.1.0", 44 | "bull": "^4.10.4", 45 | "cache-manager": "^5.1.7", 46 | "cache-manager-redis-store": "^3.0.1", 47 | "class-transformer": "^0.5.1", 48 | "class-validator": "^0.14.0", 49 | "cookie-parser": "^1.4.6", 50 | "googleapis": "^112.0.0", 51 | "helmet": "^6.0.1", 52 | "nodemailer": "^6.9.1", 53 | "otplib": "^12.0.1", 54 | "passport": "^0.6.0", 55 | "passport-jwt": "^4.0.1", 56 | "passport-local": "^1.0.0", 57 | "pg": "^8.10.0", 58 | "qrcode": "^1.5.1", 59 | "reflect-metadata": "^0.1.13", 60 | "rimraf": "^4.3.1", 61 | "rxjs": "^7.8.0", 62 | "swagger-ui-express": "^4.6.2", 63 | "taze": "^0.9.0", 64 | "typeorm": "^0.3.12", 65 | "uuid": "^9.0.0" 66 | }, 67 | "devDependencies": { 68 | "@compodoc/compodoc": "^1.1.19", 69 | "@nestjs/cli": "^9.2.0", 70 | "@nestjs/schematics": "^9.0.4", 71 | "@nestjs/testing": "^9.3.9", 72 | "@types/bcrypt": "^5.0.0", 73 | "@types/bull": "^4.10.0", 74 | "@types/cache-manager": "^4.0.2", 75 | "@types/cache-manager-redis-store": "^2.0.1", 76 | "@types/cookie-parser": "^1.4.3", 77 | "@types/cron": "^2.0.0", 78 | "@types/express": "^4.17.17", 79 | "@types/jest": "^29.4.0", 80 | "@types/multer": "^1.4.7", 81 | "@types/node": "^18.14.6", 82 | "@types/nodemailer": "^6.4.7", 83 | "@types/passport-jwt": "^3.0.8", 84 | "@types/passport-local": "^1.0.35", 85 | "@types/qrcode": "^1.5.0", 86 | "@types/supertest": "^2.0.12", 87 | "@types/uuid": "^9.0.1", 88 | "@typescript-eslint/eslint-plugin": "^5.54.1", 89 | "@typescript-eslint/parser": "^5.54.1", 90 | "eslint": "^8.35.0", 91 | "eslint-config-prettier": "^8.7.0", 92 | "eslint-plugin-prettier": "^4.2.1", 93 | "jest": "^29.5.0", 94 | "prettier": "^2.8.4", 95 | "source-map-support": "^0.5.21", 96 | "supertest": "^6.3.3", 97 | "ts-jest": "^29.0.5", 98 | "ts-loader": "^9.4.2", 99 | "ts-node": "^10.9.1", 100 | "tsconfig-paths": "^4.1.2", 101 | "typescript": "^4.9.5" 102 | }, 103 | "jest": { 104 | "moduleFileExtensions": [ 105 | "js", 106 | "json", 107 | "ts" 108 | ], 109 | "rootDir": "src", 110 | "testRegex": ".*\\.spec\\.ts$", 111 | "transform": { 112 | "^.+\\.(t|j)s$": "ts-jest" 113 | }, 114 | "collectCoverageFrom": [ 115 | "**/*.(t|j)s" 116 | ], 117 | "coverageDirectory": "../coverage", 118 | "testEnvironment": "node" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | @Controller({ 5 | version: VERSION_NEUTRAL, 6 | }) 7 | export class AppController { 8 | constructor(private configService: ConfigService) {} 9 | 10 | @Get() 11 | getHello(): string { 12 | // * config variables 13 | const port = this.configService.get('app.port'); 14 | const appUrl = this.configService.get('app.url'); 15 | const supportEmail = this.configService.get('app.supportEmail'); 16 | const appEnv = this.configService.get('app.env'); 17 | const debugMode = this.configService.get('app.debugMode'); 18 | 19 | const debugEnabled = debugMode ? 'yes' : 'no'; 20 | 21 | return ( 22 | '

' + 23 | (appUrl + 24 | ':' + 25 | port + 26 | ' | ' + 27 | supportEmail + 28 | ' >> ' + 29 | appEnv + 30 | ' environment in debug mode: ' + 31 | debugEnabled) + 32 | '

' 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassSerializerInterceptor, 3 | MiddlewareConsumer, 4 | Module, 5 | ValidationPipe, 6 | } from '@nestjs/common'; 7 | import { ConfigModule } from '@nestjs/config'; 8 | import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; 9 | import { ScheduleModule } from '@nestjs/schedule'; 10 | import { BullModule } from '@nestjs/bull'; 11 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 12 | import { AppController } from './app.controller'; 13 | import { DatabaseModule } from './database/database.module'; 14 | import { validate } from './env.validation'; 15 | import { AuthenticationModule } from './authentication/authentication.module'; 16 | import { UsersModule } from './features/users/users.module'; 17 | import { PostsModule } from './features/posts/posts.module'; 18 | import { CategoriesModule } from './features/categories/categories.module'; 19 | import { FilesModule } from './features/files/files.module'; 20 | import appConfig, { throttleModuleAsyncOptions } from './config/app.config'; 21 | import databaseConfig from './config/database.config'; 22 | import typeormConfig from './config/typeorm.config'; 23 | import awsConfig from './config/aws.config'; 24 | import jwtConfig from './config/jwt.config'; 25 | import emailConfig from './config/email.config'; 26 | import googleConfig from './config/google.config'; 27 | import redisConfig from './config/redis.config'; 28 | import elasticSearch from './config/elastic-search.config'; 29 | import { CommentsModule } from './features/comments/comments.module'; 30 | import { ProductsModule } from './features/products/products.module'; 31 | import { DatabaseFilesModule } from './features/database-files/database-files.module'; 32 | import { LocalFilesModule } from './features/local-files/local-files.module'; 33 | import { LoggerModule } from './logger/logger.module'; 34 | import { LogsMiddleware } from './utils/logs.middleware'; 35 | import { HealthModule } from './health/health.module'; 36 | import { EmailModule } from './email/email.module'; 37 | import { EmailSchedulingModule } from './features/email-scheduling/email-scheduling.module'; 38 | import { EmailConfirmationModule } from './features/email-confirmation/email-confirmation.module'; 39 | import { GoogleAuthenticationModule } from './features/google-authentication/google-authentication.module'; 40 | import { AuthorizationModule } from './authorization/authorization.module'; 41 | import { SearchModule } from './features/search/search.module'; 42 | import { bullModuleOptions } from './config/bull.config'; 43 | import { OptimizeModule } from './features/optimize/optimize.module'; 44 | 45 | @Module({ 46 | imports: [ 47 | ThrottlerModule.forRootAsync(throttleModuleAsyncOptions), 48 | ConfigModule.forRoot({ 49 | isGlobal: true, 50 | cache: true, 51 | expandVariables: true, 52 | validate, 53 | load: [ 54 | appConfig, 55 | databaseConfig, 56 | typeormConfig, 57 | awsConfig, 58 | jwtConfig, 59 | emailConfig, 60 | googleConfig, 61 | redisConfig, 62 | elasticSearch, 63 | ], 64 | }), 65 | ScheduleModule.forRoot(), 66 | DatabaseModule, 67 | AuthenticationModule, 68 | UsersModule, 69 | PostsModule, 70 | CategoriesModule, 71 | FilesModule, 72 | CommentsModule, 73 | ProductsModule, 74 | DatabaseFilesModule, 75 | LocalFilesModule, 76 | LoggerModule, 77 | HealthModule, 78 | EmailModule, 79 | EmailSchedulingModule, 80 | EmailConfirmationModule, 81 | GoogleAuthenticationModule, 82 | AuthorizationModule, 83 | SearchModule, 84 | BullModule.forRootAsync(bullModuleOptions), 85 | OptimizeModule, 86 | ], 87 | controllers: [AppController], 88 | providers: [ 89 | { 90 | provide: APP_GUARD, 91 | useClass: ThrottlerGuard, 92 | }, 93 | { 94 | provide: APP_PIPE, 95 | useClass: ValidationPipe, 96 | }, 97 | { 98 | provide: APP_INTERCEPTOR, 99 | useClass: ClassSerializerInterceptor, 100 | }, 101 | // { 102 | // provide: APP_FILTER, 103 | // useClass: ExceptionsLoggerFilter, 104 | // }, 105 | // { 106 | // provide: APP_INTERCEPTOR, 107 | // useClass: ExcludeNullInterceptor, 108 | // }, 109 | ], 110 | }) 111 | export class AppModule { 112 | configure(consumer: MiddlewareConsumer) { 113 | consumer.apply(LogsMiddleware).forRoutes('*'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/authentication/authentication.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | Post, 7 | Req, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { ApiBody, ApiTags } from '@nestjs/swagger'; 11 | import { EmailConfirmationService } from '../features/email-confirmation/email-confirmation.service'; 12 | import { UsersService } from '../features/users/users.service'; 13 | import { AuthenticationService } from './authentication.service'; 14 | import { LogInDto } from './dto/login.dto'; 15 | import { RegisterDto } from './dto/register.dto'; 16 | import { JwtAuthenticationGuard } from './jwt-authentication.guard'; 17 | import { JwtRefreshGuard } from './jwt-refresh.guard'; 18 | import { LocalAuthenticationGuard } from './local-authentication.guard'; 19 | import { RequestWithUser } from './request-with-user.interface'; 20 | 21 | @Controller('authentication') 22 | @ApiTags('authentication') 23 | export class AuthenticationController { 24 | constructor( 25 | private readonly authenticationService: AuthenticationService, 26 | private readonly usersService: UsersService, 27 | private readonly emailConfirmationService: EmailConfirmationService, 28 | ) {} 29 | 30 | @UseGuards(JwtAuthenticationGuard) 31 | @Get() 32 | authenticate(@Req() request: RequestWithUser) { 33 | const user = request.user; 34 | return user; 35 | } 36 | 37 | @UseGuards(JwtRefreshGuard) 38 | @Get('refresh') 39 | refresh(@Req() request: RequestWithUser) { 40 | const accessTokenCookie = 41 | this.authenticationService.getCookieWithJwtAccessToken(request.user.id); 42 | 43 | request.res.setHeader('Set-Cookie', accessTokenCookie); 44 | return request.user; 45 | } 46 | 47 | @Post('register') 48 | async register(@Body() registrationData: RegisterDto) { 49 | const user = await this.authenticationService.register(registrationData); 50 | await this.emailConfirmationService.sendVerificationLink(registrationData); 51 | return user; 52 | } 53 | 54 | @HttpCode(200) 55 | @UseGuards(LocalAuthenticationGuard) 56 | @Post('login') 57 | @ApiBody({ type: LogInDto }) 58 | async login(@Req() request: RequestWithUser) { 59 | const { user } = request; 60 | const accessTokenCookie = 61 | this.authenticationService.getCookieWithJwtAccessToken(user.id); 62 | const { cookie: refreshTokenCookie, token: refreshToken } = 63 | this.authenticationService.getCookieWithJwtRefreshToken(user.id); 64 | 65 | await this.usersService.setCurrentRefreshToken(refreshToken, user.id); 66 | 67 | request.res.setHeader('Set-Cookie', [ 68 | accessTokenCookie, 69 | refreshTokenCookie, 70 | ]); 71 | 72 | if (user.isTwoFactorAuthenticationEnabled) { 73 | return; 74 | } 75 | 76 | return user; 77 | } 78 | 79 | @UseGuards(JwtAuthenticationGuard) 80 | @Post('logout') 81 | @HttpCode(200) 82 | async logOut(@Req() request: RequestWithUser) { 83 | await this.usersService.removeRefreshToken(request.user.id); 84 | request.res.setHeader( 85 | 'Set-Cookie', 86 | this.authenticationService.getCookiesForLogOut(), 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/authentication/authentication.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { EmailConfirmationModule } from '../features/email-confirmation/email-confirmation.module'; 6 | import { UsersModule } from '../features/users/users.module'; 7 | import { AuthenticationController } from './authentication.controller'; 8 | import { AuthenticationService } from './authentication.service'; 9 | import { JwtRefreshTokenStrategy } from './jwt-refresh-token.strategy'; 10 | import { JwtStrategy } from './jwt.strategy'; 11 | import { LocalStrategy } from './local.strategy'; 12 | import { JwtTwoFactorStrategy } from './two-factor/jwt-two-factor.strategy'; 13 | import { TwoFactorAuthenticationController } from './two-factor/two-factor-authentication.controller'; 14 | import { TwoFactorAuthenticationService } from './two-factor/two-factor-authentication.service'; 15 | 16 | @Module({ 17 | imports: [ 18 | UsersModule, 19 | PassportModule, 20 | ConfigModule, 21 | JwtModule.registerAsync({ 22 | imports: [ConfigModule], 23 | inject: [ConfigService], 24 | useFactory: async (ConfigService: ConfigService) => ({ 25 | secret: ConfigService.get('jwt.accessTokenSecret'), 26 | signOptions: { 27 | expiresIn: `${ConfigService.get('jwt.accessTokenExpirationTime')}s`, 28 | }, 29 | }), 30 | }), 31 | EmailConfirmationModule, 32 | ], 33 | providers: [ 34 | AuthenticationService, 35 | LocalStrategy, 36 | JwtStrategy, 37 | JwtRefreshTokenStrategy, 38 | TwoFactorAuthenticationService, 39 | JwtTwoFactorStrategy, 40 | ], 41 | controllers: [AuthenticationController, TwoFactorAuthenticationController], 42 | exports: [AuthenticationService], 43 | }) 44 | export class AuthenticationModule {} 45 | -------------------------------------------------------------------------------- /src/authentication/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { UsersService } from '../features/users/users.service'; 3 | import * as bcrypt from 'bcrypt'; 4 | import { PostgresErrorCode } from '../database/postgres-error-codes.enum'; 5 | import { RegisterDto } from './dto/register.dto'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { TokenPayload } from './token-payload.interface'; 9 | 10 | @Injectable() 11 | export class AuthenticationService { 12 | constructor( 13 | private readonly usersService: UsersService, 14 | private readonly jwtService: JwtService, 15 | private readonly configService: ConfigService, 16 | ) {} 17 | 18 | public async register(registrationData: RegisterDto) { 19 | const hashedPassword = await bcrypt.hash(registrationData.password, 10); 20 | 21 | try { 22 | const createdUser = await this.usersService.create({ 23 | ...registrationData, 24 | password: hashedPassword, 25 | }); 26 | return createdUser; 27 | } catch (error: any) { 28 | if (error?.code === PostgresErrorCode.UniqueViolation) { 29 | throw new HttpException( 30 | 'User with that email already exists', 31 | HttpStatus.BAD_REQUEST, 32 | ); 33 | } 34 | throw new HttpException( 35 | 'Something went wrong', 36 | HttpStatus.INTERNAL_SERVER_ERROR, 37 | ); 38 | } 39 | } 40 | 41 | public async getAuthenticatedUser(email: string, plainTextPassword: string) { 42 | try { 43 | const user = await this.usersService.getByEmail(email); 44 | await this.verifyPassword(plainTextPassword, user.password); 45 | return user; 46 | } catch (error) { 47 | throw new HttpException( 48 | 'Wrong credentials provided', 49 | HttpStatus.BAD_REQUEST, 50 | ); 51 | } 52 | } 53 | 54 | public getCookieWithJwtAccessToken( 55 | userId: number, 56 | isSecondFactorAuthenticated = false, 57 | ) { 58 | const payload: TokenPayload = { userId, isSecondFactorAuthenticated }; 59 | const token = this.jwtService.sign(payload, { 60 | secret: this.configService.get('jwt.accessTokenSecret'), 61 | expiresIn: `${this.configService.get('jwt.accessTokenExpirationTime')}s`, 62 | }); 63 | return `Authentication=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get( 64 | 'jwt.accessTokenExpirationTime', 65 | )}`; 66 | } 67 | 68 | public getCookieWithJwtRefreshToken(userId: number) { 69 | const payload: TokenPayload = { userId }; 70 | const token = this.jwtService.sign(payload, { 71 | secret: this.configService.get('jwt.refreshTokenSecret'), 72 | expiresIn: `${this.configService.get('jwt.refreshTokenExpirationTime')}s`, 73 | }); 74 | const cookie = `Refresh=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get( 75 | 'jwt.refreshTokenExpirationTime', 76 | )}`; 77 | return { 78 | cookie, 79 | token, 80 | }; 81 | } 82 | 83 | public getCookiesForLogOut() { 84 | return [ 85 | 'Authentication=; HttpOnly; Path=/; Max-Age=0', 86 | 'Refresh=; HttpOnly; Path=/; Max-Age=0', 87 | ]; 88 | } 89 | 90 | private async verifyPassword( 91 | plainTextPassword: string, 92 | hashedPassword: string, 93 | ) { 94 | const isPasswordMatching = await bcrypt.compare( 95 | plainTextPassword, 96 | hashedPassword, 97 | ); 98 | 99 | if (!isPasswordMatching) { 100 | throw new HttpException( 101 | 'Wrong credentials provided', 102 | HttpStatus.BAD_REQUEST, 103 | ); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/authentication/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsString, IsNotEmpty, MinLength } from 'class-validator'; 3 | 4 | export class LogInDto { 5 | @IsEmail() 6 | @ApiProperty({ 7 | default: 'test@example.com', 8 | }) 9 | email: string; 10 | 11 | @IsString() 12 | @IsNotEmpty() 13 | @MinLength(7) 14 | @ApiProperty({ 15 | default: 'password', 16 | }) 17 | password: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/authentication/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { 3 | IsEmail, 4 | IsString, 5 | IsNotEmpty, 6 | MinLength, 7 | Matches, 8 | IsOptional, 9 | } from 'class-validator'; 10 | 11 | export class RegisterDto { 12 | @IsEmail() 13 | email: string; 14 | 15 | @IsString() 16 | @IsNotEmpty() 17 | name: string; 18 | 19 | @IsString() 20 | @IsNotEmpty() 21 | @MinLength(7) 22 | password: string; 23 | 24 | @ApiProperty({ 25 | description: 'Has to match a regular expression: /^\\+[1-9]\\d{1,14}$/', 26 | example: '+123123123123', 27 | }) 28 | @IsOptional() 29 | @IsString() 30 | @IsNotEmpty() 31 | @Matches(/^\+[1-9]\d{1,14}$/) 32 | phoneNumber?: string; 33 | } 34 | -------------------------------------------------------------------------------- /src/authentication/jwt-authentication.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthenticationGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/authentication/jwt-refresh-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { UsersService } from '../features/users/users.service'; 6 | import { Request } from 'express'; 7 | import { TokenPayload } from './token-payload.interface'; 8 | 9 | @Injectable() 10 | export class JwtRefreshTokenStrategy extends PassportStrategy( 11 | Strategy, 12 | 'jwt-refresh-token', 13 | ) { 14 | constructor( 15 | private readonly configService: ConfigService, 16 | private readonly userService: UsersService, 17 | ) { 18 | super({ 19 | jwtFromRequest: ExtractJwt.fromExtractors([ 20 | (request: Request) => { 21 | return request?.cookies?.Refresh; 22 | }, 23 | ]), 24 | secretOrKey: configService.get('jwt.refreshTokenSecret'), 25 | passReqToCallback: true, 26 | }); 27 | } 28 | 29 | async validate(request: Request, payload: TokenPayload) { 30 | const refreshToken = request.cookies?.Refresh; 31 | return this.userService.getUserIfRefreshTokenMatches( 32 | refreshToken, 33 | payload.userId, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/authentication/jwt-refresh.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {} 6 | -------------------------------------------------------------------------------- /src/authentication/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Request } from 'express'; 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | import { UsersService } from '../features/users/users.service'; 7 | import { TokenPayload } from './token-payload.interface'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor( 12 | private readonly configService: ConfigService, 13 | private readonly userService: UsersService, 14 | ) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromExtractors([ 17 | (request: Request) => { 18 | return request?.cookies?.Authentication; 19 | }, 20 | ]), 21 | secretOrKey: configService.get('jwt.accessTokenSecret'), 22 | }); 23 | } 24 | 25 | async validate(payload: TokenPayload) { 26 | return this.userService.getById(payload.userId); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/authentication/local-authentication.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthenticationGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/authentication/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { User } from '../features/users/entities/user.entity'; 5 | import { AuthenticationService } from './authentication.service'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor(private authenticationService: AuthenticationService) { 10 | super({ 11 | usernameField: 'email', 12 | }); 13 | } 14 | 15 | async validate(email: string, password: string): Promise { 16 | return this.authenticationService.getAuthenticatedUser(email, password); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/authentication/request-with-user.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { User } from '../features/users/entities/user.entity'; 3 | 4 | export interface RequestWithUser extends Request { 5 | user: User; 6 | } 7 | -------------------------------------------------------------------------------- /src/authentication/tests/authentication.controller.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { AuthenticationService } from '../authentication.service'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { getRepositoryToken } from '@nestjs/typeorm'; 6 | import { User } from '../../features/users/entities/user.entity'; 7 | import { UsersService } from '../../features/users/users.service'; 8 | import { mockedJwtService } from '../../utils/mocks/jwt.service'; 9 | import { mockedConfigService } from '../../utils/mocks/config.service'; 10 | import { mockedUser } from './user.mock'; 11 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 12 | import { AuthenticationController } from '../authentication.controller'; 13 | import * as request from 'supertest'; 14 | 15 | describe('The AuthenticationController', () => { 16 | let app: INestApplication; 17 | let userData: User; 18 | 19 | beforeEach(async () => { 20 | userData = { 21 | ...mockedUser, 22 | }; 23 | 24 | const usersRepository = { 25 | create: jest.fn().mockResolvedValue(userData), 26 | save: jest.fn().mockReturnValue(Promise.resolve()), 27 | }; 28 | 29 | const module: TestingModule = await Test.createTestingModule({ 30 | controllers: [AuthenticationController], 31 | providers: [ 32 | UsersService, 33 | AuthenticationService, 34 | { 35 | provide: ConfigService, 36 | useValue: mockedConfigService, 37 | }, 38 | { 39 | provide: JwtService, 40 | useValue: mockedJwtService, 41 | }, 42 | { 43 | provide: getRepositoryToken(User), 44 | useValue: usersRepository, 45 | }, 46 | ], 47 | }).compile(); 48 | 49 | app = module.createNestApplication(); 50 | app.useGlobalPipes(new ValidationPipe()); 51 | await app.init(); 52 | }); 53 | 54 | describe('when registering', () => { 55 | describe('and using valid data', () => { 56 | it('should respond with the data of the user without the password', () => { 57 | const expectedData = { 58 | ...userData, 59 | }; 60 | // delete expectedData.password; 61 | 62 | return request(app.getHttpServer()) 63 | .post('/authentication/register') 64 | .send({ 65 | email: mockedUser.email, 66 | name: mockedUser.name, 67 | password: 'strongPassword', 68 | }) 69 | .expect(201) 70 | .expect(expectedData); 71 | }); 72 | }); 73 | 74 | describe('and using invalid data', () => { 75 | it('should throw an error', () => { 76 | return request(app.getHttpServer()) 77 | .post('/authentication/register') 78 | .send({ 79 | name: mockedUser.name, 80 | }) 81 | .expect(400); 82 | }); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/authentication/tests/authentication.service.integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { AuthenticationService } from '../authentication.service'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { getRepositoryToken } from '@nestjs/typeorm'; 6 | import { User } from '../../features/users/entities/user.entity'; 7 | import { UsersService } from '../../features/users/users.service'; 8 | import { mockedJwtService } from '../../utils/mocks/jwt.service'; 9 | import { mockedConfigService } from '../../utils/mocks/config.service'; 10 | import * as bcrypt from 'bcrypt'; 11 | import { mockedUser } from './user.mock'; 12 | 13 | jest.mock('bcrypt'); 14 | 15 | describe('The AuthenticationService', () => { 16 | let authenticationService: AuthenticationService; 17 | let usersService: UsersService; 18 | let bcryptCompare: jest.Mock; 19 | let userData: User; 20 | let findUser: jest.Mock; 21 | 22 | beforeEach(async () => { 23 | userData = { 24 | ...mockedUser, 25 | }; 26 | findUser = jest.fn().mockResolvedValue(userData); 27 | const usersRepository = { 28 | findOne: findUser, 29 | }; 30 | 31 | bcryptCompare = jest.fn().mockReturnValue(true); 32 | (bcrypt.compare as jest.Mock) = bcryptCompare; 33 | 34 | const module: TestingModule = await Test.createTestingModule({ 35 | providers: [ 36 | UsersService, 37 | AuthenticationService, 38 | { 39 | provide: ConfigService, 40 | useValue: mockedConfigService, 41 | }, 42 | { 43 | provide: JwtService, 44 | useValue: mockedJwtService, 45 | }, 46 | { 47 | provide: getRepositoryToken(User), 48 | useValue: usersRepository, 49 | }, 50 | ], 51 | }).compile(); 52 | 53 | authenticationService = await module.get( 54 | AuthenticationService, 55 | ); 56 | usersService = await module.get(UsersService); 57 | }); 58 | 59 | describe('when accessing the data of authenticating user', () => { 60 | it('should attempt to get a user by email', async () => { 61 | const getByEmailSpy = jest.spyOn(usersService, 'getByEmail'); 62 | await authenticationService.getAuthenticatedUser( 63 | 'user@email.com', 64 | 'strongPassword', 65 | ); 66 | expect(getByEmailSpy).toBeCalledTimes(1); 67 | }); 68 | 69 | describe('and the provided password is not valid', () => { 70 | beforeEach(() => { 71 | bcryptCompare.mockReturnValue(false); 72 | }); 73 | it('should throw an error', async () => { 74 | await expect( 75 | authenticationService.getAuthenticatedUser( 76 | 'user@email.com', 77 | 'strongPassword', 78 | ), 79 | ).rejects.toThrow(); 80 | }); 81 | }); 82 | 83 | describe('and the provided password is valid', () => { 84 | beforeEach(() => { 85 | bcryptCompare.mockReturnValue(true); 86 | }); 87 | describe('and the user is found in the database', () => { 88 | beforeEach(() => { 89 | findUser.mockResolvedValue(userData); 90 | }); 91 | it('should return the user data', async () => { 92 | const user = await authenticationService.getAuthenticatedUser( 93 | 'user@email.com', 94 | 'strongPassword', 95 | ); 96 | expect(user).toBe(userData); 97 | }); 98 | }); 99 | 100 | describe('and the user is not found in the database', () => { 101 | beforeEach(() => { 102 | findUser.mockResolvedValue(undefined); 103 | }); 104 | it('should throw an error', async () => { 105 | await expect( 106 | authenticationService.getAuthenticatedUser( 107 | 'user@email.com', 108 | 'strongPassword', 109 | ), 110 | ).rejects.toThrow(); 111 | }); 112 | }); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/authentication/tests/authentication.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { AuthenticationService } from '../authentication.service'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { getRepositoryToken } from '@nestjs/typeorm'; 6 | import { User } from '../../features/users/entities/user.entity'; 7 | import { UsersService } from '../../features/users/users.service'; 8 | import { mockedJwtService } from '../../utils/mocks/jwt.service'; 9 | import { mockedConfigService } from '../../utils/mocks/config.service'; 10 | 11 | describe('The AuthenticationService', () => { 12 | let authenticationService: AuthenticationService; 13 | 14 | beforeEach(async () => { 15 | const module: TestingModule = await Test.createTestingModule({ 16 | providers: [ 17 | UsersService, 18 | AuthenticationService, 19 | { 20 | provide: ConfigService, 21 | useValue: mockedConfigService, 22 | }, 23 | { 24 | provide: JwtService, 25 | useValue: mockedJwtService, 26 | }, 27 | { 28 | provide: getRepositoryToken(User), 29 | useValue: {}, 30 | }, 31 | ], 32 | }).compile(); 33 | 34 | authenticationService = await module.get( 35 | AuthenticationService, 36 | ); 37 | }); 38 | 39 | describe('when creating a cookie', () => { 40 | it('should return a string', () => { 41 | const userId = 1; 42 | expect( 43 | typeof authenticationService.getCookieWithJwtAccessToken(userId), 44 | ).toEqual('string'); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/authentication/tests/user.mock.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../features/users/entities/user.entity'; 2 | 3 | export const mockedUser: User = { 4 | id: 1, 5 | email: 'user@email.com', 6 | name: 'John', 7 | password: 'hash', 8 | address: { 9 | id: 1, 10 | street: 'streetName', 11 | city: 'cityName', 12 | country: 'countryName', 13 | }, 14 | isTwoFactorAuthenticationEnabled: false, 15 | }; 16 | -------------------------------------------------------------------------------- /src/authentication/token-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface TokenPayload { 2 | userId: number; 3 | isSecondFactorAuthenticated?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/authentication/two-factor/dto/two-factor-authentication-code.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class TwoFactorAuthenticationCodeDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | twoFactorAuthenticationCode: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/authentication/two-factor/jwt-two-factor.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtTwoFactorGuard extends AuthGuard('jwt-two-factor') {} 6 | -------------------------------------------------------------------------------- /src/authentication/two-factor/jwt-two-factor.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { Request } from 'express'; 6 | import { UsersService } from '../../features/users/users.service'; 7 | import { TokenPayload } from '../token-payload.interface'; 8 | 9 | @Injectable() 10 | export class JwtTwoFactorStrategy extends PassportStrategy( 11 | Strategy, 12 | 'jwt-two-factor', 13 | ) { 14 | constructor( 15 | private readonly configService: ConfigService, 16 | private readonly userService: UsersService, 17 | ) { 18 | super({ 19 | jwtFromRequest: ExtractJwt.fromExtractors([ 20 | (request: Request) => { 21 | return request?.cookies?.Authentication; 22 | }, 23 | ]), 24 | secretOrKey: configService.get('jwt.accessTokenSecret'), 25 | }); 26 | } 27 | 28 | async validate(payload: TokenPayload) { 29 | const user = await this.userService.getById(payload.userId); 30 | if (!user.isTwoFactorAuthenticationEnabled) { 31 | return user; 32 | } 33 | if (payload.isSecondFactorAuthenticated) { 34 | return user; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/authentication/two-factor/two-factor-authentication.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Res, 5 | UseGuards, 6 | Req, 7 | Body, 8 | UnauthorizedException, 9 | HttpCode, 10 | } from '@nestjs/common'; 11 | import { ApiTags } from '@nestjs/swagger'; 12 | import { Response } from 'express'; 13 | import { UsersService } from '../../features/users/users.service'; 14 | import { AuthenticationService } from '../authentication.service'; 15 | import { JwtAuthenticationGuard } from '../jwt-authentication.guard'; 16 | import { RequestWithUser } from '../request-with-user.interface'; 17 | import { TwoFactorAuthenticationCodeDto } from './dto/two-factor-authentication-code.dto'; 18 | import { TwoFactorAuthenticationService } from './two-factor-authentication.service'; 19 | 20 | @Controller('2fa') 21 | @ApiTags('authentication-2fa') 22 | export class TwoFactorAuthenticationController { 23 | constructor( 24 | private readonly twoFactorAuthenticationService: TwoFactorAuthenticationService, 25 | private readonly usersService: UsersService, 26 | private readonly authenticationService: AuthenticationService, 27 | ) {} 28 | 29 | @Post('generate') 30 | @UseGuards(JwtAuthenticationGuard) 31 | async register(@Res() response: Response, @Req() request: RequestWithUser) { 32 | const { otpauthUrl } = 33 | await this.twoFactorAuthenticationService.generateTwoFactorAuthenticationSecret( 34 | request.user, 35 | ); 36 | 37 | response.setHeader('content-type', 'image/png'); 38 | 39 | return this.twoFactorAuthenticationService.pipeQrCodeStream( 40 | response, 41 | otpauthUrl, 42 | ); 43 | } 44 | 45 | @Post('turn-on') 46 | @HttpCode(200) 47 | @UseGuards(JwtAuthenticationGuard) 48 | async turnOnTwoFactorAuthentication( 49 | @Req() request: RequestWithUser, 50 | @Body() { twoFactorAuthenticationCode }: TwoFactorAuthenticationCodeDto, 51 | ) { 52 | const isCodeValid = 53 | this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid( 54 | twoFactorAuthenticationCode, 55 | request.user, 56 | ); 57 | if (!isCodeValid) { 58 | throw new UnauthorizedException('Wrong authentication code'); 59 | } 60 | await this.usersService.turnOnTwoFactorAuthentication(request.user.id); 61 | } 62 | 63 | @Post('turn-off') 64 | @HttpCode(200) 65 | @UseGuards(JwtAuthenticationGuard) 66 | async turnOffTwoFactorAuthentication( 67 | @Req() request: RequestWithUser, 68 | @Body() { twoFactorAuthenticationCode }: TwoFactorAuthenticationCodeDto, 69 | ) { 70 | const isCodeValid = 71 | this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid( 72 | twoFactorAuthenticationCode, 73 | request.user, 74 | ); 75 | if (!isCodeValid) { 76 | throw new UnauthorizedException('Wrong authentication code'); 77 | } 78 | await this.usersService.turnOffTwoFactorAuthentication(request.user.id); 79 | } 80 | 81 | @Post('authenticate') 82 | @HttpCode(200) 83 | @UseGuards(JwtAuthenticationGuard) 84 | async authenticate( 85 | @Req() request: RequestWithUser, 86 | @Body() { twoFactorAuthenticationCode }: TwoFactorAuthenticationCodeDto, 87 | ) { 88 | const isCodeValid = 89 | this.twoFactorAuthenticationService.isTwoFactorAuthenticationCodeValid( 90 | twoFactorAuthenticationCode, 91 | request.user, 92 | ); 93 | if (!isCodeValid) { 94 | throw new UnauthorizedException('Wrong authentication code'); 95 | } 96 | 97 | const accessTokenCookie = 98 | this.authenticationService.getCookieWithJwtAccessToken( 99 | request.user.id, 100 | true, 101 | ); 102 | 103 | request.res.setHeader('Set-Cookie', [accessTokenCookie]); 104 | 105 | return request.user; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/authentication/two-factor/two-factor-authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { authenticator } from 'otplib'; 4 | import { toFileStream } from 'qrcode'; 5 | import { Response } from 'express'; 6 | import { User } from '../../features/users/entities/user.entity'; 7 | import { UsersService } from '../../features/users/users.service'; 8 | 9 | @Injectable() 10 | export class TwoFactorAuthenticationService { 11 | constructor( 12 | private readonly usersService: UsersService, 13 | private readonly configService: ConfigService, 14 | ) {} 15 | 16 | public async generateTwoFactorAuthenticationSecret(user: User) { 17 | const secret = authenticator.generateSecret(); 18 | 19 | const otpauthUrl = authenticator.keyuri( 20 | user.email, 21 | this.configService.get('app.fileDestination'), 22 | secret, 23 | ); 24 | 25 | await this.usersService.setTwoFactorAuthenticationSecret(secret, user.id); 26 | 27 | return { 28 | secret, 29 | otpauthUrl, 30 | }; 31 | } 32 | 33 | public isTwoFactorAuthenticationCodeValid( 34 | twoFactorAuthenticationCode: string, 35 | user: User, 36 | ) { 37 | return authenticator.verify({ 38 | token: twoFactorAuthenticationCode, 39 | secret: user.twoFactorAuthenticationSecret, 40 | }); 41 | } 42 | 43 | public async pipeQrCodeStream(stream: Response, otpauthUrl: string) { 44 | return toFileStream(stream, otpauthUrl); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/authorization/authorization.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | Param, 6 | ParseIntPipe, 7 | Patch, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { ApiTags } from '@nestjs/swagger'; 11 | import { AuthorizationService } from './authorization.service'; 12 | import { UpdatePermissionsDto } from './dto/update-permissions.dto'; 13 | import { UpdateRoleDto } from './dto/update-role.dto'; 14 | import { Role } from './role.enum'; 15 | import { RoleGuard } from './role.guard'; 16 | 17 | @Controller('authorization') 18 | @ApiTags('authorization') 19 | @UseGuards(RoleGuard(Role.SuperAdmin)) 20 | export class AuthorizationController { 21 | constructor(private readonly authorizationService: AuthorizationService) {} 22 | 23 | @Patch(':userId/role') 24 | @HttpCode(204) 25 | updateRole( 26 | @Param('userId', ParseIntPipe) userId: number, 27 | @Body() updateRoleDto: UpdateRoleDto, 28 | ) { 29 | return this.authorizationService.updateRole(userId, updateRoleDto); 30 | } 31 | 32 | @Patch(':userId/permissions') 33 | @HttpCode(204) 34 | updatePermission( 35 | @Param('userId', ParseIntPipe) userId: number, 36 | @Body() updatePermissionsDto: UpdatePermissionsDto, 37 | ) { 38 | return this.authorizationService.updatePermission( 39 | userId, 40 | updatePermissionsDto, 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/authorization/authorization.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { User } from 'src/features/users/entities/user.entity'; 4 | import { AuthorizationController } from './authorization.controller'; 5 | import { AuthorizationService } from './authorization.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([User])], 9 | controllers: [AuthorizationController], 10 | providers: [AuthorizationService], 11 | }) 12 | export class AuthorizationModule {} 13 | -------------------------------------------------------------------------------- /src/authorization/authorization.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from '../features/users/entities/user.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { UpdateRoleDto } from './dto/update-role.dto'; 6 | import { UpdatePermissionsDto } from './dto/update-permissions.dto'; 7 | 8 | @Injectable() 9 | export class AuthorizationService { 10 | constructor( 11 | @InjectRepository(User) 12 | private usersRepository: Repository, 13 | ) {} 14 | 15 | async updateRole(id: number, updateRoleDto: UpdateRoleDto) { 16 | await this.usersRepository.update(id, updateRoleDto); 17 | } 18 | 19 | async updatePermission( 20 | id: number, 21 | updatePermissionsDto: UpdatePermissionsDto, 22 | ) { 23 | await this.usersRepository.update(id, updatePermissionsDto); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/authorization/dto/update-permissions.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEnum, IsNotEmpty } from 'class-validator'; 3 | import { Permission } from '../types/permission.type'; 4 | 5 | export class UpdatePermissionsDto { 6 | @ApiProperty({ 7 | isArray: true, 8 | enum: Permission, 9 | }) 10 | @IsEnum(Permission, { each: true }) 11 | @IsNotEmpty() 12 | permissions: Permission[]; 13 | } 14 | -------------------------------------------------------------------------------- /src/authorization/dto/update-role.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsNotEmpty } from 'class-validator'; 2 | import { Role } from '../role.enum'; 3 | 4 | export class UpdateRoleDto { 5 | @IsEnum(Role) 6 | @IsNotEmpty() 7 | role: Role; 8 | } 9 | -------------------------------------------------------------------------------- /src/authorization/permission.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, mixin, Type } from '@nestjs/common'; 2 | import { RequestWithUser } from '../authentication/request-with-user.interface'; 3 | import { JwtAuthenticationGuard } from '../authentication/jwt-authentication.guard'; 4 | import { Permission } from './types/permission.type'; 5 | 6 | export const PermissionGuard = (permission: Permission): Type => { 7 | class PermissionGuardMixin extends JwtAuthenticationGuard { 8 | async canActivate(context: ExecutionContext) { 9 | await super.canActivate(context); 10 | 11 | const request = context.switchToHttp().getRequest(); 12 | const user = request.user; 13 | 14 | return user?.permissions.includes(permission); 15 | } 16 | } 17 | 18 | return mixin(PermissionGuardMixin); 19 | }; 20 | -------------------------------------------------------------------------------- /src/authorization/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | SuperAdmin = 'SuperAdmin', 3 | User = 'User', 4 | Admin = 'Admin', 5 | } 6 | -------------------------------------------------------------------------------- /src/authorization/role.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, mixin, Type } from '@nestjs/common'; 2 | import { JwtAuthenticationGuard } from '../authentication/jwt-authentication.guard'; 3 | import { RequestWithUser } from '../authentication/request-with-user.interface'; 4 | import { Role } from './role.enum'; 5 | 6 | export const RoleGuard = (role: Role): Type => { 7 | class RoleGuardMixin extends JwtAuthenticationGuard { 8 | async canActivate(context: ExecutionContext) { 9 | await super.canActivate(context); 10 | 11 | const request = context.switchToHttp().getRequest(); 12 | const user = request.user; 13 | 14 | return user?.role === role; 15 | } 16 | } 17 | 18 | return mixin(RoleGuardMixin); 19 | }; 20 | -------------------------------------------------------------------------------- /src/authorization/types/permission.type.ts: -------------------------------------------------------------------------------- 1 | import { CategoriesPermission } from '../../features/categories/enums/categoriesPermission.enum'; 2 | import { ProductsPermission } from '../../features/products/enums/productsPermission.enum'; 3 | 4 | export const Permission = { 5 | ...ProductsPermission, 6 | ...CategoriesPermission, 7 | }; 8 | 9 | export type Permission = ProductsPermission | CategoriesPermission; 10 | -------------------------------------------------------------------------------- /src/config/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; 2 | import { ThrottlerAsyncOptions } from '@nestjs/throttler'; 3 | 4 | export const throttleModuleAsyncOptions: ThrottlerAsyncOptions = { 5 | imports: [ConfigModule], 6 | inject: [ConfigService], 7 | useFactory: (configService: ConfigService) => ({ 8 | ttl: configService.get('app.throttle.ttl'), 9 | limit: configService.get('app.throttle.limit'), 10 | }), 11 | }; 12 | 13 | export default registerAs('app', () => ({ 14 | // API PORT 15 | port: parseInt(process.env.PORT, 10) || 3000, 16 | 17 | // API URL 18 | url: process.env.APP_URL || 'localhost', 19 | 20 | // API Environment: local | production | staging 21 | env: process.env.APP_ENV || 'local', 22 | 23 | // API debug mode is enable or not: true | false 24 | debugMode: process.env.APP_DEBUG === 'false' ? false : true, 25 | 26 | // API support email address 27 | supportEmail: process.env.SUPPORT_EMAIL || 'support@localhost', 28 | 29 | // throttle configurations 30 | throttle: { 31 | ttl: process.env.THROTTLE_TTL || 60000, 32 | limit: process.env.THROTTLE_LIMIT || 10000, 33 | }, 34 | 35 | // Server file upload destination 36 | fileDestination: process.env.UPLOADED_FILES_DESTINATION || './uploadedFiles', 37 | 38 | // two factor authentication app name 39 | twoFactorAuthAppName: 40 | process.env.TWO_FACTOR_AUTHENTICATION_APP_NAME || 'Nest_Starter_Template', 41 | 42 | // sites that are CORS enabled 43 | frontendURL: process.env.FRONTEND_URL || 'localhost', 44 | })); 45 | -------------------------------------------------------------------------------- /src/config/aws.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('aws', () => ({ 4 | region: process.env.AWS_REGION, 5 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 6 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 7 | publicBucketName: process.env.AWS_PUBLIC_BUCKET_NAME, 8 | })); 9 | -------------------------------------------------------------------------------- /src/config/bull.config.ts: -------------------------------------------------------------------------------- 1 | import { SharedBullAsyncConfiguration } from '@nestjs/bull'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | 4 | export const bullModuleOptions: SharedBullAsyncConfiguration = { 5 | imports: [ConfigModule], 6 | inject: [ConfigService], 7 | useFactory: (configService: ConfigService) => ({ 8 | redis: { 9 | host: configService.get('redis.host'), 10 | port: configService.get('redis.port'), 11 | }, 12 | }), 13 | }; 14 | -------------------------------------------------------------------------------- /src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('database', () => ({ 4 | host: process.env.DATABASE_HOST || 'localhost', 5 | port: parseInt(process.env.DATABASE_PORT, 10) || 5432, 6 | username: process.env.DATABASE_USER || 'root', 7 | password: process.env.DATABASE_PASSWORD || '', 8 | name: process.env.DATABASE_NAME, 9 | })); 10 | -------------------------------------------------------------------------------- /src/config/elastic-search.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; 2 | import { ElasticsearchModuleAsyncOptions } from '@nestjs/elasticsearch'; 3 | 4 | export const elasticSearchModuleOptions: ElasticsearchModuleAsyncOptions = { 5 | imports: [ConfigModule], 6 | inject: [ConfigService], 7 | useFactory: (configService: ConfigService) => ({ 8 | node: configService.get('elasticSearch.node'), 9 | auth: { 10 | username: configService.get('elasticSearch.auth.username'), 11 | password: configService.get('elasticSearch.auth.password'), 12 | }, 13 | }), 14 | }; 15 | 16 | export default registerAs('elasticSearch', () => ({ 17 | node: process.env.ELASTICSEARCH_NODE || 'http://localhost:9200', 18 | auth: { 19 | username: process.env.ELASTICSEARCH_USERNAME || 'elastic', 20 | password: process.env.ELASTICSEARCH_PASSWORD || 'admin', 21 | }, 22 | })); 23 | -------------------------------------------------------------------------------- /src/config/email.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('email', () => ({ 4 | host: process.env.EMAIL_HOST || 'smtp.mailtrap.io', 5 | port: process.env.EMAIL_PORT || 2525, 6 | secure: process.env.EMAIL_IS_SECURE === 'true' ? true : false, 7 | user: process.env.EMAIL_USER || 'user', 8 | password: process.env.EMAIL_PASSWORD || 'password', 9 | from: process.env.EMAIL_FROM || '', 10 | confirmationLink: process.env.EMAIL_CONFIRMATION_URL || process.env.APP_URL, 11 | })); 12 | -------------------------------------------------------------------------------- /src/config/google.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('google', () => ({ 4 | auth: { 5 | clientId: process.env.GOOGLE_AUTH_CLIENT_ID, 6 | clientSecret: process.env.GOOGLE_AUTH_CLIENT_SECRET, 7 | }, 8 | })); 9 | -------------------------------------------------------------------------------- /src/config/jwt.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('jwt', () => ({ 4 | accessTokenSecret: process.env.JWT_ACCESS_TOKEN_SECRET, 5 | accessTokenExpirationTime: process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME, 6 | refreshTokenSecret: process.env.JWT_REFRESH_TOKEN_SECRET, 7 | refreshTokenExpirationTime: process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME, 8 | verificationTokenSecret: process.env.JWT_VERIFICATION_TOKEN_SECRET, 9 | verificationTokenExpirationTime: 10 | process.env.JWT_VERIFICATION_TOKEN_EXPIRATION_TIME, 11 | })); 12 | -------------------------------------------------------------------------------- /src/config/redis.config.ts: -------------------------------------------------------------------------------- 1 | // import { CacheModuleAsyncOptions } from '@nestjs/common'; 2 | import { registerAs } from '@nestjs/config'; 3 | // import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; 4 | // import * as redisStore from 'cache-manager-redis-store'; 5 | 6 | //! TODO: breaking changes https://github.com/dabroek/node-cache-manager-redis-store/releases/tag/v3.0.0 7 | // export const cacheModuleOptions: CacheModuleAsyncOptions = { 8 | // imports: [ConfigModule], 9 | // inject: [ConfigService], 10 | // useFactory: (configService: ConfigService) => ({ 11 | // store: redisStore, 12 | // host: configService.get('redis.host'), 13 | // port: configService.get('redis.port'), 14 | // ttl: 120, 15 | // }), 16 | // }; 17 | 18 | export default registerAs('redis', () => ({ 19 | host: process.env.REDIS_HOST || 'localhost', 20 | port: process.env.REDIS_PORT || 6379, 21 | })); 22 | -------------------------------------------------------------------------------- /src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; 2 | import { 3 | TypeOrmModuleAsyncOptions, 4 | TypeOrmModuleOptions, 5 | } from '@nestjs/typeorm'; 6 | import { DatabaseLogger } from '../database/database-logger'; 7 | 8 | class TypeOrmConfig { 9 | static getOrmConfig(configService: ConfigService): TypeOrmModuleOptions { 10 | const options: TypeOrmModuleOptions = { 11 | type: 'postgres', 12 | host: configService.get('database.host'), 13 | port: configService.get('database.port'), 14 | username: configService.get('database.username'), 15 | password: configService.get('database.password'), 16 | database: configService.get('database.name'), 17 | entities: ['dist/**/*.entity{.ts,.js}'], 18 | synchronize: configService.get('typeorm.synchronize'), 19 | }; 20 | 21 | const isLogging = configService.get('typeorm.logging') || false; 22 | if (!isLogging) { 23 | return options; 24 | } 25 | 26 | return { 27 | ...options, 28 | logger: new DatabaseLogger(), 29 | }; 30 | } 31 | } 32 | 33 | export const typeormModuleOptions: TypeOrmModuleAsyncOptions = { 34 | imports: [ConfigModule], 35 | inject: [ConfigService], 36 | useFactory: async ( 37 | configService: ConfigService, 38 | ): Promise => TypeOrmConfig.getOrmConfig(configService), 39 | }; 40 | 41 | export default registerAs('typeorm', () => ({ 42 | synchronize: process.env.TYPEORM_SYNCHRONIZE === 'false' ? false : true, 43 | logging: process.env.TYPEORM_LOGGING === 'true' ? true : false, 44 | })); 45 | -------------------------------------------------------------------------------- /src/database/database-logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger as TypeOrmLogger, QueryRunner } from 'typeorm'; 2 | import { Logger as NestLogger } from '@nestjs/common'; 3 | 4 | export class DatabaseLogger implements TypeOrmLogger { 5 | private readonly logger = new NestLogger('SQL'); 6 | 7 | logQuery(query: string, parameters?: unknown[], queryRunner?: QueryRunner) { 8 | if (queryRunner?.data?.isCreatingLogs) { 9 | return; 10 | } 11 | this.logger.log( 12 | `${query} -- Parameters: ${this.stringifyParameters(parameters)}`, 13 | ); 14 | } 15 | 16 | logQueryError( 17 | error: string, 18 | query: string, 19 | parameters?: unknown[], 20 | queryRunner?: QueryRunner, 21 | ) { 22 | if (queryRunner?.data?.isCreatingLogs) { 23 | return; 24 | } 25 | this.logger.error( 26 | `${query} -- Parameters: ${this.stringifyParameters( 27 | parameters, 28 | )} -- ${error}`, 29 | ); 30 | } 31 | 32 | logQuerySlow( 33 | time: number, 34 | query: string, 35 | parameters?: unknown[], 36 | queryRunner?: QueryRunner, 37 | ) { 38 | if (queryRunner?.data?.isCreatingLogs) { 39 | return; 40 | } 41 | this.logger.warn( 42 | `Time: ${time} -- Parameters: ${this.stringifyParameters( 43 | parameters, 44 | )} -- ${query}`, 45 | ); 46 | } 47 | 48 | logMigration(message: string) { 49 | this.logger.log(message); 50 | } 51 | 52 | logSchemaBuild(message: string) { 53 | this.logger.log(message); 54 | } 55 | 56 | log( 57 | level: 'log' | 'info' | 'warn', 58 | message: string, 59 | queryRunner?: QueryRunner, 60 | ) { 61 | if (queryRunner?.data?.isCreatingLogs) { 62 | return; 63 | } 64 | if (level === 'log') { 65 | return this.logger.log(message); 66 | } 67 | if (level === 'info') { 68 | return this.logger.debug(message); 69 | } 70 | if (level === 'warn') { 71 | return this.logger.warn(message); 72 | } 73 | } 74 | 75 | private stringifyParameters(parameters?: unknown[]) { 76 | try { 77 | return JSON.stringify(parameters); 78 | } catch { 79 | return ''; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { typeormModuleOptions } from '../config/typeorm.config'; 4 | 5 | @Module({ 6 | imports: [TypeOrmModule.forRootAsync(typeormModuleOptions)], 7 | }) 8 | export class DatabaseModule {} 9 | -------------------------------------------------------------------------------- /src/database/postgres-error-codes.enum.ts: -------------------------------------------------------------------------------- 1 | export enum PostgresErrorCode { 2 | UniqueViolation = '23505', 3 | } 4 | -------------------------------------------------------------------------------- /src/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { EmailService } from './email.service'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [EmailService], 8 | exports: [EmailService], 9 | }) 10 | export class EmailModule {} 11 | -------------------------------------------------------------------------------- /src/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { createTransport } from 'nodemailer'; 4 | import * as Mail from 'nodemailer/lib/mailer'; 5 | 6 | @Injectable() 7 | export class EmailService { 8 | private nodemailerTransport: Mail; 9 | 10 | constructor(private readonly configService: ConfigService) { 11 | this.nodemailerTransport = createTransport({ 12 | host: configService.get('email.host'), 13 | port: configService.get('email.port'), 14 | secure: configService.get('email.secure'), 15 | auth: { 16 | user: configService.get('email.user'), 17 | pass: configService.get('email.password'), 18 | }, 19 | from: configService.get('email.from'), 20 | }); 21 | } 22 | 23 | sendMail(options: Mail.Options) { 24 | return this.nodemailerTransport.sendMail(options, (err) => { 25 | if (err) { 26 | throw new InternalServerErrorException( 27 | `Internal Mailer Failed Error - ${err.message}`, 28 | ); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/env.validation.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { 3 | IsBoolean, 4 | IsEnum, 5 | IsNotEmpty, 6 | IsNumber, 7 | IsOptional, 8 | IsString, 9 | validateSync, 10 | } from 'class-validator'; 11 | 12 | enum Environment { 13 | Local = 'local', 14 | Production = 'production', 15 | Staging = 'staging', 16 | Test = 'test', 17 | } 18 | 19 | class EnvironmentVariables { 20 | @IsEnum(Environment) 21 | @IsNotEmpty() 22 | APP_ENV: Environment; 23 | 24 | @IsBoolean() 25 | @IsNotEmpty() 26 | APP_DEBUG: boolean; 27 | 28 | @IsString() 29 | @IsNotEmpty() 30 | APP_URL: string; 31 | 32 | @IsNumber() 33 | @IsNotEmpty() 34 | PORT: number; 35 | 36 | @IsString() 37 | @IsNotEmpty() 38 | JWT_ACCESS_TOKEN_SECRET: string; 39 | 40 | @IsNumber() 41 | @IsNotEmpty() 42 | JWT_ACCESS_TOKEN_EXPIRATION_TIME: number; 43 | 44 | @IsString() 45 | @IsNotEmpty() 46 | JWT_REFRESH_TOKEN_SECRET: string; 47 | 48 | @IsNumber() 49 | @IsNotEmpty() 50 | JWT_REFRESH_TOKEN_EXPIRATION_TIME: number; 51 | 52 | @IsString() 53 | @IsNotEmpty() 54 | JWT_VERIFICATION_TOKEN_SECRET: string; 55 | 56 | @IsNumber() 57 | @IsNotEmpty() 58 | JWT_VERIFICATION_TOKEN_EXPIRATION_TIME: number; 59 | 60 | @IsString() 61 | @IsNotEmpty() 62 | EMAIL_CONFIRMATION_URL: string; 63 | 64 | @IsString() 65 | @IsNotEmpty() 66 | DATABASE_HOST: string; 67 | 68 | @IsNumber() 69 | @IsNotEmpty() 70 | DATABASE_PORT: number; 71 | 72 | @IsString() 73 | @IsNotEmpty() 74 | DATABASE_USER: string; 75 | 76 | @IsString() 77 | @IsNotEmpty() 78 | DATABASE_PASSWORD: string; 79 | 80 | @IsString() 81 | @IsNotEmpty() 82 | DATABASE_NAME: string; 83 | 84 | @IsBoolean() 85 | @IsOptional() 86 | TYPEORM_SYNCHRONIZE: boolean; 87 | 88 | @IsBoolean() 89 | @IsOptional() 90 | TYPEORM_LOGGING: boolean; 91 | 92 | @IsString() 93 | @IsNotEmpty() 94 | EMAIL_HOST: string; 95 | 96 | @IsNumber() 97 | @IsNotEmpty() 98 | EMAIL_PORT: number; 99 | 100 | @IsBoolean() 101 | @IsOptional() 102 | EMAIL_IS_SECURE: boolean; 103 | 104 | @IsString() 105 | @IsNotEmpty() 106 | EMAIL_USER: string; 107 | 108 | @IsString() 109 | @IsNotEmpty() 110 | EMAIL_PASSWORD: string; 111 | } 112 | 113 | export function validate(config: Record) { 114 | const validatedConfig = plainToClass(EnvironmentVariables, config, { 115 | enableImplicitConversion: true, 116 | }); 117 | const errors = validateSync(validatedConfig, { 118 | skipMissingProperties: false, 119 | }); 120 | 121 | if (errors.length > 0) { 122 | throw new Error(errors.toString()); 123 | } 124 | return validatedConfig; 125 | } 126 | -------------------------------------------------------------------------------- /src/features/categories/categories.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Patch, 7 | Param, 8 | Delete, 9 | UseInterceptors, 10 | UseGuards, 11 | CacheInterceptor, 12 | } from '@nestjs/common'; 13 | import { ApiExtraModels, ApiTags } from '@nestjs/swagger'; 14 | import { PermissionGuard } from '../../authorization/permission.guard'; 15 | import { Permission } from '../../authorization/types/permission.type'; 16 | import { Role } from '../../authorization/role.enum'; 17 | import { RoleGuard } from '../../authorization/role.guard'; 18 | import { FindOneParams } from '../../utils/dto/find-one-params.dto'; 19 | import { CategoriesService } from './categories.service'; 20 | import { CreateCategoryDto } from './dto/create-category.dto'; 21 | import { UpdateCategoryDto } from './dto/update-category.dto'; 22 | 23 | @Controller('categories') 24 | @ApiTags('categories') 25 | @UseInterceptors(CacheInterceptor) 26 | @ApiExtraModels(FindOneParams) 27 | export class CategoriesController { 28 | constructor(private readonly categoriesService: CategoriesService) {} 29 | 30 | @Post() 31 | @UseGuards(PermissionGuard(Permission.CreateCategory)) 32 | @UseGuards(RoleGuard(Role.Admin)) 33 | create(@Body() category: CreateCategoryDto) { 34 | return this.categoriesService.create(category); 35 | } 36 | 37 | @Get() 38 | findAll() { 39 | return this.categoriesService.findAll(); 40 | } 41 | 42 | @Get('/with-deleted') 43 | findAllWithDeleted() { 44 | return this.categoriesService.findAllWithDeleted(); 45 | } 46 | 47 | @Get(':id') 48 | findOne(@Param() { id }: FindOneParams) { 49 | return this.categoriesService.findOne(+id); 50 | } 51 | 52 | @Patch(':id') 53 | @UseGuards(PermissionGuard(Permission.UpdateCategory)) 54 | @UseGuards(RoleGuard(Role.Admin)) 55 | update(@Param() { id }: FindOneParams, @Body() category: UpdateCategoryDto) { 56 | return this.categoriesService.update(+id, category); 57 | } 58 | 59 | @Delete(':id') 60 | @UseGuards(PermissionGuard(Permission.DeleteCategory)) 61 | @UseGuards(RoleGuard(Role.Admin)) 62 | remove(@Param() { id }: FindOneParams) { 63 | return this.categoriesService.remove(+id); 64 | } 65 | 66 | @Post(':id/restore') 67 | @UseGuards(PermissionGuard(Permission.RestoreCategory)) 68 | @UseGuards(RoleGuard(Role.Admin)) 69 | restoreDeleted(@Param() { id }: FindOneParams) { 70 | return this.categoriesService.restoreDeleted(+id); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/features/categories/categories.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule, Module } from '@nestjs/common'; 2 | import { CategoriesService } from './categories.service'; 3 | import { CategoriesController } from './categories.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Category } from './entities/category.entity'; 6 | 7 | @Module({ 8 | imports: [ 9 | TypeOrmModule.forFeature([Category]), 10 | CacheModule.register({ 11 | ttl: 120000, 12 | max: 100, 13 | }), 14 | ], 15 | controllers: [CategoriesController], 16 | providers: [CategoriesService], 17 | }) 18 | export class CategoriesModule {} 19 | -------------------------------------------------------------------------------- /src/features/categories/categories.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateCategoryDto } from './dto/create-category.dto'; 5 | import { UpdateCategoryDto } from './dto/update-category.dto'; 6 | import { Category } from './entities/category.entity'; 7 | import { CategoryNotFoundException } from './exceptions/category-not-found.exception'; 8 | 9 | @Injectable() 10 | export class CategoriesService { 11 | constructor( 12 | @InjectRepository(Category) 13 | private categoryRepository: Repository, 14 | ) {} 15 | 16 | async create(createCategoryDto: CreateCategoryDto) { 17 | const category = await this.categoryRepository.create(createCategoryDto); 18 | await this.categoryRepository.save(category); 19 | return category; 20 | } 21 | 22 | findAll() { 23 | return this.categoryRepository.find({ 24 | relations: ['posts'], 25 | }); 26 | } 27 | 28 | findAllWithDeleted() { 29 | return this.categoryRepository.find({ 30 | relations: ['posts'], 31 | withDeleted: true, 32 | }); 33 | } 34 | 35 | async findOne(id: number) { 36 | const category = await this.categoryRepository.findOne({ 37 | where: { 38 | id, 39 | }, 40 | relations: ['posts'], 41 | }); 42 | if (category) { 43 | return category; 44 | } 45 | throw new CategoryNotFoundException(id); 46 | } 47 | 48 | async update(id: number, category: UpdateCategoryDto) { 49 | await this.categoryRepository.update(id, category); 50 | const updatedCategory = await this.categoryRepository.findOne({ 51 | where: { 52 | id, 53 | }, 54 | relations: ['posts'], 55 | }); 56 | if (updatedCategory) { 57 | return updatedCategory; 58 | } 59 | throw new CategoryNotFoundException(id); 60 | } 61 | 62 | async remove(id: number) { 63 | const deleteResponse = await this.categoryRepository.softDelete(id); 64 | if (!deleteResponse.affected) { 65 | throw new CategoryNotFoundException(id); 66 | } 67 | } 68 | 69 | async restoreDeleted(id: number) { 70 | const restoreResponse = await this.categoryRepository.restore(id); 71 | if (!restoreResponse.affected) { 72 | throw new CategoryNotFoundException(id); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/features/categories/dto/create-category.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class CreateCategoryDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/categories/dto/update-category.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class UpdateCategoryDto { 4 | @IsNumber() 5 | @IsOptional() 6 | id: number; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | @IsOptional() 11 | name: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/features/categories/entities/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '../../posts/entities/post.entity'; 2 | import { 3 | Column, 4 | DeleteDateColumn, 5 | Entity, 6 | ManyToMany, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | 10 | @Entity() 11 | export class Category { 12 | @PrimaryGeneratedColumn() 13 | public id: number; 14 | 15 | @Column() 16 | public name: string; 17 | 18 | @ManyToMany(() => Post, (post: Post) => post.categories) 19 | public posts: Post[]; 20 | 21 | @DeleteDateColumn() 22 | public deletedAt: Date; 23 | } 24 | -------------------------------------------------------------------------------- /src/features/categories/enums/categoriesPermission.enum.ts: -------------------------------------------------------------------------------- 1 | export enum CategoriesPermission { 2 | CreateCategory = 'CreateCategory', 3 | UpdateCategory = 'UpdateCategory', 4 | DeleteCategory = 'DeleteCategory', 5 | RestoreCategory = 'RestoreCategory', 6 | } 7 | -------------------------------------------------------------------------------- /src/features/categories/exceptions/category-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class CategoryNotFoundException extends NotFoundException { 4 | constructor(categoryId: number) { 5 | super(`Category with id ${categoryId} not found`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/features/comments/commands/handlers/create-comment.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateCommentCommand } from '../implementations/create-comment.command'; 5 | import { Comment } from '../../entities/comment.entity'; 6 | 7 | @CommandHandler(CreateCommentCommand) 8 | export class CreateCommentHandler 9 | implements ICommandHandler 10 | { 11 | constructor( 12 | @InjectRepository(Comment) private commentsRepository: Repository, 13 | ) {} 14 | 15 | async execute(command: CreateCommentCommand): Promise { 16 | const { comment, author } = command; 17 | const newComment = await this.commentsRepository.create({ 18 | ...comment, 19 | author, 20 | }); 21 | await this.commentsRepository.save(newComment); 22 | return newComment; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/features/comments/commands/implementations/create-comment.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../../users/entities/user.entity'; 2 | import { CreateCommentDto } from '../../dto/create-comment.dto'; 3 | 4 | export class CreateCommentCommand { 5 | constructor( 6 | public readonly comment: CreateCommentDto, 7 | public readonly author: User, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/features/comments/comments.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Post, 6 | Query, 7 | Req, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { RequestWithUser } from '../../authentication/request-with-user.interface'; 11 | import { JwtAuthenticationGuard } from '../../authentication/jwt-authentication.guard'; 12 | import { CommentsService } from './comments.service'; 13 | import { CreateCommentDto } from './dto/create-comment.dto'; 14 | import { GetCommentsDto } from './dto/get-comments.dto'; 15 | import { PaginatedResultDto } from '../../utils/dto/paginated-result.dto'; 16 | import { Comment } from './entities/comment.entity'; 17 | import { ApiExtraModels, ApiTags } from '@nestjs/swagger'; 18 | 19 | @Controller('comments') 20 | @ApiTags('comments') 21 | @ApiExtraModels(PaginatedResultDto) 22 | @UseGuards(JwtAuthenticationGuard) 23 | export class CommentsController { 24 | constructor(private readonly commentsService: CommentsService) {} 25 | 26 | @Get() 27 | async getComments( 28 | @Query() { postId, page = 1, limit = 20 }: GetCommentsDto, 29 | ): Promise> { 30 | return this.commentsService.getComments(postId, { page, limit }); 31 | } 32 | 33 | @Post() 34 | async createComment( 35 | @Body() comment: CreateCommentDto, 36 | @Req() request: RequestWithUser, 37 | ) { 38 | const user = request.user; 39 | return this.commentsService.createComment(comment, user); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/features/comments/comments.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CqrsModule } from '@nestjs/cqrs'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { CreateCommentHandler } from './commands/handlers/create-comment.handler'; 5 | import { CommentsController } from './comments.controller'; 6 | import { CommentsService } from './comments.service'; 7 | import { Comment } from './entities/comment.entity'; 8 | import { GetCommentsHandler } from './queries/handlers/get-comments.handler'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Comment]), CqrsModule], 12 | controllers: [CommentsController], 13 | providers: [CommentsService, CreateCommentHandler, GetCommentsHandler], 14 | }) 15 | export class CommentsModule {} 16 | -------------------------------------------------------------------------------- /src/features/comments/comments.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CommandBus, QueryBus } from '@nestjs/cqrs'; 3 | import { PaginatedResultDto } from '../../utils/dto/paginated-result.dto'; 4 | import { PaginationDto } from '../../utils/dto/pagination.dto'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { CreateCommentCommand } from './commands/implementations/create-comment.command'; 7 | import { CreateCommentDto } from './dto/create-comment.dto'; 8 | import { GetCommentsQuery } from './queries/implementations/get-comments.query'; 9 | import { Comment } from './entities/comment.entity'; 10 | 11 | @Injectable() 12 | export class CommentsService { 13 | constructor(private commandBus: CommandBus, private queryBus: QueryBus) {} 14 | 15 | async getComments( 16 | postId: number, 17 | paginationDto: PaginationDto, 18 | ): Promise> { 19 | return this.queryBus.execute(new GetCommentsQuery(postId, paginationDto)); 20 | } 21 | 22 | async createComment(comment: CreateCommentDto, author: User) { 23 | return this.commandBus.execute(new CreateCommentCommand(comment, author)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/features/comments/dto/create-comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsString, IsNotEmpty, ValidateNested } from 'class-validator'; 3 | import { ObjectWithIdDto } from '../../../utils/dto/object-with-id.dto'; 4 | export class CreateCommentDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | content: string; 8 | 9 | @ValidateNested() 10 | @Type(() => ObjectWithIdDto) 11 | post: ObjectWithIdDto; 12 | } 13 | -------------------------------------------------------------------------------- /src/features/comments/dto/get-comments.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsOptional } from 'class-validator'; 3 | import { PaginationDto } from '../../../utils/dto/pagination.dto'; 4 | 5 | export class GetCommentsDto extends PaginationDto { 6 | @Type(() => Number) 7 | @IsOptional() 8 | postId?: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/features/comments/entities/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Post } from '../../posts/entities/post.entity'; 3 | import { User } from '../../users/entities/user.entity'; 4 | 5 | @Entity() 6 | export class Comment { 7 | @PrimaryGeneratedColumn() 8 | public id: number; 9 | 10 | @Column() 11 | public content: string; 12 | 13 | @ManyToOne(() => Post, (post: Post) => post.comments) 14 | public post: Post; 15 | 16 | @ManyToOne(() => User, (author: User) => author.posts) 17 | public author: User; 18 | } 19 | -------------------------------------------------------------------------------- /src/features/comments/queries/handlers/get-comments.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { GetCommentsQuery } from '../implementations/get-comments.query'; 4 | import { Comment } from '../../entities/comment.entity'; 5 | import { FindManyOptions, Repository } from 'typeorm'; 6 | import { PaginatedResultDto } from '../../../../utils/dto/paginated-result.dto'; 7 | import { getPaginationProps } from '../../../../utils/get-pagination-props'; 8 | 9 | @QueryHandler(GetCommentsQuery) 10 | export class GetCommentsHandler implements IQueryHandler { 11 | constructor( 12 | @InjectRepository(Comment) private commentsRepository: Repository, 13 | ) {} 14 | 15 | async execute({ 16 | postId, 17 | paginationDto, 18 | }: GetCommentsQuery): Promise> { 19 | const { page, limit, skippedItems } = getPaginationProps(paginationDto); 20 | 21 | const where: FindManyOptions['where'] = {}; 22 | if (postId) { 23 | where.post = { 24 | id: postId, 25 | }; 26 | } 27 | 28 | const [comments, commentsCount] = 29 | await this.commentsRepository.findAndCount({ 30 | where, 31 | relations: ['author'], 32 | skip: skippedItems, 33 | take: limit, 34 | }); 35 | 36 | return { 37 | totalCount: commentsCount, 38 | page: page, 39 | limit: limit, 40 | data: comments, 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/features/comments/queries/implementations/get-comments.query.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto } from '../../../../utils/dto/pagination.dto'; 2 | 3 | export class GetCommentsQuery { 4 | constructor( 5 | public readonly postId: number, 6 | public readonly paginationDto: PaginationDto, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/features/database-files/database-files.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | ParseIntPipe, 6 | Res, 7 | StreamableFile, 8 | } from '@nestjs/common'; 9 | import { DatabaseFilesService } from './database-files.service'; 10 | import { Readable } from 'stream'; 11 | import { Response } from 'express'; 12 | 13 | @Controller('database-files') 14 | export class DatabaseFilesController { 15 | constructor(private readonly databaseFilesService: DatabaseFilesService) {} 16 | 17 | @Get(':id') 18 | async getDatabaseFileById( 19 | @Param('id', ParseIntPipe) id: number, 20 | @Res({ passthrough: true }) response: Response, 21 | ) { 22 | const file = await this.databaseFilesService.getFileById(id); 23 | 24 | const stream = Readable.from(file.data); 25 | 26 | response.set({ 27 | 'Content-Disposition': `inline; filename="${file.filename}"`, 28 | 'Content-Type': 'image', 29 | }); 30 | 31 | return new StreamableFile(stream); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/features/database-files/database-files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { DatabaseFilesService } from './database-files.service'; 5 | import { DatabaseFile } from './entities/database-file.entity'; 6 | import { DatabaseFilesController } from './database-files.controller'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([DatabaseFile]), ConfigModule], 10 | providers: [DatabaseFilesService], 11 | exports: [DatabaseFilesService], 12 | controllers: [DatabaseFilesController], 13 | }) 14 | export class DatabaseFilesModule {} 15 | -------------------------------------------------------------------------------- /src/features/database-files/database-files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { QueryRunner, Repository } from 'typeorm'; 4 | import { DatabaseFile } from './entities/database-file.entity'; 5 | 6 | @Injectable() 7 | export class DatabaseFilesService { 8 | constructor( 9 | @InjectRepository(DatabaseFile) 10 | private databaseFilesRepository: Repository, 11 | ) {} 12 | 13 | async uploadDatabaseFileWithQueryRunner( 14 | dataBuffer: Buffer, 15 | filename: string, 16 | queryRunner: QueryRunner, 17 | ) { 18 | const newFile = await queryRunner.manager.create(DatabaseFile, { 19 | filename, 20 | data: dataBuffer, 21 | }); 22 | await queryRunner.manager.save(DatabaseFile, newFile); 23 | return newFile; 24 | } 25 | 26 | async deleteFileWithQueryRunner(fileId: number, queryRunner: QueryRunner) { 27 | const deleteResponse = await queryRunner.manager.delete( 28 | DatabaseFile, 29 | fileId, 30 | ); 31 | if (!deleteResponse.affected) { 32 | throw new NotFoundException(); 33 | } 34 | } 35 | 36 | async getFileById(fileId: number) { 37 | const file = await this.databaseFilesRepository.findOne({ 38 | where: { 39 | id: fileId, 40 | }, 41 | }); 42 | if (!file) { 43 | throw new NotFoundException(); 44 | } 45 | return file; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/features/database-files/entities/database-file.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class DatabaseFile { 5 | @PrimaryGeneratedColumn() 6 | public id: number; 7 | 8 | @Column() 9 | public filename: string; 10 | 11 | @Column({ 12 | type: 'bytea', 13 | }) 14 | public data: Uint8Array; 15 | } 16 | -------------------------------------------------------------------------------- /src/features/email-confirmation/dto/confirm-email.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class ConfirmEmailDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | token: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/email-confirmation/email-confirmation.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { JwtAuthenticationGuard } from '../../authentication/jwt-authentication.guard'; 4 | import { RequestWithUser } from '../../authentication/request-with-user.interface'; 5 | import { ConfirmEmailDto } from './dto/confirm-email.dto'; 6 | import { EmailConfirmationService } from './email-confirmation.service'; 7 | 8 | @Controller('email-confirmation') 9 | @ApiTags('authentication') 10 | export class EmailConfirmationController { 11 | constructor( 12 | private readonly emailConfirmationService: EmailConfirmationService, 13 | ) {} 14 | 15 | @Post('confirm') 16 | async confirm(@Body() confirmationData: ConfirmEmailDto) { 17 | const email = await this.emailConfirmationService.decodeConfirmationToken( 18 | confirmationData.token, 19 | ); 20 | await this.emailConfirmationService.confirmEmail(email); 21 | } 22 | 23 | @Post('resend-confirmation-link') 24 | @UseGuards(JwtAuthenticationGuard) 25 | async resendConfirmationLink(@Req() request: RequestWithUser) { 26 | await this.emailConfirmationService.resendConfirmationLink(request.user.id); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/features/email-confirmation/email-confirmation.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { EmailModule } from '../../email/email.module'; 5 | import { UsersModule } from '../users/users.module'; 6 | import { EmailConfirmationController } from './email-confirmation.controller'; 7 | import { EmailConfirmationService } from './email-confirmation.service'; 8 | 9 | @Module({ 10 | imports: [ConfigModule, EmailModule, JwtModule.register({}), UsersModule], 11 | controllers: [EmailConfirmationController], 12 | providers: [EmailConfirmationService], 13 | exports: [EmailConfirmationService], 14 | }) 15 | export class EmailConfirmationModule {} 16 | -------------------------------------------------------------------------------- /src/features/email-confirmation/email-confirmation.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { RegisterDto } from '../../authentication/dto/register.dto'; 5 | import { EmailService } from '../../email/email.service'; 6 | import { UsersService } from '../users/users.service'; 7 | import { VerificationTokenPayload } from './verification-token-payload.interface'; 8 | 9 | @Injectable() 10 | export class EmailConfirmationService { 11 | constructor( 12 | private readonly jwtService: JwtService, 13 | private readonly configService: ConfigService, 14 | private readonly emailService: EmailService, 15 | private readonly usersService: UsersService, 16 | ) {} 17 | 18 | public sendVerificationLink({ email, name }: RegisterDto) { 19 | const payload: VerificationTokenPayload = { email }; 20 | 21 | const token = this.jwtService.sign(payload, { 22 | secret: this.configService.get('jwt.verificationTokenSecret'), 23 | expiresIn: `${this.configService.get( 24 | 'jwt.verificationTokenExpirationTime', 25 | )}s`, 26 | }); 27 | 28 | const url = `${this.configService.get( 29 | 'email.confirmationLink', 30 | )}?token=${token}`; 31 | 32 | const text = `Welcome to the application. To confirm the email address, click here:
${url}`; 33 | 34 | return this.emailService.sendMail({ 35 | from: this.configService.get('email.from'), 36 | to: email, 37 | subject: 'Email confirmation', 38 | html: `

Hello ${name}!

${text}

`, 39 | }); 40 | } 41 | 42 | public async resendConfirmationLink(userId: number) { 43 | const user = await this.usersService.getById(userId); 44 | 45 | if (user.isEmailConfirmed) { 46 | throw new BadRequestException('Email already confirmed'); 47 | } 48 | 49 | await this.sendVerificationLink(user); 50 | } 51 | 52 | public async confirmEmail(email: string) { 53 | const user = await this.usersService.getByEmail(email); 54 | 55 | if (user.isEmailConfirmed) { 56 | throw new BadRequestException('Email already confirmed'); 57 | } 58 | 59 | await this.usersService.markEmailAsConfirmed(email); 60 | } 61 | 62 | public async decodeConfirmationToken(token: string) { 63 | try { 64 | const payload = await this.jwtService.verify(token, { 65 | secret: this.configService.get('jwt.verificationTokenSecret'), 66 | }); 67 | 68 | if (typeof payload === 'object' && 'email' in payload) { 69 | return payload.email; 70 | } 71 | 72 | throw new BadRequestException(); 73 | } catch (error: any) { 74 | if (error.name === 'TokenExpiredError') { 75 | throw new BadRequestException('Email confirmation token expired'); 76 | } 77 | 78 | throw new BadRequestException('Bad confirmation token'); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/features/email-confirmation/verification-token-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface VerificationTokenPayload { 2 | email: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/email-scheduling/dto/email-schedule.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsDateString, IsEmail } from 'class-validator'; 2 | 3 | export class EmailScheduleDto { 4 | @IsEmail() 5 | recipient: string; 6 | 7 | @IsString() 8 | @IsNotEmpty() 9 | subject: string; 10 | 11 | @IsString() 12 | @IsNotEmpty() 13 | content: string; 14 | 15 | @IsDateString() 16 | date: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/features/email-scheduling/email-scheduling.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, UseGuards } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { JwtAuthenticationGuard } from '../../authentication/jwt-authentication.guard'; 4 | import { EmailScheduleDto } from './dto/email-schedule.dto'; 5 | import { EmailSchedulingService } from './email-scheduling.service'; 6 | 7 | @Controller('email-scheduling') 8 | @ApiTags('email-scheduling') 9 | export class EmailSchedulingController { 10 | constructor( 11 | private readonly emailSchedulingService: EmailSchedulingService, 12 | ) {} 13 | 14 | @Post('schedule') 15 | @UseGuards(JwtAuthenticationGuard) 16 | async scheduleEmail(@Body() emailSchedule: EmailScheduleDto) { 17 | this.emailSchedulingService.scheduleEmail(emailSchedule); 18 | } 19 | 20 | @Post('schedule/cancel') 21 | @UseGuards(JwtAuthenticationGuard) 22 | async cancelScheduleEmail() { 23 | this.emailSchedulingService.cancelAllScheduledEmails(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/features/email-scheduling/email-scheduling.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EmailSchedulingService } from './email-scheduling.service'; 3 | import { EmailSchedulingController } from './email-scheduling.controller'; 4 | import { EmailModule } from '../../email/email.module'; 5 | 6 | @Module({ 7 | imports: [EmailModule], 8 | providers: [EmailSchedulingService], 9 | controllers: [EmailSchedulingController], 10 | }) 11 | export class EmailSchedulingModule {} 12 | -------------------------------------------------------------------------------- /src/features/email-scheduling/email-scheduling.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SchedulerRegistry } from '@nestjs/schedule'; 3 | import { CronJob } from 'cron'; 4 | import { EmailService } from '../../email/email.service'; 5 | import { EmailScheduleDto } from './dto/email-schedule.dto'; 6 | 7 | @Injectable() 8 | export class EmailSchedulingService { 9 | constructor( 10 | private readonly emailService: EmailService, 11 | private readonly schedulerRegistry: SchedulerRegistry, 12 | ) {} 13 | 14 | scheduleEmail(emailSchedule: EmailScheduleDto) { 15 | const date = new Date(emailSchedule.date); 16 | 17 | const job = new CronJob(date, () => { 18 | this.emailService.sendMail({ 19 | to: emailSchedule.recipient, 20 | subject: emailSchedule.subject, 21 | text: emailSchedule.content, 22 | }); 23 | }); 24 | 25 | this.schedulerRegistry.addCronJob( 26 | `${Date.now()}-${emailSchedule.subject}`, 27 | job, 28 | ); 29 | 30 | job.start(); 31 | } 32 | 33 | cancelAllScheduledEmails() { 34 | this.schedulerRegistry.getCronJobs().forEach((job) => { 35 | job.stop(); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/features/files/entities/private-file.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { User } from '../../users/entities/user.entity'; 3 | 4 | @Entity() 5 | export class PrivateFile { 6 | @PrimaryGeneratedColumn() 7 | public id: number; 8 | 9 | @Column() 10 | public key: string; 11 | 12 | @ManyToOne(() => User, (owner: User) => owner.files) 13 | public owner: User; 14 | } 15 | -------------------------------------------------------------------------------- /src/features/files/entities/public-file.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class PublicFile { 5 | @PrimaryGeneratedColumn() 6 | public id: number; 7 | 8 | @Column() 9 | public url: string; 10 | 11 | @Column() 12 | public key: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/features/files/exceptions/file-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class FileNotFoundException extends NotFoundException { 4 | constructor(fileId: number) { 5 | super(`File with id ${fileId} not found`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/features/files/files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { PrivateFile } from './entities/private-file.entity'; 4 | import { PublicFile } from './entities/public-file.entity'; 5 | import { FilesService } from './files.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([PublicFile, PrivateFile])], 9 | providers: [FilesService], 10 | exports: [FilesService], 11 | }) 12 | export class FilesModule {} 13 | -------------------------------------------------------------------------------- /src/features/files/files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { PublicFile } from './entities/public-file.entity'; 6 | import { PrivateFile } from './entities/private-file.entity'; 7 | import { S3 } from 'aws-sdk'; 8 | import { v4 as uuid } from 'uuid'; 9 | import { FileNotFoundException } from './exceptions/file-not-found.exception'; 10 | import internal from 'stream'; 11 | 12 | @Injectable() 13 | export class FilesService { 14 | constructor( 15 | @InjectRepository(PublicFile) 16 | private publicFilesRepository: Repository, 17 | @InjectRepository(PrivateFile) 18 | private privateFilesRepository: Repository, 19 | private readonly configService: ConfigService, 20 | ) {} 21 | 22 | /** 23 | * @param fileId An id of a file. A file with this id should exist in the database 24 | * @returns A promise with the file and its information 25 | */ 26 | public async getPrivateFile(fileId: number): Promise<{ 27 | stream: internal.Readable; 28 | info: PrivateFile; 29 | }> { 30 | const s3 = new S3(); 31 | const fileInfo = await this.privateFilesRepository.findOne({ 32 | where: { 33 | id: fileId, 34 | }, 35 | relations: ['owner'], 36 | }); 37 | if (fileInfo) { 38 | const stream = await s3 39 | .getObject({ 40 | Bucket: this.configService.get('aws.publicBucketName'), 41 | Key: fileInfo.key, 42 | }) 43 | .createReadStream(); 44 | return { 45 | stream, 46 | info: fileInfo, 47 | }; 48 | } 49 | throw new FileNotFoundException(fileId); 50 | } 51 | 52 | /** 53 | * @param key AWS object key 54 | * @returns A promise with a url 55 | */ 56 | public async generatePresignedUrl(key: string): Promise { 57 | const s3 = new S3(); 58 | return s3.getSignedUrlPromise('getObject', { 59 | Bucket: this.configService.get('aws.publicBucketName'), 60 | Key: key, 61 | }); 62 | } 63 | 64 | /** 65 | * @param dataBuffer The file buffer of the uploaded content 66 | * @param filename A file name 67 | * @returns A promise with the public version of the uploaded file 68 | */ 69 | async uploadPublicFile( 70 | dataBuffer: Buffer, 71 | filename: string, 72 | ): Promise { 73 | const s3 = new S3(); 74 | const uploadResult = await s3 75 | .upload({ 76 | Bucket: this.configService.get('aws.publicBucketName'), 77 | Body: dataBuffer, 78 | Key: `${uuid()}-${filename}`, 79 | }) 80 | .promise(); 81 | const newFile = this.publicFilesRepository.create({ 82 | key: uploadResult.Key, 83 | url: uploadResult.Location, 84 | }); 85 | await this.publicFilesRepository.save(newFile); 86 | return newFile; 87 | } 88 | 89 | /** 90 | * @param dataBuffer The file buffer of the uploaded content 91 | * @param ownerId An id of the user who uploaded the file. A user with this id should exist on the database 92 | * @param filename A file name 93 | * @returns A promise with the public version of the uploaded file 94 | */ 95 | async uploadPrivateFile( 96 | dataBuffer: Buffer, 97 | ownerId: number, 98 | filename: string, 99 | ): Promise { 100 | const s3 = new S3(); 101 | const uploadResult = await s3 102 | .upload({ 103 | Bucket: this.configService.get('aws.publicBucketName'), 104 | Body: dataBuffer, 105 | Key: `${uuid()}-${filename}`, 106 | }) 107 | .promise(); 108 | const newFile = this.privateFilesRepository.create({ 109 | key: uploadResult.Key, 110 | owner: { 111 | id: ownerId, 112 | }, 113 | }); 114 | await this.privateFilesRepository.save(newFile); 115 | return newFile; 116 | } 117 | 118 | /** 119 | * @param fileId An id of a file. A file with this id should exist in the database 120 | */ 121 | async deletePublicFile(fileId: number): Promise { 122 | const s3 = new S3(); 123 | const file = await this.publicFilesRepository.findOne({ 124 | where: { id: fileId }, 125 | }); 126 | await s3 127 | .deleteObject({ 128 | Bucket: this.configService.get('aws.publicBucketName'), 129 | Key: file.key, 130 | }) 131 | .promise(); 132 | await this.publicFilesRepository.delete(fileId); 133 | } 134 | 135 | /** 136 | * @param fileId An id of a file. A file with this id should exist in the database 137 | * @param ownerId An id of the user who uploaded the file. A user with this id should exist on the database 138 | */ 139 | async deletePrivateFile(fileId: number, ownerId: number): Promise { 140 | const s3 = new S3(); 141 | const file = await this.privateFilesRepository.findOne({ 142 | where: { 143 | id: fileId, 144 | }, 145 | relations: ['owner'], 146 | }); 147 | if (file) { 148 | if (file.owner && file.owner.id === ownerId) { 149 | await s3 150 | .deleteObject({ 151 | Bucket: this.configService.get('aws.publicBucketName'), 152 | Key: file.key, 153 | }) 154 | .promise(); 155 | await this.privateFilesRepository.delete(fileId); 156 | } else { 157 | throw new UnauthorizedException(); 158 | } 159 | } else { 160 | throw new FileNotFoundException(fileId); 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/features/google-authentication/dto/token-verification.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class TokenVerificationDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | token: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/google-authentication/google-authentication.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Req } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { GoogleAuthenticationService } from './google-authentication.service'; 4 | import { Request } from 'express'; 5 | import { TokenVerificationDto } from './dto/token-verification.dto'; 6 | 7 | @Controller('google-authentication') 8 | @ApiTags('authentication') 9 | export class GoogleAuthenticationController { 10 | constructor( 11 | private readonly googleAuthenticationService: GoogleAuthenticationService, 12 | ) {} 13 | 14 | @Post() 15 | async authenticate( 16 | @Body() tokenData: TokenVerificationDto, 17 | @Req() request: Request, 18 | ) { 19 | const { accessTokenCookie, refreshTokenCookie, user } = 20 | await this.googleAuthenticationService.authenticate(tokenData.token); 21 | 22 | request.res.setHeader('Set-Cookie', [ 23 | accessTokenCookie, 24 | refreshTokenCookie, 25 | ]); 26 | 27 | return user; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/features/google-authentication/google-authentication.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { AuthenticationModule } from '../../authentication/authentication.module'; 4 | import { UsersModule } from '../users/users.module'; 5 | import { GoogleAuthenticationController } from './google-authentication.controller'; 6 | import { GoogleAuthenticationService } from './google-authentication.service'; 7 | 8 | @Module({ 9 | imports: [ConfigModule, UsersModule, AuthenticationModule], 10 | controllers: [GoogleAuthenticationController], 11 | providers: [GoogleAuthenticationService], 12 | }) 13 | export class GoogleAuthenticationModule {} 14 | -------------------------------------------------------------------------------- /src/features/google-authentication/google-authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { Auth, google } from 'googleapis'; 4 | import { AuthenticationService } from '../../authentication/authentication.service'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { UsersService } from '../users/users.service'; 7 | 8 | @Injectable() 9 | export class GoogleAuthenticationService { 10 | oauthClient: Auth.OAuth2Client; 11 | 12 | constructor( 13 | private readonly usersService: UsersService, 14 | private readonly configService: ConfigService, 15 | private readonly authenticationService: AuthenticationService, 16 | ) { 17 | const clientID = this.configService.get('google.auth.clientId'); 18 | const clientSecret = this.configService.get('google.auth.clientSecret'); 19 | 20 | this.oauthClient = new google.auth.OAuth2(clientID, clientSecret); 21 | } 22 | 23 | async getUserData(token: string) { 24 | const userInfoClient = google.oauth2('v2').userinfo; 25 | 26 | this.oauthClient.setCredentials({ 27 | access_token: token, 28 | }); 29 | 30 | const userInfoResponse = await userInfoClient.get({ 31 | auth: this.oauthClient, 32 | }); 33 | 34 | return userInfoResponse.data; 35 | } 36 | 37 | async getCookiesForUser(user: User) { 38 | const accessTokenCookie = 39 | this.authenticationService.getCookieWithJwtAccessToken(user.id); 40 | const { cookie: refreshTokenCookie, token: refreshToken } = 41 | this.authenticationService.getCookieWithJwtRefreshToken(user.id); 42 | 43 | await this.usersService.setCurrentRefreshToken(refreshToken, user.id); 44 | 45 | return { 46 | accessTokenCookie, 47 | refreshTokenCookie, 48 | }; 49 | } 50 | 51 | async handleRegisteredUser(user: User) { 52 | if (!user.isRegisteredWithGoogle) { 53 | throw new UnauthorizedException(); 54 | } 55 | 56 | const { accessTokenCookie, refreshTokenCookie } = 57 | await this.getCookiesForUser(user); 58 | 59 | return { 60 | accessTokenCookie, 61 | refreshTokenCookie, 62 | user, 63 | }; 64 | } 65 | 66 | async registerUser(token: string, email: string) { 67 | const userData = await this.getUserData(token); 68 | const name = userData.name; 69 | 70 | const user = await this.usersService.createWithGoogle(email, name); 71 | 72 | return this.handleRegisteredUser(user); 73 | } 74 | 75 | async authenticate(token: string) { 76 | const tokenInfo = await this.oauthClient.getTokenInfo(token); 77 | 78 | const email = tokenInfo.email; 79 | 80 | try { 81 | const user = await this.usersService.getByEmail(email); 82 | 83 | return this.handleRegisteredUser(user); 84 | } catch (error: any) { 85 | if (error.status !== 404) { 86 | throw new error(); 87 | } 88 | 89 | return this.registerUser(token, email); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/features/local-files/dto/local-file.dto.ts: -------------------------------------------------------------------------------- 1 | export interface LocalFileDto { 2 | filename: string; 3 | path: string; 4 | mimetype: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/features/local-files/entities/local-file.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class LocalFile { 5 | @PrimaryGeneratedColumn() 6 | public id: number; 7 | 8 | @Column() 9 | filename: string; 10 | 11 | @Column() 12 | path: string; 13 | 14 | @Column() 15 | mimetype: string; 16 | } 17 | -------------------------------------------------------------------------------- /src/features/local-files/local-files.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | ParseIntPipe, 6 | Res, 7 | StreamableFile, 8 | } from '@nestjs/common'; 9 | import { createReadStream } from 'fs'; 10 | import { join } from 'path'; 11 | import { Response } from 'express'; 12 | import { LocalFilesService } from './local-files.service'; 13 | 14 | @Controller('local-files') 15 | export class LocalFilesController { 16 | constructor(private readonly localFilesService: LocalFilesService) {} 17 | 18 | @Get(':id') 19 | async getDatabaseFileById( 20 | @Param('id', ParseIntPipe) id: number, 21 | @Res({ passthrough: true }) response: Response, 22 | ) { 23 | const file = await this.localFilesService.getFileById(id); 24 | 25 | const stream = createReadStream(join(process.cwd(), file.path)); 26 | 27 | response.set({ 28 | 'Content-Disposition': `inline; filename="${file.filename}"`, 29 | 'Content-Type': file.mimetype, 30 | }); 31 | 32 | return new StreamableFile(stream); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/features/local-files/local-files.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, mixin, NestInterceptor, Type } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { FileInterceptor } from '@nestjs/platform-express'; 4 | import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; 5 | import { diskStorage } from 'multer'; 6 | 7 | interface LocalFilesInterceptorOptions { 8 | fieldName: string; 9 | path?: string; 10 | fileFilter?: MulterOptions['fileFilter']; 11 | limits?: MulterOptions['limits']; 12 | } 13 | 14 | export function LocalFilesInterceptor( 15 | options: LocalFilesInterceptorOptions, 16 | ): Type { 17 | @Injectable() 18 | class Interceptor implements NestInterceptor { 19 | fileInterceptor: NestInterceptor; 20 | 21 | constructor(configService: ConfigService) { 22 | const filesDestination = configService.get('app.fileDestination'); 23 | 24 | const destination = `${filesDestination}${options.path}`; 25 | 26 | const multerOptions: MulterOptions = { 27 | storage: diskStorage({ 28 | destination, 29 | }), 30 | fileFilter: options.fileFilter, 31 | limits: options.limits, 32 | }; 33 | 34 | this.fileInterceptor = new (FileInterceptor( 35 | options.fieldName, 36 | multerOptions, 37 | ))(); 38 | } 39 | 40 | intercept(...args: Parameters) { 41 | return this.fileInterceptor.intercept(...args); 42 | } 43 | } 44 | 45 | return mixin(Interceptor); 46 | } 47 | -------------------------------------------------------------------------------- /src/features/local-files/local-files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { LocalFile } from './entities/local-file.entity'; 5 | import { LocalFilesController } from './local-files.controller'; 6 | import { LocalFilesService } from './local-files.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([LocalFile]), ConfigModule], 10 | controllers: [LocalFilesController], 11 | providers: [LocalFilesService], 12 | exports: [LocalFilesService], 13 | }) 14 | export class LocalFilesModule {} 15 | -------------------------------------------------------------------------------- /src/features/local-files/local-files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { QueryRunner, Repository } from 'typeorm'; 4 | import { LocalFileDto } from './dto/local-file.dto'; 5 | import { LocalFile } from './entities/local-file.entity'; 6 | 7 | @Injectable() 8 | export class LocalFilesService { 9 | constructor( 10 | @InjectRepository(LocalFile) 11 | private localFilesRepository: Repository, 12 | ) {} 13 | 14 | async saveLocalFileData(fileData: LocalFileDto) { 15 | const newFile = await this.localFilesRepository.create(fileData); 16 | await this.localFilesRepository.save(newFile); 17 | return newFile; 18 | } 19 | 20 | async getFileById(fileId: number) { 21 | const file = await this.localFilesRepository.findOne({ 22 | where: { 23 | id: fileId, 24 | }, 25 | }); 26 | if (!file) { 27 | throw new NotFoundException(); 28 | } 29 | return file; 30 | } 31 | 32 | async deleteLocalFileWithQueryRunner( 33 | fileId: number, 34 | queryRunner: QueryRunner, 35 | ) { 36 | await queryRunner.manager.delete(LocalFile, fileId); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/features/optimize/dto/images-upload.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Express } from 'express'; 3 | 4 | export class ImagesUploadDto { 5 | @ApiProperty({ 6 | description: 'Attachments', 7 | type: 'array', 8 | items: { 9 | type: 'file', 10 | items: { 11 | type: 'string', 12 | format: 'binary', 13 | }, 14 | }, 15 | }) 16 | files: Express.Multer.File[]; 17 | } 18 | -------------------------------------------------------------------------------- /src/features/optimize/image.processor.ts: -------------------------------------------------------------------------------- 1 | import { DoneCallback, Job } from 'bull'; 2 | import { Logger } from '@nestjs/common'; 3 | 4 | async function imageProcessor(job: Job, doneCallback: DoneCallback) { 5 | const logger = new Logger(job.name); 6 | 7 | logger.debug('Start processing...'); 8 | logger.debug(job.data.files); 9 | logger.debug('Processing completed'); 10 | 11 | doneCallback(null, job.data.files); 12 | } 13 | 14 | export default imageProcessor; 15 | -------------------------------------------------------------------------------- /src/features/optimize/optimize.controller.ts: -------------------------------------------------------------------------------- 1 | import { InjectQueue } from '@nestjs/bull'; 2 | import { 3 | Controller, 4 | Get, 5 | Param, 6 | Post, 7 | Res, 8 | UploadedFiles, 9 | UseInterceptors, 10 | } from '@nestjs/common'; 11 | import { FilesInterceptor } from '@nestjs/platform-express'; 12 | import { 13 | ApiBody, 14 | ApiConsumes, 15 | ApiCreatedResponse, 16 | ApiParam, 17 | ApiTags, 18 | } from '@nestjs/swagger'; 19 | import { Queue } from 'bull'; 20 | import { Response } from 'express'; 21 | import { Readable } from 'stream'; 22 | import { ImagesUploadDto } from './dto/images-upload.dto'; 23 | 24 | @Controller('optimize') 25 | @ApiTags('optimize') 26 | export class OptimizeController { 27 | constructor(@InjectQueue('image') private readonly imageQueue: Queue) {} 28 | 29 | @Post('image') 30 | @ApiConsumes('multipart/form-data') 31 | @ApiBody({ 32 | description: 'Upload images', 33 | type: ImagesUploadDto, 34 | }) 35 | @ApiCreatedResponse({ 36 | description: 'Images have been uploaded successfully!', 37 | }) 38 | @UseInterceptors(FilesInterceptor('files')) 39 | async processImage(@UploadedFiles() files: Express.Multer.File[]) { 40 | const job = await this.imageQueue.add('optimize', { 41 | files, 42 | }); 43 | 44 | return { 45 | jobId: job.id, 46 | }; 47 | } 48 | 49 | @Get('image/:id') 50 | @ApiParam({ 51 | name: 'id', 52 | required: true, 53 | description: 'Should be a valid job id', 54 | type: String, 55 | }) 56 | async getJobResult(@Res() response: Response, @Param('id') id: string) { 57 | const job = await this.imageQueue.getJob(id); 58 | if (!job) { 59 | return response.sendStatus(404); 60 | } 61 | 62 | const isCompleted = await job.isCompleted(); 63 | 64 | if (!isCompleted) { 65 | return response.sendStatus(202); 66 | } 67 | 68 | const result = Buffer.from(job.returnvalue); 69 | 70 | const stream = Readable.from(result); 71 | 72 | stream.pipe(response); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/features/optimize/optimize.module.ts: -------------------------------------------------------------------------------- 1 | import { BullModule } from '@nestjs/bull'; 2 | import { Module } from '@nestjs/common'; 3 | import { join } from 'path'; 4 | import { OptimizeController } from './optimize.controller'; 5 | 6 | @Module({ 7 | imports: [ 8 | BullModule.registerQueue({ 9 | name: 'image', 10 | processors: [ 11 | { 12 | name: 'optimize', 13 | path: join(__dirname, 'image.processor.js'), 14 | }, 15 | ], 16 | }), 17 | ], 18 | controllers: [OptimizeController], 19 | }) 20 | export class OptimizeModule {} 21 | -------------------------------------------------------------------------------- /src/features/posts/dto/create-post.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; 2 | export class CreatePostDto { 3 | @IsString() 4 | @IsNotEmpty() 5 | content: string; 6 | 7 | @IsString() 8 | @IsNotEmpty() 9 | title: string; 10 | 11 | @IsString({ each: true }) 12 | @IsOptional() 13 | paragraphs: string[]; 14 | } 15 | -------------------------------------------------------------------------------- /src/features/posts/dto/update-post.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class UpdatePostDto { 4 | @IsNumber() 5 | @IsOptional() 6 | id: number; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | @IsOptional() 11 | content: string; 12 | 13 | @IsString() 14 | @IsNotEmpty() 15 | @IsOptional() 16 | title: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/features/posts/entities/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '../../categories/entities/category.entity'; 2 | import { User } from '../../users/entities/user.entity'; 3 | import { Comment } from '../../comments/entities/comment.entity'; 4 | import { 5 | Column, 6 | Entity, 7 | Index, 8 | JoinTable, 9 | ManyToMany, 10 | ManyToOne, 11 | OneToMany, 12 | PrimaryGeneratedColumn, 13 | } from 'typeorm'; 14 | 15 | @Entity() 16 | export class Post { 17 | @PrimaryGeneratedColumn() 18 | public id: number; 19 | 20 | @Column() 21 | public title: string; 22 | 23 | @Column() 24 | public content: string; 25 | 26 | @Column('text', { array: true, nullable: true }) 27 | public paragraphs?: string[]; 28 | 29 | @Column({ nullable: true }) 30 | public category?: string; 31 | 32 | @Index('post_authorId_index') 33 | @ManyToOne(() => User, (author: User) => author.posts) 34 | public author: User; 35 | 36 | @ManyToMany(() => Category, (category: Category) => category.posts) 37 | @JoinTable() 38 | public categories: Category[]; 39 | 40 | @OneToMany(() => Comment, (comment: Comment) => comment.post) 41 | public comments: Comment[]; 42 | } 43 | -------------------------------------------------------------------------------- /src/features/posts/exceptions/post-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class PostNotFoundException extends NotFoundException { 4 | constructor(postId: number) { 5 | super(`Post with id ${postId} not found`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/features/posts/posts-search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ElasticsearchService } from '@nestjs/elasticsearch'; 3 | import { WriteResponseBase } from '@elastic/elasticsearch/lib/api/types'; 4 | import { Post } from './entities/post.entity'; 5 | import { PostSearchDocument } from './types/post-search-document.interface'; 6 | 7 | @Injectable() 8 | export class PostsSearchService { 9 | index = 'posts'; 10 | 11 | constructor(private readonly elasticsearchService: ElasticsearchService) {} 12 | 13 | async indexPost(post: Post): Promise { 14 | return this.elasticsearchService.index({ 15 | index: this.index, 16 | document: { 17 | id: post.id, 18 | title: post.title, 19 | paragraphs: post.paragraphs, 20 | authorId: post.author.id, 21 | }, 22 | }); 23 | } 24 | 25 | async count(query: string, fields: string[]) { 26 | const { count } = await this.elasticsearchService.count({ 27 | index: this.index, 28 | body: { 29 | query: { 30 | multi_match: { 31 | query, 32 | fields, 33 | }, 34 | }, 35 | }, 36 | }); 37 | return count; 38 | } 39 | 40 | async search(text: string, offset?: number, limit?: number, startId = 0) { 41 | let separateCount = 0; 42 | if (startId) { 43 | separateCount = await this.count(text, ['title', 'paragraphs']); 44 | } 45 | const { hits } = await this.elasticsearchService.search( 46 | { 47 | index: this.index, 48 | from: offset, 49 | size: limit, 50 | query: { 51 | bool: { 52 | should: { 53 | multi_match: { 54 | query: text, 55 | fields: ['title', 'paragraphs'], 56 | }, 57 | }, 58 | filter: { 59 | range: { 60 | id: { 61 | gt: startId, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | ); 69 | const count = hits.total; 70 | const results = hits.hits.map((item) => item._source); 71 | return { 72 | count: startId ? separateCount : count, 73 | results, 74 | }; 75 | } 76 | 77 | async remove(postId: number) { 78 | this.elasticsearchService.deleteByQuery({ 79 | index: this.index, 80 | query: { 81 | match: { 82 | id: postId, 83 | }, 84 | }, 85 | }); 86 | } 87 | 88 | async update(post: Post) { 89 | const newDocument: PostSearchDocument = { 90 | id: post.id, 91 | title: post.title, 92 | paragraphs: post.paragraphs, 93 | authorId: post.author.id, 94 | }; 95 | 96 | const script = Object.entries(newDocument).reduce( 97 | (result, [key, value]) => { 98 | return `${result} ctx._source.${key}='${value}';`; 99 | }, 100 | '', 101 | ); 102 | 103 | return this.elasticsearchService.updateByQuery({ 104 | index: this.index, 105 | script: { 106 | source: script, 107 | }, 108 | query: { 109 | match: { 110 | id: post.id, 111 | }, 112 | }, 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/features/posts/posts.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | CacheKey, 4 | CacheTTL, 5 | // CacheInterceptor, 6 | Controller, 7 | Delete, 8 | Get, 9 | Param, 10 | Patch, 11 | Post, 12 | Query, 13 | Req, 14 | UseGuards, 15 | // UseInterceptors, 16 | } from '@nestjs/common'; 17 | import { ApiTags } from '@nestjs/swagger'; 18 | import { JwtTwoFactorGuard } from '../../authentication/two-factor/jwt-two-factor.guard'; 19 | import { RequestWithUser } from '../../authentication/request-with-user.interface'; 20 | import { FindOneParams } from '../../utils/dto/find-one-params.dto'; 21 | import { CreatePostDto } from './dto/create-post.dto'; 22 | import { UpdatePostDto } from './dto/update-post.dto'; 23 | import { PostsService } from './posts.service'; 24 | // import { HttpCacheInterceptor } from '../../utils/http-cache.interceptor'; 25 | import { PaginationWithStartIdDto } from '../../utils/dto/pagination-with-start-id.dto'; 26 | 27 | @Controller('posts') 28 | @ApiTags('posts') 29 | // @UseInterceptors(CacheInterceptor) // default cache interceptor 30 | export class PostsController { 31 | constructor(private readonly postsService: PostsService) {} 32 | 33 | // custom cache interceptor 34 | // @UseInterceptors(HttpCacheInterceptor) 35 | @CacheKey('GET_POSTS_CACHE_KEY') 36 | @CacheTTL(120) 37 | @Get() 38 | getPosts( 39 | @Query('search') search: string, 40 | @Query() { offset, limit, startId }: PaginationWithStartIdDto, 41 | ) { 42 | if (search) { 43 | return this.postsService.searchForPosts(search, offset, limit, startId); 44 | } 45 | return this.postsService.getPostsWithAuthors(); 46 | } 47 | 48 | @Get(':id') 49 | getPostById(@Param() { id }: FindOneParams) { 50 | return this.postsService.getPostById(Number(id)); 51 | } 52 | 53 | @Get('paragraphs/:paragraph') 54 | getPostsWithParagraph(@Param() paragraph: string) { 55 | return this.postsService.getPostsWithParagraph(paragraph); 56 | } 57 | 58 | @Post() 59 | @UseGuards(JwtTwoFactorGuard) 60 | async createPost( 61 | @Body() post: CreatePostDto, 62 | @Req() request: RequestWithUser, 63 | ) { 64 | return this.postsService.createPost(post, request.user); 65 | } 66 | 67 | @Patch(':id') 68 | @UseGuards(JwtTwoFactorGuard) 69 | async updatePost( 70 | @Param() { id }: FindOneParams, 71 | @Body() post: UpdatePostDto, 72 | ) { 73 | return this.postsService.updatePost(Number(id), post); 74 | } 75 | 76 | @Delete(':id') 77 | @UseGuards(JwtTwoFactorGuard) 78 | async deletePost(@Param() { id }: FindOneParams) { 79 | return this.postsService.deletePost(Number(id)); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/features/posts/posts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | // import { CacheModule, Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | // import { cacheModuleOptions } from '../../config/redis.config'; 5 | import { SearchModule } from '../search/search.module'; 6 | import { Post } from './entities/post.entity'; 7 | import { PostsSearchService } from './posts-search.service'; 8 | import { PostsController } from './posts.controller'; 9 | import { PostsService } from './posts.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | // CacheModule.registerAsync(cacheModuleOptions), 14 | TypeOrmModule.forFeature([Post]), 15 | SearchModule, 16 | ], 17 | controllers: [PostsController], 18 | providers: [PostsService, PostsSearchService], 19 | }) 20 | export class PostsModule {} 21 | -------------------------------------------------------------------------------- /src/features/posts/posts.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from '../users/entities/user.entity'; 4 | import { In, Repository } from 'typeorm'; 5 | import { CreatePostDto } from './dto/create-post.dto'; 6 | import { UpdatePostDto } from './dto/update-post.dto'; 7 | import { Post } from './entities/post.entity'; 8 | import { PostNotFoundException } from './exceptions/post-not-found.exception'; 9 | import { PostsSearchService } from './posts-search.service'; 10 | 11 | @Injectable() 12 | export class PostsService { 13 | constructor( 14 | @InjectRepository(Post) private postsRepository: Repository, 15 | private postsSearchService: PostsSearchService, 16 | ) {} 17 | 18 | async getPostsWithAuthors() { 19 | return this.postsRepository.find({ 20 | relations: ['author'], 21 | }); 22 | } 23 | 24 | async getPostById(id: number) { 25 | const post = await this.postsRepository.findOne({ 26 | where: { 27 | id, 28 | }, 29 | relations: ['author'], 30 | }); 31 | if (post) { 32 | return post; 33 | } 34 | throw new PostNotFoundException(id); 35 | } 36 | 37 | async getPostsWithParagraph(paragraph: string) { 38 | const posts = await this.postsRepository.query( 39 | 'SELECT * FROM post WHERE $1 = ANY(paragraphs)', 40 | [paragraph], 41 | ); 42 | if (posts) { 43 | return posts; 44 | } 45 | throw new NotFoundException('Post with the paragraph is not found!'); 46 | } 47 | 48 | async createPost(post: CreatePostDto, user: User) { 49 | const newPost = await this.postsRepository.create({ 50 | ...post, 51 | author: user, 52 | }); 53 | const savedPost = await this.postsRepository.save(newPost); 54 | this.postsSearchService.indexPost(savedPost); 55 | return savedPost; 56 | } 57 | 58 | async updatePost(id: number, post: UpdatePostDto) { 59 | await this.postsRepository.update(id, post); 60 | const updatedPost = await this.postsRepository.findOne({ 61 | where: { 62 | id, 63 | }, 64 | relations: ['author'], 65 | }); 66 | if (updatedPost) { 67 | await this.postsSearchService.update(updatedPost); 68 | return updatedPost; 69 | } 70 | throw new PostNotFoundException(id); 71 | } 72 | 73 | async deletePost(id: number) { 74 | const deleteResponse = await this.postsRepository.delete(id); 75 | if (!deleteResponse.affected) { 76 | throw new PostNotFoundException(id); 77 | } 78 | await this.postsSearchService.remove(id); 79 | } 80 | 81 | async searchForPosts( 82 | text: string, 83 | offset?: number, 84 | limit?: number, 85 | startId?: number, 86 | ) { 87 | const { results, count } = await this.postsSearchService.search( 88 | text, 89 | offset, 90 | limit, 91 | startId, 92 | ); 93 | 94 | const ids = results.map((result) => result.id); 95 | if (!ids.length) { 96 | return { 97 | items: [], 98 | count, 99 | }; 100 | } 101 | const items = await this.postsRepository.find({ 102 | where: { id: In(ids) }, 103 | }); 104 | return { 105 | items, 106 | count, 107 | }; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/features/posts/types/post-search-document.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PostSearchDocument { 2 | id: number; 3 | title: string; 4 | paragraphs: string[]; 5 | authorId: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/features/products/commands/handlers/create-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { Product } from '../../entities/product.entity'; 5 | import { CreateProductCommand } from '../implementations/create-product.command'; 6 | 7 | @CommandHandler(CreateProductCommand) 8 | export class CreateProductHandler 9 | implements ICommandHandler 10 | { 11 | constructor( 12 | @InjectRepository(Product) private productsRepository: Repository, 13 | ) {} 14 | 15 | async execute(command: CreateProductCommand): Promise { 16 | const { product, owner } = command; 17 | const newProduct = await this.productsRepository.create({ 18 | ...product, 19 | owner, 20 | }); 21 | await this.productsRepository.save(newProduct); 22 | return newProduct; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/features/products/commands/handlers/delete-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException } from '@nestjs/common'; 2 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { Product } from '../../entities/product.entity'; 6 | import { ProductNotFoundException } from '../../exceptions/file-not-found.exception'; 7 | import { DeleteProductCommand } from '../implementations/delete-product.command'; 8 | 9 | @CommandHandler(DeleteProductCommand) 10 | export class DeleteProductHandler 11 | implements ICommandHandler 12 | { 13 | constructor( 14 | @InjectRepository(Product) private productsRepository: Repository, 15 | ) {} 16 | 17 | async execute(command: DeleteProductCommand): Promise { 18 | const { id, owner } = command; 19 | const oldProduct = await this.productsRepository.findOne({ 20 | where: { 21 | id, 22 | }, 23 | relations: ['owner'], 24 | }); 25 | if (oldProduct) { 26 | if (oldProduct.owner && oldProduct.owner.id === owner.id) { 27 | const deleteResponse = await this.productsRepository.delete(id); 28 | if (!deleteResponse.affected) { 29 | throw new ProductNotFoundException(id); 30 | } 31 | return; 32 | } else { 33 | throw new UnauthorizedException(); 34 | } 35 | } 36 | throw new ProductNotFoundException(id); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/features/products/commands/handlers/update-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException } from '@nestjs/common'; 2 | import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Repository } from 'typeorm'; 5 | import { Product } from '../../entities/product.entity'; 6 | import { ProductNotFoundException } from '../../exceptions/file-not-found.exception'; 7 | import { UpdateProductCommand } from '../implementations/update-product.command'; 8 | 9 | @CommandHandler(UpdateProductCommand) 10 | export class UpdateProductHandler 11 | implements ICommandHandler 12 | { 13 | constructor( 14 | @InjectRepository(Product) private productsRepository: Repository, 15 | ) {} 16 | 17 | async execute(command: UpdateProductCommand): Promise { 18 | const { id, product, owner } = command; 19 | const oldProduct = await this.productsRepository.findOne({ 20 | where: { 21 | id, 22 | }, 23 | relations: ['owner'], 24 | }); 25 | if (oldProduct) { 26 | if (oldProduct.owner && oldProduct.owner.id === owner.id) { 27 | await this.productsRepository.update(id, product); 28 | const updatedProduct = await this.productsRepository.findOne({ 29 | where: { 30 | id, 31 | }, 32 | relations: ['owner'], 33 | }); 34 | if (updatedProduct) { 35 | return updatedProduct; 36 | } 37 | } else { 38 | throw new UnauthorizedException(); 39 | } 40 | } 41 | throw new ProductNotFoundException(id); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/features/products/commands/implementations/create-product.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../../users/entities/user.entity'; 2 | import { CreateProductDto } from '../../dto/create-product.dto'; 3 | 4 | export class CreateProductCommand { 5 | constructor( 6 | public readonly product: CreateProductDto, 7 | public readonly owner: User, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/features/products/commands/implementations/delete-product.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../../users/entities/user.entity'; 2 | 3 | export class DeleteProductCommand { 4 | constructor(public readonly id: string, public readonly owner: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/features/products/commands/implementations/update-product.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../../users/entities/user.entity'; 2 | import { UpdateProductDto } from '../../dto/update-product.dto'; 3 | 4 | export class UpdateProductCommand { 5 | constructor( 6 | public readonly id: string, 7 | public readonly product: UpdateProductDto, 8 | public readonly owner: User, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/features/products/dto/create-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | import { BookProperties } from '../types/book-properties.interface'; 3 | import { CarProperties } from '../types/car-properties.interface'; 4 | 5 | export class CreateProductDto { 6 | @IsString() 7 | @IsNotEmpty() 8 | name: string; 9 | 10 | @IsOptional() 11 | properties?: CarProperties | BookProperties; 12 | } 13 | -------------------------------------------------------------------------------- /src/features/products/dto/get-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsOptional } from 'class-validator'; 3 | import { PaginationDto } from '../../../utils/dto/pagination.dto'; 4 | 5 | export class GetProductDto extends PaginationDto { 6 | @Type(() => Number) 7 | @IsOptional() 8 | ownerId?: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/features/products/dto/update-product.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateProductDto } from './create-product.dto'; 3 | 4 | export class UpdateProductDto extends PartialType(CreateProductDto) {} 5 | -------------------------------------------------------------------------------- /src/features/products/entities/product.entity.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../../users/entities/user.entity'; 2 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 3 | import { BookProperties } from '../types/book-properties.interface'; 4 | import { CarProperties } from '../types/car-properties.interface'; 5 | 6 | @Entity() 7 | export class Product { 8 | @PrimaryGeneratedColumn('uuid') 9 | public id: string; 10 | 11 | @Column() 12 | public name: string; 13 | 14 | @Column({ 15 | type: 'jsonb', 16 | nullable: true, 17 | }) 18 | public properties?: CarProperties | BookProperties; 19 | 20 | @ManyToOne(() => User, (owner: User) => owner.products) 21 | public owner: User; 22 | } 23 | -------------------------------------------------------------------------------- /src/features/products/enums/productsPermission.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ProductsPermission { 2 | CreateProduct = 'CreateProduct', 3 | UpdateProduct = 'UpdateProduct', 4 | DeleteProduct = 'DeleteProduct', 5 | } 6 | -------------------------------------------------------------------------------- /src/features/products/exceptions/file-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class ProductNotFoundException extends NotFoundException { 4 | constructor(fileId: string) { 5 | super(`Product with id ${fileId} not found`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/features/products/products.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Patch, 7 | Param, 8 | Delete, 9 | UseGuards, 10 | Req, 11 | Query, 12 | HttpCode, 13 | } from '@nestjs/common'; 14 | import { ProductsService } from './products.service'; 15 | import { CreateProductDto } from './dto/create-product.dto'; 16 | import { UpdateProductDto } from './dto/update-product.dto'; 17 | import { JwtAuthenticationGuard } from '../../authentication/jwt-authentication.guard'; 18 | import { RequestWithUser } from '../../authentication/request-with-user.interface'; 19 | import { GetProductDto } from './dto/get-product.dto'; 20 | import { PaginatedResultDto } from '../../utils/dto/paginated-result.dto'; 21 | import { Product } from './entities/product.entity'; 22 | import { ApiExtraModels, ApiTags } from '@nestjs/swagger'; 23 | import { PermissionGuard } from '../../authorization/permission.guard'; 24 | import { Permission } from '../../authorization/types/permission.type'; 25 | 26 | @Controller('products') 27 | @ApiTags('products') 28 | @ApiExtraModels(PaginatedResultDto) 29 | @UseGuards(JwtAuthenticationGuard) 30 | export class ProductsController { 31 | constructor(private readonly productsService: ProductsService) {} 32 | 33 | @Post() 34 | @UseGuards(PermissionGuard(Permission.CreateProduct)) 35 | async create( 36 | @Body() createProductDto: CreateProductDto, 37 | @Req() request: RequestWithUser, 38 | ): Promise { 39 | const user = request.user; 40 | return this.productsService.create(createProductDto, user); 41 | } 42 | 43 | @Get() 44 | async findAll( 45 | @Query() { ownerId, page = 1, limit = 20 }: GetProductDto, 46 | ): Promise> { 47 | return this.productsService.findAll(ownerId, { page, limit }); 48 | } 49 | 50 | @Get(':id') 51 | findOne(@Param('id') id: string): Promise { 52 | return this.productsService.findOne(id); 53 | } 54 | 55 | @Patch(':id') 56 | update( 57 | @Param('id') id: string, 58 | @Body() updateProductDto: UpdateProductDto, 59 | @Req() request: RequestWithUser, 60 | ) { 61 | const user = request.user; 62 | return this.productsService.update(id, updateProductDto, user); 63 | } 64 | 65 | @Delete(':id') 66 | @UseGuards(PermissionGuard(Permission.DeleteProduct)) 67 | @HttpCode(204) 68 | remove(@Param('id') id: string, @Req() request: RequestWithUser) { 69 | const user = request.user; 70 | return this.productsService.remove(id, user); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/features/products/products.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductsService } from './products.service'; 3 | import { ProductsController } from './products.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Product } from './entities/product.entity'; 6 | import { CqrsModule } from '@nestjs/cqrs'; 7 | import { CreateProductHandler } from './commands/handlers/create-product.handler'; 8 | import { FindAllProductsHandler } from './queries/handlers/find-all-products.handler'; 9 | import { FindProductHandler } from './queries/handlers/find-product.handler'; 10 | import { UpdateProductHandler } from './commands/handlers/update-product.handler'; 11 | import { DeleteProductHandler } from './commands/handlers/delete-product.handler'; 12 | 13 | @Module({ 14 | imports: [TypeOrmModule.forFeature([Product]), CqrsModule], 15 | controllers: [ProductsController], 16 | providers: [ 17 | ProductsService, 18 | CreateProductHandler, 19 | FindAllProductsHandler, 20 | FindProductHandler, 21 | UpdateProductHandler, 22 | DeleteProductHandler, 23 | ], 24 | }) 25 | export class ProductsModule {} 26 | -------------------------------------------------------------------------------- /src/features/products/products.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CommandBus, QueryBus } from '@nestjs/cqrs'; 3 | import { PaginatedResultDto } from '../../utils/dto/paginated-result.dto'; 4 | import { PaginationDto } from '../../utils/dto/pagination.dto'; 5 | import { User } from '../users/entities/user.entity'; 6 | import { CreateProductCommand } from './commands/implementations/create-product.command'; 7 | import { DeleteProductCommand } from './commands/implementations/delete-product.command'; 8 | import { UpdateProductCommand } from './commands/implementations/update-product.command'; 9 | import { CreateProductDto } from './dto/create-product.dto'; 10 | import { UpdateProductDto } from './dto/update-product.dto'; 11 | import { Product } from './entities/product.entity'; 12 | import { FindAllProductsQuery } from './queries/implementations/find-all-products.query'; 13 | import { FindProductQuery } from './queries/implementations/find-product.query'; 14 | 15 | @Injectable() 16 | export class ProductsService { 17 | constructor(private commandBus: CommandBus, private queryBus: QueryBus) {} 18 | 19 | async create( 20 | createProductDto: CreateProductDto, 21 | owner: User, 22 | ): Promise { 23 | return this.commandBus.execute( 24 | new CreateProductCommand(createProductDto, owner), 25 | ); 26 | } 27 | 28 | async findAll( 29 | ownerId: number, 30 | paginationDto: PaginationDto, 31 | ): Promise> { 32 | return this.queryBus.execute( 33 | new FindAllProductsQuery(ownerId, paginationDto), 34 | ); 35 | } 36 | 37 | async findOne(id: string): Promise { 38 | return this.queryBus.execute(new FindProductQuery(id)); 39 | } 40 | 41 | async update(id: string, updateProductDto: UpdateProductDto, owner: User) { 42 | return this.commandBus.execute( 43 | new UpdateProductCommand(id, updateProductDto, owner), 44 | ); 45 | } 46 | 47 | remove(id: string, owner: User) { 48 | return this.commandBus.execute(new DeleteProductCommand(id, owner)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/features/products/queries/handlers/find-all-products.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { PaginatedResultDto } from '../../../../utils/dto/paginated-result.dto'; 3 | import { Product } from '../../entities/product.entity'; 4 | import { FindAllProductsQuery } from '../implementations/find-all-products.query'; 5 | import { getPaginationProps } from '../../../../utils/get-pagination-props'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import { FindManyOptions, Repository } from 'typeorm'; 8 | 9 | @QueryHandler(FindAllProductsQuery) 10 | export class FindAllProductsHandler 11 | implements IQueryHandler 12 | { 13 | constructor( 14 | @InjectRepository(Product) private productsRepository: Repository, 15 | ) {} 16 | 17 | async execute({ 18 | ownerId, 19 | paginationDto, 20 | }: FindAllProductsQuery): Promise> { 21 | const { page, limit, skippedItems } = getPaginationProps(paginationDto); 22 | 23 | const where: FindManyOptions['where'] = {}; 24 | if (ownerId) { 25 | where.owner = { 26 | id: ownerId, 27 | }; 28 | } 29 | 30 | const [products, productsCount] = 31 | await this.productsRepository.findAndCount({ 32 | where, 33 | relations: ['owner'], 34 | skip: skippedItems, 35 | take: limit, 36 | }); 37 | 38 | return { 39 | totalCount: productsCount, 40 | page: page, 41 | limit: limit, 42 | data: products, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/features/products/queries/handlers/find-product.handler.ts: -------------------------------------------------------------------------------- 1 | import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; 2 | import { Product } from '../../entities/product.entity'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { FindProductQuery } from '../implementations/find-product.query'; 5 | import { Repository } from 'typeorm'; 6 | 7 | @QueryHandler(FindProductQuery) 8 | export class FindProductHandler implements IQueryHandler { 9 | constructor( 10 | @InjectRepository(Product) private productsRepository: Repository, 11 | ) {} 12 | execute({ productId }: FindProductQuery): Promise { 13 | return this.productsRepository.findOne({ 14 | where: { 15 | id: productId, 16 | }, 17 | relations: ['owner'], 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/features/products/queries/implementations/find-all-products.query.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto } from '../../../../utils/dto/pagination.dto'; 2 | 3 | export class FindAllProductsQuery { 4 | constructor( 5 | public readonly ownerId: number, 6 | public readonly paginationDto: PaginationDto, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/features/products/queries/implementations/find-product.query.ts: -------------------------------------------------------------------------------- 1 | export class FindProductQuery { 2 | constructor(public readonly productId: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/features/products/types/book-properties.interface.ts: -------------------------------------------------------------------------------- 1 | export interface BookProperties { 2 | authors: string[]; 3 | publicationYear: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/features/products/types/car-properties.interface.ts: -------------------------------------------------------------------------------- 1 | export interface CarProperties { 2 | brand: string; 3 | engine: { 4 | fuel: string; 5 | numberOfCylinders: number; 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/search/search.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { ElasticsearchModule } from '@nestjs/elasticsearch'; 4 | import { elasticSearchModuleOptions } from '../../config/elastic-search.config'; 5 | 6 | @Module({ 7 | imports: [ 8 | ConfigModule, 9 | ElasticsearchModule.registerAsync(elasticSearchModuleOptions), 10 | ], 11 | exports: [ElasticsearchModule], 12 | }) 13 | export class SearchModule {} 14 | -------------------------------------------------------------------------------- /src/features/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsString, 4 | IsNotEmpty, 5 | MinLength, 6 | Matches, 7 | IsOptional, 8 | } from 'class-validator'; 9 | 10 | export class CreateUserDto { 11 | @IsEmail() 12 | email: string; 13 | 14 | @IsString() 15 | @IsNotEmpty() 16 | name: string; 17 | 18 | @IsString() 19 | @IsNotEmpty() 20 | @MinLength(7) 21 | password: string; 22 | 23 | @IsOptional() 24 | @IsString() 25 | @IsNotEmpty() 26 | @Matches(/^\+[1-9]\d{1,14}$/) 27 | phoneNumber?: string; 28 | } 29 | -------------------------------------------------------------------------------- /src/features/users/dto/file-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNumber } from 'class-validator'; 2 | import { User } from '../entities/user.entity'; 3 | 4 | export class FileResponseDto { 5 | @IsString() 6 | public url: string; 7 | 8 | @IsNumber() 9 | public id: number; 10 | 11 | @IsString() 12 | public key: string; 13 | 14 | public owner: User; 15 | } 16 | -------------------------------------------------------------------------------- /src/features/users/dto/file-upload-multiple.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class FileUploadMultipleDto { 4 | @ApiProperty({ type: 'array', items: { type: 'string', format: 'binary' } }) 5 | files: any[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/features/users/dto/file-upload.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Express } from 'express'; 3 | 4 | export class FileUploadDto { 5 | @ApiProperty({ type: 'string', format: 'binary' }) 6 | file: Express.Multer.File; 7 | } 8 | -------------------------------------------------------------------------------- /src/features/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateUserDto } from './create-user.dto'; 3 | 4 | export class UpdateUserDto extends PartialType(CreateUserDto) {} 5 | -------------------------------------------------------------------------------- /src/features/users/entities/address.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { User } from './user.entity'; 3 | 4 | @Entity() 5 | export class Address { 6 | @PrimaryGeneratedColumn() 7 | public id: number; 8 | 9 | @Column() 10 | public street: string; 11 | 12 | @Column() 13 | public city: string; 14 | 15 | @Column() 16 | public country: string; 17 | 18 | @OneToOne(() => User, (user: User) => user.address) 19 | public user?: User; 20 | } 21 | -------------------------------------------------------------------------------- /src/features/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | OneToMany, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | import { Exclude } from 'class-transformer'; 10 | import { Address } from './address.entity'; 11 | import { Post } from '../../posts/entities/post.entity'; 12 | import { PrivateFile } from '../../files/entities/private-file.entity'; 13 | import { Product } from '../../products/entities/product.entity'; 14 | import { Role } from '../../../authorization/role.enum'; 15 | import { Permission } from '../../../authorization/types/permission.type'; 16 | import { DatabaseFile } from 'src/features/database-files/entities/database-file.entity'; 17 | 18 | @Entity() 19 | export class User { 20 | @PrimaryGeneratedColumn() 21 | public id?: number; 22 | 23 | @Column({ unique: true }) 24 | public email: string; 25 | 26 | @Column({ default: false }) 27 | public isEmailConfirmed?: boolean; 28 | 29 | @Column({ 30 | type: 'enum', 31 | enum: Role, 32 | default: Role.User, 33 | }) 34 | public role?: Role; 35 | 36 | @Column({ 37 | type: 'enum', 38 | enum: Permission, 39 | array: true, 40 | default: [], 41 | }) 42 | public permissions?: Permission[]; 43 | 44 | @Column() 45 | public name: string; 46 | 47 | @Column({ nullable: true }) 48 | public phoneNumber?: string; 49 | 50 | @OneToOne(() => Address, { 51 | eager: true, 52 | cascade: true, 53 | }) 54 | @JoinColumn() 55 | public address?: Address; 56 | 57 | @Column() 58 | @Exclude() 59 | public password: string; 60 | 61 | @Column({ nullable: true }) 62 | @Exclude() 63 | public currentHashedRefreshToken?: string; 64 | 65 | @OneToMany(() => Post, (post: Post) => post.author) 66 | public posts?: Post[]; 67 | 68 | // use Amazon S3 to store avatar publicly 69 | // @OneToOne(() => PublicFile, { 70 | // eager: true, 71 | // nullable: true, 72 | // }) 73 | // @JoinColumn() 74 | // public avatar?: PublicFile; 75 | 76 | // store file directly to postgres database 77 | @JoinColumn({ name: 'avatarId' }) 78 | @OneToOne(() => DatabaseFile, { 79 | nullable: true, 80 | }) 81 | public avatar?: DatabaseFile; 82 | 83 | // @JoinColumn({ name: 'avatarId' }) 84 | // @OneToOne(() => LocalFile, { 85 | // nullable: true, 86 | // }) 87 | // public avatar?: LocalFile; 88 | 89 | // this field is necessary only for storing files to postgres database 90 | @Column({ nullable: true }) 91 | public avatarId?: number; 92 | 93 | @OneToMany(() => PrivateFile, (file: PrivateFile) => file.owner) 94 | public files?: PrivateFile[]; 95 | 96 | @OneToMany(() => Product, (product: Product) => product.owner) 97 | public products?: Product[]; 98 | 99 | @Column({ nullable: true }) 100 | @Exclude() 101 | public twoFactorAuthenticationSecret?: string; 102 | 103 | @Column({ default: false }) 104 | public isTwoFactorAuthenticationEnabled: boolean; 105 | 106 | @Column({ default: false }) 107 | public isRegisteredWithGoogle?: boolean; 108 | } 109 | -------------------------------------------------------------------------------- /src/features/users/exceptions/user-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class UserNotFoundException extends NotFoundException { 4 | constructor(userId: number) { 5 | super(`User with id ${userId} not found`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/features/users/tests/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { getRepositoryToken } from '@nestjs/typeorm'; 3 | import { User } from '../entities/user.entity'; 4 | import { UsersService } from '../users.service'; 5 | 6 | describe('The UsersService', () => { 7 | let usersService: UsersService; 8 | let findOne: jest.Mock; 9 | 10 | beforeEach(async () => { 11 | findOne = jest.fn(); 12 | 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | UsersService, 16 | { 17 | provide: getRepositoryToken(User), 18 | useValue: { 19 | findOne, 20 | }, 21 | }, 22 | ], 23 | }).compile(); 24 | 25 | usersService = await module.get(UsersService); 26 | }); 27 | 28 | describe('when getting a user by email', () => { 29 | describe('and the user is matched', () => { 30 | let user: User; 31 | 32 | beforeEach(() => { 33 | user = new User(); 34 | findOne.mockReturnValue(Promise.resolve(user)); 35 | }); 36 | 37 | it('should return the user', async () => { 38 | const fetchedUser = await usersService.getByEmail('test@test.com'); 39 | expect(fetchedUser).toEqual(user); 40 | }); 41 | }); 42 | 43 | describe('and the user is not matched', () => { 44 | beforeEach(() => { 45 | findOne.mockReturnValue(undefined); 46 | }); 47 | 48 | it('should throw an error', async () => { 49 | await expect( 50 | usersService.getByEmail('test@test.com'), 51 | ).rejects.toThrow(); 52 | }); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/features/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Post, 9 | Req, 10 | Res, 11 | StreamableFile, 12 | UploadedFile, 13 | UseGuards, 14 | UseInterceptors, 15 | } from '@nestjs/common'; 16 | import { RequestWithUser } from '../../authentication/request-with-user.interface'; 17 | import { UsersService } from './users.service'; 18 | import { Express, Response, Request } from 'express'; 19 | import { FileInterceptor } from '@nestjs/platform-express'; 20 | import { JwtAuthenticationGuard } from '../../authentication/jwt-authentication.guard'; 21 | import { FindOneParams } from '../../utils/dto/find-one-params.dto'; 22 | import { 23 | ApiBody, 24 | ApiConsumes, 25 | ApiCreatedResponse, 26 | ApiNotFoundResponse, 27 | ApiOkResponse, 28 | ApiParam, 29 | ApiTags, 30 | ApiUnauthorizedResponse, 31 | } from '@nestjs/swagger'; 32 | import { FileUploadDto } from './dto/file-upload.dto'; 33 | import { User } from './entities/user.entity'; 34 | import { FileResponseDto } from './dto/file-response.dto'; 35 | import { PublicFile } from '../files/entities/public-file.entity'; 36 | import { PrivateFile } from '../files/entities/private-file.entity'; 37 | import { LocalFilesInterceptor } from '../local-files/local-files.interceptor'; 38 | 39 | @Controller('users') 40 | @ApiTags('users') 41 | export class UsersController { 42 | constructor(private readonly usersService: UsersService) {} 43 | 44 | @Get() 45 | @ApiOkResponse({ 46 | description: 'All the users have been fetched successfully!', 47 | type: [User], 48 | }) 49 | findAll() { 50 | return this.usersService.getAllUsers(); 51 | } 52 | 53 | @Get(':email') 54 | @ApiParam({ 55 | name: 'email', 56 | required: true, 57 | description: 'Should be a valid email for the user to fetch', 58 | type: String, 59 | }) 60 | @ApiOkResponse({ 61 | description: 'A user with the email has been fetched successfully!', 62 | type: User, 63 | }) 64 | @ApiNotFoundResponse({ 65 | description: 'A user with given email does not exist.', 66 | }) 67 | findOne(@Param('email') email: string) { 68 | return this.usersService.getByEmail(email); 69 | } 70 | 71 | @Get(':userId/avatar') 72 | async getAvatar( 73 | @Param('userId', ParseIntPipe) userId: number, 74 | @Res({ passthrough: true }) response: Response, 75 | @Req() request: Request, 76 | ) { 77 | const { file, fileMetadata } = await this.usersService.getAvatar(userId); 78 | 79 | const tag = `W/"file-id-${fileMetadata.id}"`; 80 | 81 | response.set({ 82 | 'Content-Disposition': `inline; filename="${fileMetadata.filename}"`, 83 | 'Content-Type': fileMetadata.mimetype, 84 | ETag: tag, 85 | }); 86 | 87 | if (request.headers['if-none-match'] === tag) { 88 | response.status(304); 89 | return; 90 | } 91 | 92 | return new StreamableFile(file); 93 | } 94 | 95 | @Post('avatar') 96 | @ApiConsumes('multipart/form-data') 97 | @ApiBody({ 98 | description: 'A new avatar for the user', 99 | type: FileUploadDto, 100 | }) 101 | @ApiCreatedResponse({ 102 | description: 'An avatar of the user has been added successfully!', 103 | type: PublicFile, 104 | }) 105 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 106 | @UseGuards(JwtAuthenticationGuard) 107 | @UseInterceptors( 108 | LocalFilesInterceptor({ 109 | fieldName: 'file', 110 | path: '/avatars', 111 | fileFilter: (request, file, callback) => { 112 | if (!file.mimetype.includes('image')) { 113 | return callback( 114 | new BadRequestException('Provide a valid image'), 115 | false, 116 | ); 117 | } 118 | callback(null, true); 119 | }, 120 | limits: { 121 | fileSize: Math.pow(1024, 2), // 1 MB 122 | }, 123 | }), 124 | ) 125 | addAvatar( 126 | @Req() request: RequestWithUser, 127 | @UploadedFile() file: Express.Multer.File, 128 | ) { 129 | return this.usersService.addAvatar(request.user.id, { 130 | path: file.path, 131 | filename: file.originalname, 132 | mimetype: file.mimetype, 133 | }); 134 | } 135 | 136 | @Delete('avatar') 137 | @ApiOkResponse({ 138 | description: 'Avatar for this user has been deleted successfully!', 139 | }) 140 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 141 | @UseGuards(JwtAuthenticationGuard) 142 | deleteAvatar(@Req() request: RequestWithUser) { 143 | return this.usersService.deleteAvatar(request.user.id); 144 | } 145 | 146 | @Post('avatar/amazonS3') 147 | @ApiConsumes('multipart/form-data') 148 | @ApiBody({ 149 | description: 'A new avatar for the user', 150 | type: FileUploadDto, 151 | }) 152 | @ApiCreatedResponse({ 153 | description: 'An avatar of the user has been added successfully!', 154 | type: PublicFile, 155 | }) 156 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 157 | @UseGuards(JwtAuthenticationGuard) 158 | @UseInterceptors(FileInterceptor('file')) 159 | addAvatarOnAmazonS3( 160 | @Req() request: RequestWithUser, 161 | @UploadedFile() file: Express.Multer.File, 162 | ) { 163 | return this.usersService.addAvatarUsingAmazonS3( 164 | request.user.id, 165 | file.buffer, 166 | file.originalname, 167 | ); 168 | } 169 | 170 | @Post('avatar/postgres') 171 | @ApiConsumes('multipart/form-data') 172 | @ApiBody({ 173 | description: 'A new avatar for the user', 174 | type: FileUploadDto, 175 | }) 176 | @ApiCreatedResponse({ 177 | description: 'An avatar of the user has been added successfully!', 178 | type: PublicFile, 179 | }) 180 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 181 | @UseGuards(JwtAuthenticationGuard) 182 | @UseInterceptors(FileInterceptor('file')) 183 | addAvatarOnPostgres( 184 | @Req() request: RequestWithUser, 185 | @UploadedFile() file: Express.Multer.File, 186 | ) { 187 | return this.usersService.addAvatarInPGsql( 188 | request.user.id, 189 | file.buffer, 190 | file.originalname, 191 | ); 192 | } 193 | 194 | @Get('files') 195 | @ApiOkResponse({ 196 | description: 197 | 'All the private files of the user have been fetched successfully!', 198 | type: [FileResponseDto], 199 | }) 200 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 201 | @UseGuards(JwtAuthenticationGuard) 202 | async getAllPrivateFiles(@Req() request: RequestWithUser) { 203 | return this.usersService.getAllPrivateFiles(request.user.id); 204 | } 205 | 206 | @Get('files/:id') 207 | @ApiParam({ 208 | name: 'id', 209 | required: true, 210 | description: 'Should be an id of a file that exists in the database', 211 | type: String, 212 | }) 213 | @ApiOkResponse({ 214 | description: 'A private file of the user has been fetched successfully!', 215 | type: FileResponseDto, 216 | }) 217 | @ApiNotFoundResponse({ 218 | description: 'A file with given id does not exist.', 219 | }) 220 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 221 | @UseGuards(JwtAuthenticationGuard) 222 | async getPrivateFile( 223 | @Req() request: RequestWithUser, 224 | @Param() { id }: FindOneParams, 225 | @Res() response: Response, 226 | ) { 227 | const file = await this.usersService.getPrivateFile( 228 | request.user.id, 229 | Number(id), 230 | ); 231 | file.stream.pipe(response); 232 | } 233 | 234 | @Post('files') 235 | @ApiConsumes('multipart/form-data') 236 | @ApiBody({ 237 | description: 'Upload a new private file for the logged in user', 238 | type: FileUploadDto, 239 | }) 240 | @ApiCreatedResponse({ 241 | description: 'A private file for this user has been uploaded successfully!', 242 | type: PrivateFile, 243 | }) 244 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 245 | @UseGuards(JwtAuthenticationGuard) 246 | @UseInterceptors(FileInterceptor('file')) 247 | addPrivateFile( 248 | @Req() request: RequestWithUser, 249 | @UploadedFile() file: Express.Multer.File, 250 | ) { 251 | return this.usersService.addPrivateFile( 252 | request.user.id, 253 | file.buffer, 254 | file.originalname, 255 | ); 256 | } 257 | 258 | @Delete('files/:id') 259 | @ApiParam({ 260 | name: 'id', 261 | required: true, 262 | description: 'Should be an id of a product that exists in the database', 263 | type: String, 264 | }) 265 | @ApiOkResponse({ 266 | description: 'A private file of the user has been deleted successfully!', 267 | }) 268 | @ApiNotFoundResponse({ 269 | description: 'A file with given id does not exist.', 270 | }) 271 | @ApiUnauthorizedResponse({ description: 'Unauthorized.' }) 272 | @UseGuards(JwtAuthenticationGuard) 273 | deletePrivateFile( 274 | @Req() request: RequestWithUser, 275 | @Param() { id }: FindOneParams, 276 | ) { 277 | return this.usersService.deletePrivateFile(request.user.id, Number(id)); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/features/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './entities/user.entity'; 6 | import { FilesModule } from '../files/files.module'; 7 | import { DatabaseFilesModule } from '../database-files/database-files.module'; 8 | import { LocalFilesModule } from '../local-files/local-files.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([User]), 13 | FilesModule, 14 | DatabaseFilesModule, 15 | LocalFilesModule, 16 | ], 17 | controllers: [UsersController], 18 | providers: [UsersService], 19 | exports: [UsersService], 20 | }) 21 | export class UsersModule {} 22 | -------------------------------------------------------------------------------- /src/features/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpException, 3 | HttpStatus, 4 | Injectable, 5 | InternalServerErrorException, 6 | NotFoundException, 7 | UnauthorizedException, 8 | } from '@nestjs/common'; 9 | import { InjectRepository } from '@nestjs/typeorm'; 10 | import { DataSource, Repository } from 'typeorm'; 11 | import { FileNotFoundException } from '../files/exceptions/file-not-found.exception'; 12 | import { FilesService } from '../files/files.service'; 13 | import { CreateUserDto } from './dto/create-user.dto'; 14 | import { User } from './entities/user.entity'; 15 | import { UserNotFoundException } from './exceptions/user-not-found.exception'; 16 | import * as bcrypt from 'bcrypt'; 17 | import { DatabaseFilesService } from '../database-files/database-files.service'; 18 | import { LocalFileDto } from '../local-files/dto/local-file.dto'; 19 | import { LocalFilesService } from '../local-files/local-files.service'; 20 | import { join } from 'path'; 21 | import * as util from 'util'; 22 | import * as filesystem from 'fs'; 23 | 24 | @Injectable() 25 | export class UsersService { 26 | constructor( 27 | @InjectRepository(User) private usersRepository: Repository, 28 | private readonly filesService: FilesService, 29 | private readonly databaseFilesService: DatabaseFilesService, 30 | private readonly localFilesService: LocalFilesService, 31 | private dataSource: DataSource, 32 | ) {} 33 | 34 | async setCurrentRefreshToken(refreshToken: string, userId: number) { 35 | const currentHashedRefreshToken = await bcrypt.hash(refreshToken, 10); 36 | await this.usersRepository.update(userId, { 37 | currentHashedRefreshToken, 38 | }); 39 | } 40 | 41 | async getUserIfRefreshTokenMatches(refreshToken: string, userId: number) { 42 | const user = await this.getById(userId); 43 | const isRefreshTokenMatching = await bcrypt.compare( 44 | refreshToken, 45 | user.currentHashedRefreshToken, 46 | ); 47 | if (isRefreshTokenMatching) { 48 | return user; 49 | } 50 | } 51 | 52 | async removeRefreshToken(userId: number) { 53 | return this.usersRepository.update(userId, { 54 | currentHashedRefreshToken: null, 55 | }); 56 | } 57 | 58 | async setTwoFactorAuthenticationSecret(secret: string, userId: number) { 59 | return this.usersRepository.update(userId, { 60 | twoFactorAuthenticationSecret: secret, 61 | }); 62 | } 63 | 64 | async turnOnTwoFactorAuthentication(userId: number) { 65 | return this.usersRepository.update(userId, { 66 | isTwoFactorAuthenticationEnabled: true, 67 | }); 68 | } 69 | 70 | async turnOffTwoFactorAuthentication(userId: number) { 71 | return this.usersRepository.update(userId, { 72 | isTwoFactorAuthenticationEnabled: false, 73 | }); 74 | } 75 | 76 | async markEmailAsConfirmed(email: string) { 77 | return this.usersRepository.update( 78 | { email }, 79 | { 80 | isEmailConfirmed: true, 81 | }, 82 | ); 83 | } 84 | 85 | async createWithGoogle(email: string, name: string) { 86 | const newUser = await this.usersRepository.create({ 87 | email, 88 | name, 89 | isRegisteredWithGoogle: true, 90 | }); 91 | 92 | await this.usersRepository.save(newUser); 93 | return newUser; 94 | } 95 | 96 | async getAllUsers() { 97 | const users = await this.usersRepository.find(); 98 | return users; 99 | } 100 | 101 | async getById(id: number) { 102 | const user = await this.usersRepository.findOne({ 103 | where: { 104 | id, 105 | }, 106 | }); 107 | if (user) { 108 | return user; 109 | } 110 | throw new UserNotFoundException(id); 111 | } 112 | 113 | async getByEmail(email: string) { 114 | const user = await this.usersRepository.findOne({ 115 | where: { 116 | email, 117 | }, 118 | }); 119 | if (user) { 120 | return user; 121 | } 122 | throw new HttpException( 123 | 'User with this email does not exist', 124 | HttpStatus.NOT_FOUND, 125 | ); 126 | } 127 | 128 | async create(createUserDto: CreateUserDto) { 129 | const newUser = await this.usersRepository.create(createUserDto); 130 | await this.usersRepository.save(newUser); 131 | return newUser; 132 | } 133 | 134 | // Upload files to Amazon S3 135 | async addAvatarUsingAmazonS3( 136 | userId: number, 137 | imageBuffer: Buffer, 138 | filename: string, 139 | ) { 140 | const avatar = await this.filesService.uploadPublicFile( 141 | imageBuffer, 142 | filename, 143 | ); 144 | const user = await this.getById(userId); 145 | await this.usersRepository.update(userId, { 146 | ...user, 147 | avatar, 148 | }); 149 | return avatar; 150 | } 151 | 152 | // Upload files to Postgres database directly 153 | async addAvatarInPGsql( 154 | userId: number, 155 | imageBuffer: Buffer, 156 | filename: string, 157 | ) { 158 | const queryRunner = this.dataSource.createQueryRunner(); 159 | 160 | await queryRunner.connect(); 161 | await queryRunner.startTransaction(); 162 | 163 | try { 164 | const user = await queryRunner.manager.findOne(User, { 165 | where: { 166 | id: userId, 167 | }, 168 | }); 169 | const currentAvatarId = user.avatarId; 170 | const avatar = 171 | await this.databaseFilesService.uploadDatabaseFileWithQueryRunner( 172 | imageBuffer, 173 | filename, 174 | queryRunner, 175 | ); 176 | 177 | await queryRunner.manager.update(User, userId, { 178 | avatarId: avatar.id, 179 | }); 180 | 181 | if (currentAvatarId) { 182 | await this.databaseFilesService.deleteFileWithQueryRunner( 183 | currentAvatarId, 184 | queryRunner, 185 | ); 186 | } 187 | 188 | await queryRunner.commitTransaction(); 189 | return avatar; 190 | } catch { 191 | await queryRunner.rollbackTransaction(); 192 | throw new InternalServerErrorException(); 193 | } finally { 194 | await queryRunner.release(); 195 | } 196 | } 197 | 198 | async getAvatar(userId: number) { 199 | const user = await this.getById(userId); 200 | const fileId = user.avatarId; 201 | if (!fileId) { 202 | throw new NotFoundException(); 203 | } 204 | const fileMetadata = await this.localFilesService.getFileById( 205 | user.avatarId, 206 | ); 207 | 208 | const pathOnDisk = join(process.cwd(), fileMetadata.path); 209 | 210 | const file = await util.promisify(filesystem.readFile)(pathOnDisk); 211 | 212 | return { 213 | file, 214 | fileMetadata, 215 | }; 216 | } 217 | 218 | async addAvatar(userId: number, fileData: LocalFileDto) { 219 | const avatar = await this.localFilesService.saveLocalFileData(fileData); 220 | await this.usersRepository.update(userId, { 221 | avatarId: avatar.id, 222 | }); 223 | } 224 | 225 | async deleteAvatar(userId: number) { 226 | const queryRunner = this.dataSource.createQueryRunner(); 227 | 228 | const user = await this.getById(userId); 229 | const fileId = user.avatarId; 230 | if (fileId) { 231 | await queryRunner.connect(); 232 | await queryRunner.startTransaction(); 233 | 234 | try { 235 | await queryRunner.manager.update(User, userId, { 236 | ...user, 237 | avatarId: null, 238 | }); 239 | await this.localFilesService.deleteLocalFileWithQueryRunner( 240 | fileId, 241 | queryRunner, 242 | ); 243 | await queryRunner.commitTransaction(); 244 | } catch (error) { 245 | await queryRunner.rollbackTransaction(); 246 | throw new InternalServerErrorException(); 247 | } finally { 248 | await queryRunner.release(); 249 | } 250 | } else { 251 | throw new FileNotFoundException(fileId); 252 | } 253 | } 254 | 255 | async getPrivateFile(userId: number, fileId: number) { 256 | const file = await this.filesService.getPrivateFile(fileId); 257 | if (file.info.owner.id === userId) { 258 | return file; 259 | } 260 | throw new UnauthorizedException(); 261 | } 262 | 263 | async getAllPrivateFiles(userId: number) { 264 | const userWithFiles = await this.usersRepository.findOne({ 265 | where: { 266 | id: userId, 267 | }, 268 | relations: ['files'], 269 | }); 270 | if (userWithFiles) { 271 | return Promise.all( 272 | userWithFiles.files.map(async (file) => { 273 | const url = await this.filesService.generatePresignedUrl(file.key); 274 | return { 275 | ...file, 276 | url, 277 | }; 278 | }), 279 | ); 280 | } 281 | throw new UserNotFoundException(userId); 282 | } 283 | 284 | async addPrivateFile(userId: number, imageBuffer: Buffer, filename: string) { 285 | return this.filesService.uploadPrivateFile(imageBuffer, userId, filename); 286 | } 287 | 288 | async deletePrivateFile(userId: number, fileId: number) { 289 | await this.filesService.deletePrivateFile(fileId, userId); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { 3 | DiskHealthIndicator, 4 | HealthCheck, 5 | HealthCheckService, 6 | MemoryHealthIndicator, 7 | TypeOrmHealthIndicator, 8 | } from '@nestjs/terminus'; 9 | 10 | @Controller('health') 11 | export class HealthController { 12 | constructor( 13 | private healthCheckService: HealthCheckService, 14 | private typeOrmHealthIndicator: TypeOrmHealthIndicator, 15 | private memoryHealthIndicator: MemoryHealthIndicator, 16 | private diskHealthIndicator: DiskHealthIndicator, 17 | ) {} 18 | 19 | @Get() 20 | @HealthCheck() 21 | check() { 22 | return this.healthCheckService.check([ 23 | () => this.typeOrmHealthIndicator.pingCheck('database'), 24 | // the process should not use more than 300MB memory 25 | () => 26 | this.memoryHealthIndicator.checkHeap('memory heap', 300 * 1024 * 1024), 27 | // The process should not have more than 300MB RSS memory allocated 28 | () => 29 | this.memoryHealthIndicator.checkRSS('memory RSS', 300 * 1024 * 1024), 30 | // the used disk storage should not exceed the 50% of the available space 31 | () => 32 | this.diskHealthIndicator.checkStorage('disk health', { 33 | thresholdPercent: 0.5, 34 | path: '/', 35 | }), 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TerminusModule } from '@nestjs/terminus'; 3 | import { HealthController } from './health.controller'; 4 | 5 | @Module({ 6 | imports: [TerminusModule], 7 | controllers: [HealthController], 8 | }) 9 | export class HealthModule {} 10 | -------------------------------------------------------------------------------- /src/logger/custom-logger.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ConsoleLogger } from '@nestjs/common'; 2 | import { ConsoleLoggerOptions } from '@nestjs/common/services/console-logger.service'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { getLogLevels } from '../utils/get-log-levels'; 5 | import { LogsService } from './logs.service'; 6 | 7 | @Injectable() 8 | export class CustomLogger extends ConsoleLogger { 9 | private readonly logsService: LogsService; 10 | 11 | constructor( 12 | context: string, 13 | options: ConsoleLoggerOptions, 14 | configService: ConfigService, 15 | logsService: LogsService, 16 | ) { 17 | const environment = configService.get('NODE_ENV'); 18 | 19 | super(context, { 20 | ...options, 21 | logLevels: getLogLevels(environment === 'production'), 22 | }); 23 | 24 | this.logsService = logsService; 25 | } 26 | 27 | log(message: string, context?: string) { 28 | super.log.apply(this, [message, context]); 29 | 30 | this.logsService.createLog({ 31 | message, 32 | context, 33 | level: 'log', 34 | }); 35 | } 36 | 37 | error(message: string, context?: string, stack?: string) { 38 | super.error.apply(this, [message, context, stack]); 39 | 40 | this.logsService.createLog({ 41 | message, 42 | context, 43 | level: 'error', 44 | }); 45 | } 46 | 47 | warn(message: string, context?: string) { 48 | super.warn.apply(this, [message, context]); 49 | 50 | this.logsService.createLog({ 51 | message, 52 | context, 53 | level: 'error', 54 | }); 55 | } 56 | 57 | debug(message: string, context?: string) { 58 | super.debug.apply(this, [message, context]); 59 | 60 | this.logsService.createLog({ 61 | message, 62 | context, 63 | level: 'error', 64 | }); 65 | } 66 | 67 | verbose(message: string, context?: string) { 68 | super.debug.apply(this, [message, context]); 69 | 70 | this.logsService.createLog({ 71 | message, 72 | context, 73 | level: 'error', 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/logger/dto/create-log.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class CreateLogDto { 4 | @IsString() 5 | context: string; 6 | 7 | @IsString() 8 | message: string; 9 | 10 | @IsString() 11 | level: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/logger/entities/log.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | } from 'typeorm'; 7 | 8 | @Entity() 9 | export class Log { 10 | @PrimaryGeneratedColumn() 11 | public id: number; 12 | 13 | @Column() 14 | public context: string; 15 | 16 | @Column() 17 | public message: string; 18 | 19 | @Column() 20 | public level: string; 21 | 22 | @CreateDateColumn() 23 | creationDate: Date; 24 | } 25 | -------------------------------------------------------------------------------- /src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { CustomLogger } from './custom-logger'; 5 | import { Log } from './entities/log.entity'; 6 | import { LogsService } from './logs.service'; 7 | 8 | @Module({ 9 | imports: [ConfigModule, TypeOrmModule.forFeature([Log])], 10 | providers: [CustomLogger, LogsService], 11 | exports: [CustomLogger], 12 | }) 13 | export class LoggerModule {} 14 | -------------------------------------------------------------------------------- /src/logger/logs.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateLogDto } from './dto/create-log.dto'; 5 | import { Log } from './entities/log.entity'; 6 | 7 | @Injectable() 8 | export class LogsService { 9 | constructor( 10 | @InjectRepository(Log) 11 | private logsRepository: Repository, 12 | ) {} 13 | 14 | async createLog(log: CreateLogDto) { 15 | const newLog = await this.logsRepository.create(log); 16 | await this.logsRepository.save(newLog, { 17 | data: { 18 | isCreatingLogs: true, 19 | }, 20 | }); 21 | return newLog; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { VersioningType } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 5 | import helmet from 'helmet'; 6 | import * as cookieParser from 'cookie-parser'; 7 | import { AppModule } from './app.module'; 8 | import { CustomLogger } from './logger/custom-logger'; 9 | 10 | async function bootstrap() { 11 | const app = await NestFactory.create(AppModule, { 12 | bufferLogs: true, 13 | }); 14 | 15 | const configService = app.get(ConfigService); 16 | 17 | app.use(helmet()); 18 | 19 | app.useLogger(app.get(CustomLogger)); 20 | 21 | app.setGlobalPrefix('/api'); 22 | 23 | app.enableVersioning({ 24 | type: VersioningType.URI, 25 | defaultVersion: '1', 26 | }); 27 | 28 | app.use(cookieParser()); 29 | 30 | app.enableCors({ 31 | origin: configService.get('app.frontendURL'), 32 | credentials: true, 33 | }); 34 | 35 | const config = new DocumentBuilder() 36 | .setTitle('NestJS Starter Template') 37 | .setDescription('This is a starter template where everything is set up.') 38 | .setVersion('1.0') 39 | .build(); 40 | const document = SwaggerModule.createDocument(app, config); 41 | SwaggerModule.setup('swagger/v1', app, document); 42 | 43 | await app.listen(configService.get('app.port')); 44 | } 45 | 46 | bootstrap(); 47 | -------------------------------------------------------------------------------- /src/utils/dto/find-one-params.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumberString } from 'class-validator'; 2 | 3 | export class FindOneParams { 4 | @IsNumberString() 5 | id: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/dto/object-with-id.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | 3 | export class ObjectWithIdDto { 4 | @IsNumber() 5 | id: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/dto/paginated-result.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, ValidateNested } from 'class-validator'; 2 | 3 | export class PaginatedResultDto { 4 | @ValidateNested() 5 | data: T[]; 6 | 7 | @IsNumber() 8 | page: number; 9 | 10 | @IsNumber() 11 | limit: number; 12 | 13 | @IsNumber() 14 | totalCount: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/dto/pagination-with-start-id.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsOptional, Min } from 'class-validator'; 3 | 4 | export class PaginationWithStartIdDto { 5 | @IsOptional() 6 | @Type(() => Number) 7 | @IsNumber() 8 | @Min(1) 9 | startId?: number; 10 | 11 | @IsOptional() 12 | @Type(() => Number) 13 | @IsNumber() 14 | @Min(0) 15 | offset?: number; 16 | 17 | @IsOptional() 18 | @Type(() => Number) 19 | @IsNumber() 20 | @Min(1) 21 | limit?: number; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/dto/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsOptional, Min } from 'class-validator'; 3 | 4 | export class PaginationDto { 5 | @IsOptional() 6 | @Type(() => Number) 7 | @IsNumber() 8 | @Min(1) 9 | page?: number; 10 | 11 | @IsOptional() 12 | @Type(() => Number) 13 | @IsNumber() 14 | @Min(1) 15 | limit?: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/exceptions-logger.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch } from '@nestjs/common'; 2 | import { BaseExceptionFilter } from '@nestjs/core'; 3 | 4 | @Catch() 5 | export class ExceptionsLoggerFilter extends BaseExceptionFilter { 6 | catch(exception: unknown, host: ArgumentsHost) { 7 | console.log('Exception thrown', exception); 8 | super.catch(exception, host); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/exclude-null.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { map } from 'rxjs/operators'; 9 | import { recursivelyStripNullValues } from './recursively-strip-null-values'; 10 | 11 | @Injectable() 12 | export class ExcludeNullInterceptor implements NestInterceptor { 13 | intercept(context: ExecutionContext, next: CallHandler): Observable { 14 | return next 15 | .handle() 16 | .pipe(map((value) => recursivelyStripNullValues(value))); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/get-log-levels.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from '@nestjs/common/services/logger.service'; 2 | 3 | export function getLogLevels(isProduction: boolean): LogLevel[] { 4 | if (isProduction) { 5 | return ['log', 'warn', 'error']; 6 | } 7 | return ['error', 'warn', 'log', 'verbose', 'debug']; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/get-pagination-props.ts: -------------------------------------------------------------------------------- 1 | import { PaginationDto } from './dto/pagination.dto'; 2 | 3 | export const getPaginationProps = (paginationDto: PaginationDto) => { 4 | if (Object.keys(paginationDto).length === 0) { 5 | return { 6 | page: 1, 7 | limit: 20, 8 | skippedItems: 0, 9 | }; 10 | } 11 | 12 | const page = Number(paginationDto.page); 13 | const limit = Number(paginationDto.limit); 14 | const skippedItems = (page - 1) * limit; 15 | 16 | return { 17 | page, 18 | limit: limit > 20 ? 20 : limit, 19 | skippedItems, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/http-cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CACHE_KEY_METADATA, 3 | CacheInterceptor, 4 | ExecutionContext, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class HttpCacheInterceptor extends CacheInterceptor { 10 | trackBy(context: ExecutionContext): string | undefined { 11 | const cacheKey = this.reflector.get( 12 | CACHE_KEY_METADATA, 13 | context.getHandler(), 14 | ); 15 | 16 | if (cacheKey) { 17 | const request = context.switchToHttp().getRequest(); 18 | return `${cacheKey}-${request._parsedUrl.query}`; 19 | } 20 | 21 | return super.trackBy(context); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/logs.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | 4 | @Injectable() 5 | export class LogsMiddleware implements NestMiddleware { 6 | private readonly logger = new Logger('HTTP'); 7 | 8 | use(request: Request, response: Response, next: NextFunction) { 9 | response.on('finish', () => { 10 | const { method, originalUrl } = request; 11 | const { statusCode, statusMessage } = response; 12 | 13 | const message = `${method} ${originalUrl} ${statusCode} ${statusMessage}`; 14 | 15 | if (statusCode >= 500) { 16 | return this.logger.error(message); 17 | } 18 | 19 | if (statusCode >= 400) { 20 | return this.logger.warn(message); 21 | } 22 | 23 | return this.logger.log(message); 24 | }); 25 | 26 | next(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/mocks/config.service.ts: -------------------------------------------------------------------------------- 1 | export const mockedConfigService = { 2 | get(key: string) { 3 | switch (key) { 4 | case 'JWT_EXPIRATION_TIME': 5 | return '3600'; 6 | } 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/mocks/jwt.service.ts: -------------------------------------------------------------------------------- 1 | export const mockedJwtService = { 2 | sign: () => '', 3 | }; 4 | -------------------------------------------------------------------------------- /src/utils/recursively-strip-null-values.ts: -------------------------------------------------------------------------------- 1 | export function recursivelyStripNullValues(value: unknown): unknown { 2 | if (Array.isArray(value)) { 3 | return value.map(recursivelyStripNullValues); 4 | } 5 | if (value !== null && typeof value === 'object') { 6 | return Object.fromEntries( 7 | Object.entries(value).map(([key, value]) => [ 8 | key, 9 | recursivelyStripNullValues(value), 10 | ]), 11 | ); 12 | } 13 | if (value !== null) { 14 | return value; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "alwaysStrict": true, 5 | "module": "commonjs", 6 | "declaration": true, 7 | "removeComments": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "target": "es2019", 12 | "sourceMap": true, 13 | "outDir": "./dist", 14 | "baseUrl": "./", 15 | "incremental": true, 16 | "skipLibCheck": true, 17 | "strictNullChecks": false, 18 | "noImplicitAny": true, 19 | "strictBindCallApply": false, 20 | "forceConsistentCasingInFileNames": false, 21 | "noFallthroughCasesInSwitch": false, 22 | }, 23 | "exclude": [ 24 | "node_modules", 25 | "dist", 26 | "documentation", 27 | "uploadedFiles" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------