├── .dockerignore ├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── codecov.yml ├── docker-compose-dev.yml ├── docker-compose-prod.yml ├── docker-compose-test.yml ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── decorators │ │ ├── roles.decorator.ts │ │ └── user.decorator.ts │ ├── dto │ │ ├── login.dto.ts │ │ └── register.dto.ts │ ├── guards │ │ ├── local-auth.guard.ts │ │ ├── roles.guard.ts │ │ └── session-auth.guard.ts │ ├── local.serializer.ts │ └── local.strategy.ts ├── carts │ ├── carts.controller.spec.ts │ ├── carts.controller.ts │ ├── carts.module.ts │ ├── carts.service.spec.ts │ ├── carts.service.ts │ ├── dto │ │ ├── cart-item.dto.ts │ │ └── cart.dto.ts │ └── models │ │ ├── cart-item.entity.ts │ │ └── cart.entity.ts ├── catalog │ ├── attribute-types │ │ ├── attribute-types.controller.spec.ts │ │ ├── attribute-types.controller.ts │ │ ├── attribute-types.exporter.ts │ │ ├── attribute-types.importer.ts │ │ ├── attribute-types.module.ts │ │ ├── attribute-types.service.spec.ts │ │ ├── attribute-types.service.ts │ │ ├── dto │ │ │ └── attribute-type.dto.ts │ │ └── models │ │ │ ├── attribute-type.entity.ts │ │ │ └── attribute-value-type.enum.ts │ ├── catalog.module.ts │ ├── categories │ │ ├── categories.controller.spec.ts │ │ ├── categories.controller.ts │ │ ├── categories.exporter.ts │ │ ├── categories.importer.ts │ │ ├── categories.module.ts │ │ ├── categories.service.spec.ts │ │ ├── categories.service.ts │ │ ├── dto │ │ │ ├── category-create.dto.ts │ │ │ ├── category-group.dto.ts │ │ │ └── category-update.dto.ts │ │ └── models │ │ │ ├── category-group.entity.ts │ │ │ └── category.entity.ts │ ├── product-ratings │ │ ├── dto │ │ │ └── product-rating.dto.ts │ │ ├── models │ │ │ └── product-rating.entity.ts │ │ ├── product-rating-photos │ │ │ ├── models │ │ │ │ └── product-rating-photo.entity.ts │ │ │ ├── product-rating-photos.controller.spec.ts │ │ │ ├── product-rating-photos.controller.ts │ │ │ ├── product-rating-photos.module.ts │ │ │ ├── product-rating-photos.service.spec.ts │ │ │ └── product-rating-photos.service.ts │ │ ├── product-ratings.controller.spec.ts │ │ ├── product-ratings.controller.ts │ │ ├── product-ratings.module.ts │ │ ├── product-ratings.service.spec.ts │ │ └── product-ratings.service.ts │ └── products │ │ ├── dto │ │ ├── attribute.dto.ts │ │ ├── product-create.dto.ts │ │ └── product-update.dto.ts │ │ ├── models │ │ ├── attribute.entity.ts │ │ └── product.entity.ts │ │ ├── product-photos │ │ ├── models │ │ │ └── product-photo.entity.ts │ │ ├── product-photos.controller.spec.ts │ │ ├── product-photos.controller.ts │ │ ├── product-photos.exporter.ts │ │ ├── product-photos.importer.ts │ │ ├── product-photos.module.ts │ │ ├── product-photos.service.spec.ts │ │ └── product-photos.service.ts │ │ ├── products.controller.spec.ts │ │ ├── products.controller.ts │ │ ├── products.exporter.ts │ │ ├── products.importer.ts │ │ ├── products.module.ts │ │ ├── products.service.spec.ts │ │ └── products.service.ts ├── config │ ├── configuration.schema.ts │ └── configuration.ts ├── errors │ ├── conflict.error.ts │ ├── generic.error.ts │ ├── not-found.error.ts │ ├── not-related.error.ts │ ├── parse.error.ts │ ├── service-error.interceptor.ts │ ├── service-error.ts │ └── type-check.error.ts ├── import-export │ ├── data-type.utils.ts │ ├── dto │ │ ├── export.dto.ts │ │ └── import.dto.ts │ ├── export.controller.spec.ts │ ├── export.controller.ts │ ├── export.service.spec.ts │ ├── export.service.ts │ ├── import-export.module.ts │ ├── import.controller.spec.ts │ ├── import.controller.ts │ ├── import.service.spec.ts │ ├── import.service.ts │ ├── json-serializer.service.ts │ ├── models │ │ ├── collection.type.ts │ │ ├── data-type-dependencies.data.ts │ │ ├── data-type.enum.ts │ │ ├── exporter.interface.ts │ │ ├── file-serializer.interface.ts │ │ ├── id-map.type.ts │ │ ├── import-status.interface.ts │ │ └── importer.interface.ts │ └── zip-serializer.service.ts ├── local-files │ ├── local-files.module.ts │ ├── local-files.service.ts │ └── models │ │ ├── file-body.schema.ts │ │ ├── file-response.schema.ts │ │ └── photo.entity.ts ├── main.ts ├── pages │ ├── dto │ │ ├── page-create.dto.ts │ │ ├── page-group.dto.ts │ │ └── page-update.dto.ts │ ├── models │ │ ├── page-group.entity.ts │ │ └── page.entity.ts │ ├── pages.controller.spec.ts │ ├── pages.controller.ts │ ├── pages.exporter.ts │ ├── pages.importer.ts │ ├── pages.module.ts │ ├── pages.service.spec.ts │ └── pages.service.ts ├── redis │ ├── index.ts │ ├── redis.constants.ts │ └── redis.module.ts ├── sales │ ├── delivery-methods │ │ ├── delivery-methods.controller.spec.ts │ │ ├── delivery-methods.controller.ts │ │ ├── delivery-methods.exporter.ts │ │ ├── delivery-methods.importer.ts │ │ ├── delivery-methods.module.ts │ │ ├── delivery-methods.service.spec.ts │ │ ├── delivery-methods.service.ts │ │ ├── dto │ │ │ └── delivery-method.dto.ts │ │ └── models │ │ │ └── delivery-method.entity.ts │ ├── orders │ │ ├── dto │ │ │ ├── order-create.dto.ts │ │ │ ├── order-delivery.dto.ts │ │ │ ├── order-item.dto.ts │ │ │ ├── order-payment.dto.ts │ │ │ └── order-update.dto.ts │ │ ├── models │ │ │ ├── order-delivery.entity.ts │ │ │ ├── order-item.entity.ts │ │ │ ├── order-payment.entity.ts │ │ │ ├── order-status.enum.ts │ │ │ └── order.entity.ts │ │ ├── orders.controller.spec.ts │ │ ├── orders.controller.ts │ │ ├── orders.exporter.ts │ │ ├── orders.importer.ts │ │ ├── orders.module.ts │ │ ├── orders.service.spec.ts │ │ ├── orders.service.ts │ │ └── orders.subscriber.ts │ ├── payment-methods │ │ ├── dto │ │ │ └── payment-method.dto.ts │ │ ├── models │ │ │ └── payment-method.entity.ts │ │ ├── payment-methods.controller.spec.ts │ │ ├── payment-methods.controller.ts │ │ ├── payment-methods.exporter.ts │ │ ├── payment-methods.importer.ts │ │ ├── payment-methods.module.ts │ │ ├── payment-methods.service.spec.ts │ │ └── payment-methods.service.ts │ ├── returns │ │ ├── dto │ │ │ ├── return-create.dto.ts │ │ │ └── return-update.dto.ts │ │ ├── models │ │ │ ├── return-status.enum.ts │ │ │ └── return.entity.ts │ │ ├── returns.controller.spec.ts │ │ ├── returns.controller.ts │ │ ├── returns.exporter.ts │ │ ├── returns.importer.ts │ │ ├── returns.module.ts │ │ ├── returns.service.spec.ts │ │ ├── returns.service.ts │ │ └── returns.subscriber.ts │ └── sales.module.ts ├── settings │ ├── builtin-settings.data.ts │ ├── dto │ │ ├── setting-create.dto.ts │ │ └── setting-update.dto.ts │ ├── guards │ │ ├── features-enabled.guard.ts │ │ └── features.decorator.ts │ ├── models │ │ ├── setting-type.enum.ts │ │ └── setting.entity.ts │ ├── settings.controller.spec.ts │ ├── settings.controller.ts │ ├── settings.exporter.ts │ ├── settings.importer.ts │ ├── settings.module.ts │ ├── settings.service.spec.ts │ └── settings.service.ts ├── users │ ├── dto │ │ └── user-update.dto.ts │ ├── models │ │ ├── role.enum.ts │ │ └── user.entity.ts │ ├── users.controller.spec.ts │ ├── users.controller.ts │ ├── users.exporter.ts │ ├── users.importer.ts │ ├── users.module.ts │ ├── users.service.spec.ts │ └── users.service.ts └── wishlists │ ├── dto │ ├── wishlist-create.dto.ts │ └── wishlist-update.dto.ts │ ├── models │ └── wishlist.entity.ts │ ├── wishlists.controller.spec.ts │ ├── wishlists.controller.ts │ ├── wishlists.exporter.ts │ ├── wishlists.importer.ts │ ├── wishlists.module.ts │ ├── wishlists.service.spec.ts │ └── wishlists.service.ts ├── test ├── assets │ ├── export-bad.json │ ├── export.json │ ├── export.tar.gz │ ├── test.jpg │ ├── test.png │ └── test.txt ├── auth.e2e-spec.ts ├── carts.e2e-spec.ts ├── catalog │ ├── attribute-types.e2e-spec.ts │ ├── categories.e2e-spec.ts │ ├── product-photos.e2e-spec.ts │ ├── product-rating-photos.e2e-spec.ts │ ├── product-ratings.e2e-spec.ts │ └── products.e2e-spec.ts ├── import-export-rbac.e2e-spec.ts ├── import-export.e2e-spec.ts ├── jest-e2e.json ├── pages.e2e-spec.ts ├── sales │ ├── delivery-methods.e2e-spec.ts │ ├── orders.e2e-spec.ts │ ├── payment-methods.e2e-spec.ts │ └── returns.e2e-spec.ts ├── settings.e2e-spec.ts ├── users.e2e-spec.ts ├── utils │ ├── dto-generator │ │ ├── dto-generator.module.ts │ │ └── dto-generator.service.ts │ ├── generate-file-metadata.ts │ ├── parse-endpoint.ts │ ├── repository-mock │ │ ├── repository-mock.module.ts │ │ └── repository-mock.service.ts │ ├── setup-rbac-tests.ts │ ├── setup.ts │ └── test-users │ │ ├── test-users.module.ts │ │ └── test-users.service.ts └── wishlists.e2e-spec.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | test 5 | .git 6 | .idea 7 | uploads 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | 'prettier/prettier': [ 21 | 'error', 22 | { 23 | endOfLine: 'auto', 24 | }, 25 | ], 26 | '@typescript-eslint/interface-name-prefix': 'off', 27 | '@typescript-eslint/explicit-function-return-type': 'off', 28 | '@typescript-eslint/explicit-module-boundary-types': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | 'no-console': 1, 31 | 'no-inline-comments': 1, 32 | 'max-lines': [1, 150], 33 | '@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }], 34 | }, 35 | overrides: [ 36 | { 37 | files: ['*.spec.ts', '*.e2e-spec.ts'], 38 | rules: { 39 | 'max-lines': 'off', 40 | '@typescript-eslint/no-unused-vars': [ 41 | 'warn', 42 | { ignoreRestSiblings: true }, 43 | ], 44 | }, 45 | }, 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ master ] 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | cache: 'npm' 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Run tests 20 | run: npm run test 21 | 22 | e2e-tests: 23 | runs-on: ubuntu-latest 24 | services: 25 | postgres: 26 | image: postgres:14 27 | env: 28 | POSTGRES_USER: postgres 29 | POSTGRES_PASSWORD: postgres 30 | POSTGRES_DB: ecommerce-platform-test 31 | options: >- 32 | --health-cmd pg_isready 33 | --health-interval 10s 34 | --health-timeout 5s 35 | --health-retries 5 36 | ports: 37 | - 5432:5432 38 | redis: 39 | image: redis:7 40 | options: >- 41 | --health-cmd "redis-cli ping" 42 | --health-interval 10s 43 | --health-timeout 5s 44 | --health-retries 5 45 | ports: 46 | - 6379:6379 47 | steps: 48 | - uses: actions/checkout@v3 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version: 16 52 | cache: 'npm' 53 | - name: Install dependencies 54 | run: npm ci 55 | - name: Run e2e tests 56 | run: npm run test:e2e:cov 57 | env: 58 | POSTGRES_HOST: localhost 59 | POSTGRES_USER: postgres 60 | POSTGRES_PASSWORD: postgres 61 | POSTGRES_DB: ecommerce-platform-test 62 | REDIS_HOST: localhost 63 | NODE_ENV: test 64 | ADMIN_EMAIL: test@test.local 65 | ADMIN_PASSWORD: test1234 66 | - name: Archive e2e tests coverage 67 | uses: actions/upload-artifact@v3 68 | with: 69 | name: e2e-tests-coverage 70 | path: test/coverage 71 | - name: Codecov 72 | uses: codecov/codecov-action@v3.1.0 73 | with: 74 | files: ./test/coverage/lcov.info 75 | 76 | 77 | lint: 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: actions/checkout@v3 81 | - uses: actions/setup-node@v3 82 | with: 83 | node-version: 16 84 | cache: 'npm' 85 | - name: Install dependencies 86 | run: npm ci 87 | - name: Run ESLint 88 | run: npm run lint 89 | 90 | build: 91 | needs: [ tests, e2e-tests ] 92 | runs-on: ubuntu-latest 93 | steps: 94 | - uses: actions/checkout@v3 95 | - uses: actions/setup-node@v3 96 | with: 97 | node-version: 16 98 | cache: 'npm' 99 | - name: Install dependencies 100 | run: npm ci 101 | - name: Build 102 | run: npm run build 103 | - name: Archive build output 104 | uses: actions/upload-artifact@v3 105 | with: 106 | name: build 107 | path: dist 108 | -------------------------------------------------------------------------------- /.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 | /test/coverage 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | /uploads 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | message="Upgrade to %s" 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine as base 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm i -g @nestjs/cli 8 | 9 | 10 | FROM base as dev 11 | 12 | RUN npm ci 13 | 14 | COPY . . 15 | 16 | 17 | FROM base as prod 18 | 19 | RUN npm ci 20 | 21 | COPY . . 22 | 23 | RUN npm run build 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Michał Marchewczyk 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 | # E-Commerce Platform - NestJS API 2 | 3 | REST API for the [E-commerce Platform project](https://github.com/michalmarchewczyk/ecommerce-platform) 4 | 5 | [![GitHub](https://img.shields.io/github/license/michalmarchewczyk/ecommerce-platform-nestjs-api)](LICENSE) 6 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/michalmarchewczyk/ecommerce-platform-nestjs-api/main.yml?label=CI)](https://github.com/michalmarchewczyk/ecommerce-platform-nestjs-api/actions/workflows/main.yml) 7 | [![Codecov](https://img.shields.io/codecov/c/github/michalmarchewczyk/ecommerce-platform-nestjs-api)](https://app.codecov.io/github/michalmarchewczyk/ecommerce-platform-nestjs-api) 8 | [![GitHub last commit](https://img.shields.io/github/last-commit/michalmarchewczyk/ecommerce-platform-nestjs-api)](https://github.com/michalmarchewczyk/ecommerce-platform-nestjs-api/commits/master) 9 | [![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/michalmarchewczyk/ecommerce-platform-nestjs-api)](https://github.com/michalmarchewczyk/ecommerce-platform-nestjs-api) 10 | [![GitHub milestone](https://img.shields.io/github/milestones/progress/michalmarchewczyk/ecommerce-platform-nestjs-api/5?label=1.4%20milestone)](https://github.com/michalmarchewczyk/ecommerce-platform-nestjs-api/milestone/5) 11 | [![GitHub package.json version](https://img.shields.io/github/package-json/v/michalmarchewczyk/ecommerce-platform-nestjs-api)](package.json) 12 | 13 | 14 | ### Used technologies 15 | - [NestJS](https://nestjs.com/) 16 | - [TypeORM](https://typeorm.io/) 17 | - [PostgreSQL](https://www.postgresql.org/) 18 | - [Redis](https://redis.io/) 19 | - [Passport.js](https://www.passportjs.org/) 20 | 21 | ## Installation 22 | ```bash 23 | npm install 24 | ``` 25 | 26 | ## Running the app 27 | ```bash 28 | # development 29 | npm run start 30 | 31 | # watch mode 32 | npm run start:dev 33 | 34 | # production mode 35 | npm run start:prod 36 | ``` 37 | 38 | ## Building the app 39 | ```bash 40 | npm run build 41 | ``` 42 | Built app is available in the `dist` folder. 43 | 44 | ## Test 45 | ```bash 46 | # unit tests 47 | npm run test 48 | 49 | # e2e tests 50 | npm run test:e2e 51 | ``` 52 | 53 | ## License 54 | 55 | [MIT license](LICENSE). 56 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 85% 6 | threshold: 5% 7 | patch: 8 | default: 9 | target: 80% 10 | threshold: 5% 11 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | server: 5 | build: 6 | dockerfile: Dockerfile 7 | context: . 8 | target: dev 9 | volumes: 10 | - ./src:/usr/src/app/src 11 | - uploads:/usr/src/app/uploads 12 | environment: 13 | POSTGRES_HOST: database 14 | POSTGRES_USER: postgres 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: ecommerce-platform 17 | PORT: 80 18 | SESSION_SECRET: secret 19 | ADMIN_EMAIL: admin@test.local 20 | ADMIN_PASSWORD: test1234 21 | command: npm run start:dev 22 | ports: 23 | - "80:80" 24 | networks: 25 | - default 26 | links: 27 | - database 28 | - redis 29 | database: 30 | image: postgres:14-alpine 31 | volumes: 32 | - postgres:/var/lib/postgresql/data 33 | environment: 34 | POSTGRES_USER: postgres 35 | POSTGRES_PASSWORD: postgres 36 | POSTGRES_DB: ecommerce-platform 37 | ports: 38 | - "5432:5432" 39 | redis: 40 | image: redis:7-alpine 41 | ports: 42 | - "6379:6379" 43 | pgadmin: 44 | image: dpage/pgadmin4 45 | environment: 46 | PGADMIN_DEFAULT_EMAIL: dev@dev.local 47 | PGADMIN_DEFAULT_PASSWORD: admin 48 | PGADMIN_CONFIG_SERVER_MODE: 'False' 49 | PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' 50 | volumes: 51 | - pgadmin:/var/lib/pgadmin 52 | ports: 53 | - "8080:80" 54 | links: 55 | - database 56 | 57 | volumes: 58 | postgres: 59 | pgadmin: 60 | uploads: 61 | -------------------------------------------------------------------------------- /docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | server: 5 | build: 6 | dockerfile: Dockerfile 7 | context: . 8 | target: prod 9 | volumes: 10 | - uploads:/usr/src/app/uploads 11 | environment: 12 | POSTGRES_HOST: database 13 | POSTGRES_USER: "${POSTGRES_USER}" 14 | POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" 15 | POSTGRES_DB: ecommerce-platform 16 | PORT: 80 17 | SESSION_SECRET: "${SESSION_SECRET}" 18 | ADMIN_EMAIL: "${ADMIN_EMAIL}" 19 | ADMIN_PASSWORD: "${ADMIN_PASSWORD}" 20 | command: npm run start:prod 21 | ports: 22 | - "80:80" 23 | networks: 24 | - default 25 | links: 26 | - database 27 | - redis 28 | database: 29 | image: postgres:14-alpine 30 | volumes: 31 | - postgres:/var/lib/postgresql/data 32 | environment: 33 | POSTGRES_USER: "${POSTGRES_USER}" 34 | POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" 35 | POSTGRES_DB: ecommerce-platform 36 | redis: 37 | image: redis:7-alpine 38 | 39 | volumes: 40 | postgres: 41 | uploads: 42 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | database: 5 | image: postgres:14-alpine 6 | environment: 7 | POSTGRES_USER: postgres 8 | POSTGRES_PASSWORD: postgres 9 | POSTGRES_DB: ecommerce-platform-test 10 | ports: 11 | - "5432:5432" 12 | redis: 13 | image: redis:7-alpine 14 | ports: 15 | - "6379:6379" 16 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "plugins": ["@nestjs/swagger"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { UsersService } from '../users/users.service'; 5 | import { DtoGeneratorService } from '../../test/utils/dto-generator/dto-generator.service'; 6 | import { RegisterDto } from './dto/register.dto'; 7 | import { User } from '../users/models/user.entity'; 8 | import { RepositoryMockService } from '../../test/utils/repository-mock/repository-mock.service'; 9 | import { Role } from '../users/models/role.enum'; 10 | import { Request } from 'express'; 11 | import { ConfigService } from '@nestjs/config'; 12 | 13 | describe('AuthController', () => { 14 | let controller: AuthController; 15 | let generate: DtoGeneratorService['generate']; 16 | 17 | beforeAll(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | controllers: [AuthController], 20 | providers: [ 21 | AuthService, 22 | ConfigService, 23 | DtoGeneratorService, 24 | UsersService, 25 | RepositoryMockService.getProvider(User), 26 | ], 27 | }).compile(); 28 | 29 | controller = module.get(AuthController); 30 | generate = module 31 | .get(DtoGeneratorService) 32 | .generate.bind(module.get(DtoGeneratorService)); 33 | }); 34 | 35 | it('should be defined', () => { 36 | expect(controller).toBeDefined(); 37 | }); 38 | 39 | describe('register', () => { 40 | it('should return registered user', async () => { 41 | const { email, password } = generate(RegisterDto); 42 | const user = await controller.register({ email, password }); 43 | expect(user).toBeDefined(); 44 | expect(user).toEqual({ 45 | email, 46 | id: expect.any(Number), 47 | firstName: null, 48 | lastName: null, 49 | password: undefined, 50 | registered: expect.any(Date), 51 | role: Role.Customer, 52 | }); 53 | }); 54 | 55 | it('should return registered user with optional fields', async () => { 56 | const { email, password, firstName, lastName } = generate( 57 | RegisterDto, 58 | true, 59 | ); 60 | const user = await controller.register({ 61 | email, 62 | password, 63 | firstName, 64 | lastName, 65 | }); 66 | expect(user).toBeDefined(); 67 | expect(user).toEqual({ 68 | email, 69 | firstName, 70 | lastName, 71 | id: expect.any(Number), 72 | registered: expect.any(Date), 73 | role: Role.Customer, 74 | password: undefined, 75 | }); 76 | }); 77 | }); 78 | 79 | describe('login', () => { 80 | it('should return req.user', async () => { 81 | const { email, password } = generate(RegisterDto); 82 | const user = await controller.login({ 83 | user: { email, password }, 84 | } as unknown as Request); 85 | expect(user).toBeDefined(); 86 | expect(user).toEqual({ email, password }); 87 | }); 88 | }); 89 | 90 | describe('logout', () => { 91 | it('should call logout method', async () => { 92 | const logout = jest.fn(); 93 | await controller.logout({ logOut: logout } as unknown as Request); 94 | expect(logout).toHaveBeenCalled(); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | ClassSerializerInterceptor, 4 | Controller, 5 | Post, 6 | UseGuards, 7 | UseInterceptors, 8 | Req, 9 | } from '@nestjs/common'; 10 | import { AuthService } from './auth.service'; 11 | import { RegisterDto } from './dto/register.dto'; 12 | import { User } from '../users/models/user.entity'; 13 | import { LocalAuthGuard } from './guards/local-auth.guard'; 14 | import { SessionAuthGuard } from './guards/session-auth.guard'; 15 | import { Request } from 'express'; 16 | import { 17 | ApiBadRequestResponse, 18 | ApiBody, 19 | ApiConflictResponse, 20 | ApiCreatedResponse, 21 | ApiTags, 22 | ApiUnauthorizedResponse, 23 | PickType, 24 | } from '@nestjs/swagger'; 25 | import { LoginDto } from './dto/login.dto'; 26 | 27 | @ApiTags('auth') 28 | @Controller('auth') 29 | @UseInterceptors(ClassSerializerInterceptor) 30 | export class AuthController { 31 | constructor(private readonly authService: AuthService) {} 32 | 33 | @Post('register') 34 | @ApiCreatedResponse({ type: User, description: 'Registered user' }) 35 | @ApiBadRequestResponse({ description: 'Invalid register data' }) 36 | @ApiConflictResponse({ description: 'User with given email already exists' }) 37 | async register(@Body() registerDto: RegisterDto): Promise { 38 | return this.authService.register(registerDto); 39 | } 40 | 41 | @UseGuards(LocalAuthGuard) 42 | @Post('login') 43 | @ApiBody({ type: LoginDto }) 44 | @ApiCreatedResponse({ 45 | type: PickType(User, ['id', 'email', 'role']), 46 | description: 'Logged in user', 47 | }) 48 | @ApiUnauthorizedResponse({ description: 'Invalid credentials' }) 49 | async login( 50 | @Req() req: Request, 51 | ): Promise> { 52 | return req.user as User; 53 | } 54 | 55 | @UseGuards(SessionAuthGuard) 56 | @Post('logout') 57 | @ApiCreatedResponse({ description: 'User logged out' }) 58 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 59 | async logout(@Req() req: Request): Promise { 60 | req.logOut(() => { 61 | req.session.cookie.maxAge = 0; 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | import { UsersModule } from '../users/users.module'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { LocalStrategy } from './local.strategy'; 7 | import { LocalSerializer } from './local.serializer'; 8 | 9 | @Module({ 10 | imports: [UsersModule, PassportModule], 11 | providers: [AuthService, LocalStrategy, LocalSerializer], 12 | controllers: [AuthController], 13 | }) 14 | export class AuthModule {} 15 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { UsersService } from '../users/users.service'; 3 | import * as argon2 from 'argon2'; 4 | import { User } from '../users/models/user.entity'; 5 | import { RegisterDto } from './dto/register.dto'; 6 | import { LoginDto } from './dto/login.dto'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { Role } from '../users/models/role.enum'; 9 | 10 | @Injectable() 11 | export class AuthService implements OnModuleInit { 12 | constructor( 13 | private usersService: UsersService, 14 | private config: ConfigService, 15 | ) {} 16 | 17 | async onModuleInit() { 18 | await this.addAdminUser(); 19 | } 20 | 21 | async addAdminUser(): Promise { 22 | try { 23 | const user = await this.register({ 24 | email: this.config.get('admin.email', ''), 25 | password: this.config.get('admin.password', ''), 26 | }); 27 | await this.usersService.updateUser(user.id, { role: Role.Admin }); 28 | } catch (e) { 29 | // do nothing 30 | } 31 | } 32 | 33 | async register(registerDto: RegisterDto): Promise { 34 | const hashedPassword = await argon2.hash(registerDto.password); 35 | return await this.usersService.addUser( 36 | registerDto.email, 37 | hashedPassword, 38 | registerDto.firstName, 39 | registerDto.lastName, 40 | ); 41 | } 42 | 43 | async validateUser(loginDto: LoginDto): Promise { 44 | const user = await this.usersService.findUserToLogin(loginDto.email); 45 | if (!user) { 46 | return null; 47 | } 48 | const passwordMatches = await argon2.verify( 49 | user.password, 50 | loginDto.password, 51 | ); 52 | if (!passwordMatches) { 53 | return null; 54 | } 55 | const { password, ...toReturn } = user; 56 | return toReturn as User; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '../../users/models/role.enum'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); 6 | -------------------------------------------------------------------------------- /src/auth/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { User } from '../../users/models/user.entity'; 3 | 4 | export const ReqUser = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext): User | null => { 6 | const request = ctx.switchToHttp().getRequest(); 7 | return request.user ?? null; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class LoginDto { 4 | @IsEmail() 5 | email: string; 6 | 7 | @IsString() 8 | @IsNotEmpty() 9 | password: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsOptional, 5 | IsString, 6 | MinLength, 7 | } from 'class-validator'; 8 | 9 | export class RegisterDto { 10 | @IsEmail() 11 | email: string; 12 | 13 | @IsString() 14 | @IsNotEmpty() 15 | @MinLength(8) 16 | password: string; 17 | 18 | @IsOptional() 19 | @IsString() 20 | firstName?: string; 21 | 22 | @IsOptional() 23 | @IsString() 24 | lastName?: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') { 6 | async canActivate(context: ExecutionContext): Promise { 7 | await super.canActivate(context); 8 | 9 | const request = context.switchToHttp().getRequest(); 10 | await super.logIn(request); 11 | 12 | return true; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | Injectable, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { Reflector } from '@nestjs/core'; 9 | import { Role } from '../../users/models/role.enum'; 10 | import { ROLES_KEY } from '../decorators/roles.decorator'; 11 | 12 | @Injectable() 13 | export class RolesGuard implements CanActivate { 14 | constructor(private readonly reflector: Reflector) {} 15 | 16 | canActivate(context: ExecutionContext): boolean { 17 | const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ 18 | context.getHandler(), 19 | context.getClass(), 20 | ]); 21 | if (!requiredRoles) { 22 | return true; 23 | } 24 | const user = context.switchToHttp().getRequest().user; 25 | if (!user) { 26 | throw new UnauthorizedException(['unauthorized']); 27 | } 28 | if (requiredRoles.includes(user.role)) { 29 | return true; 30 | } else { 31 | throw new ForbiddenException(['forbidden']); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/auth/guards/session-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class SessionAuthGuard implements CanActivate { 10 | async canActivate(context: ExecutionContext) { 11 | const request = context.switchToHttp().getRequest(); 12 | if (request.isAuthenticated()) { 13 | return true; 14 | } else { 15 | throw new UnauthorizedException(['unauthorized']); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/auth/local.serializer.ts: -------------------------------------------------------------------------------- 1 | import { PassportSerializer } from '@nestjs/passport'; 2 | import { User } from '../users/models/user.entity'; 3 | import { UsersService } from '../users/users.service'; 4 | import { Injectable } from '@nestjs/common'; 5 | 6 | @Injectable() 7 | export class LocalSerializer extends PassportSerializer { 8 | constructor(private readonly usersService: UsersService) { 9 | super(); 10 | } 11 | 12 | serializeUser(user: User, done: CallableFunction) { 13 | done(null, user.id.toString(10)); 14 | } 15 | 16 | async deserializeUser(id: string, done: CallableFunction) { 17 | const user = await this.usersService.findUserToSession(parseInt(id, 10)); 18 | done(null, user); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { AuthService } from './auth.service'; 5 | import { User } from '../users/models/user.entity'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor(private readonly authService: AuthService) { 10 | super({ 11 | usernameField: 'email', 12 | }); 13 | } 14 | 15 | async validate(email: string, password: string): Promise { 16 | const user = await this.authService.validateUser({ email, password }); 17 | if (!user) { 18 | throw new UnauthorizedException(['unauthorized']); 19 | } 20 | return user; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/carts/carts.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Put, Session } from '@nestjs/common'; 2 | import { CartsService } from './carts.service'; 3 | import { User } from '../users/models/user.entity'; 4 | import { ReqUser } from '../auth/decorators/user.decorator'; 5 | import { CartDto } from './dto/cart.dto'; 6 | import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; 7 | import { Cart } from './models/cart.entity'; 8 | 9 | @ApiTags('carts') 10 | @Controller('carts') 11 | export class CartsController { 12 | constructor(private cartsService: CartsService) {} 13 | 14 | @Get('my') 15 | @ApiOkResponse({ type: Cart, description: 'Current user/session cart' }) 16 | async getCart( 17 | @ReqUser() user: User | null, 18 | @Session() session: Record, 19 | ) { 20 | session.cart = true; 21 | return await this.cartsService.getCart(user, session.id); 22 | } 23 | 24 | @Put('my') 25 | @ApiOkResponse({ type: Cart, description: 'Updated cart' }) 26 | async updateCart( 27 | @ReqUser() user: User | null, 28 | @Session() session: Record, 29 | @Body() body: CartDto, 30 | ) { 31 | session.cart = true; 32 | return await this.cartsService.updateCart(body, user, session.id); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/carts/carts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CartsService } from './carts.service'; 3 | import { CartsController } from './carts.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Cart } from './models/cart.entity'; 6 | import { CatalogModule } from '../catalog/catalog.module'; 7 | import { CartItem } from './models/cart-item.entity'; 8 | 9 | @Module({ 10 | imports: [CatalogModule, TypeOrmModule.forFeature([Cart, CartItem])], 11 | providers: [CartsService], 12 | controllers: [CartsController], 13 | }) 14 | export class CartsModule {} 15 | -------------------------------------------------------------------------------- /src/carts/carts.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CartsService } from './carts.service'; 3 | import { RepositoryMockService } from '../../test/utils/repository-mock/repository-mock.service'; 4 | import { Cart } from './models/cart.entity'; 5 | import { ProductsService } from '../catalog/products/products.service'; 6 | import { User } from '../users/models/user.entity'; 7 | 8 | describe('CartsService', () => { 9 | let service: CartsService; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [ 14 | CartsService, 15 | RepositoryMockService.getProvider(Cart), 16 | { 17 | provide: ProductsService, 18 | useValue: { 19 | getProduct: (id: number) => ({ id, name: 'product ' + id }), 20 | }, 21 | }, 22 | ], 23 | }).compile(); 24 | 25 | service = module.get(CartsService); 26 | }); 27 | 28 | it('should be defined', () => { 29 | expect(service).toBeDefined(); 30 | }); 31 | 32 | describe('getCart', () => { 33 | it('should create users cart if it does not exist', async () => { 34 | const cart = await service.getCart({ id: 1 } as User); 35 | expect(cart).toBeDefined(); 36 | expect(cart.items).toEqual([]); 37 | }); 38 | 39 | it('should create session cart if it does not exist', async () => { 40 | const cart = await service.getCart(null, '123456789'); 41 | expect(cart).toBeDefined(); 42 | expect(cart.items).toEqual([]); 43 | }); 44 | 45 | it('should return users cart if it exists', async () => { 46 | const cart = await service.getCart({ id: 2 } as User); 47 | const cart2 = await service.getCart({ id: 2 } as User); 48 | expect(cart).toStrictEqual(cart2); 49 | }); 50 | 51 | it('should return session cart if it exists', async () => { 52 | const cart = await service.getCart(null, '12345'); 53 | const cart2 = await service.getCart(null, '12345'); 54 | expect(cart).toStrictEqual(cart2); 55 | }); 56 | }); 57 | 58 | describe('updateCart', () => { 59 | it('should update users cart', async () => { 60 | const cart = await service.getCart({ id: 3 } as User); 61 | await service.updateCart( 62 | { 63 | items: [ 64 | { productId: 1, quantity: 1 }, 65 | { productId: 2, quantity: 2 }, 66 | ], 67 | }, 68 | { id: 3 } as User, 69 | ); 70 | expect(cart.items).toEqual([ 71 | { quantity: 1, product: { id: 1, name: 'product 1' } }, 72 | { quantity: 2, product: { id: 2, name: 'product 2' } }, 73 | ]); 74 | }); 75 | 76 | it('should update session cart', async () => { 77 | const cart = await service.getCart(null, '123'); 78 | await service.updateCart( 79 | { 80 | items: [ 81 | { productId: 10, quantity: 10 }, 82 | { productId: 20, quantity: 20 }, 83 | ], 84 | }, 85 | null, 86 | '123', 87 | ); 88 | expect(cart.items).toEqual([ 89 | { quantity: 10, product: { id: 10, name: 'product 10' } }, 90 | { quantity: 20, product: { id: 20, name: 'product 20' } }, 91 | ]); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/carts/carts.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Cart } from './models/cart.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { ProductsService } from '../catalog/products/products.service'; 6 | import { User } from '../users/models/user.entity'; 7 | import { CartDto } from './dto/cart.dto'; 8 | import { CartItem } from './models/cart-item.entity'; 9 | 10 | @Injectable() 11 | export class CartsService { 12 | constructor( 13 | @InjectRepository(Cart) private cartsRepository: Repository, 14 | private productsService: ProductsService, 15 | ) {} 16 | 17 | async getCart(user: User | null, sessionId?: string) { 18 | let cart: Cart | null = null; 19 | if (user?.id) { 20 | cart = await this.cartsRepository.findOne({ 21 | where: { user: { id: user.id } }, 22 | }); 23 | } else if (sessionId) { 24 | cart = await this.cartsRepository.findOne({ 25 | where: { sessionId }, 26 | }); 27 | } 28 | if (!cart) { 29 | cart = await this.createCart(user, sessionId); 30 | } 31 | return cart; 32 | } 33 | 34 | private async createCart(user: User | null, sessionId?: string) { 35 | if (user) { 36 | const cart = new Cart(); 37 | cart.user = user; 38 | cart.items = []; 39 | return await this.cartsRepository.save(cart); 40 | } else { 41 | const cart = new Cart(); 42 | cart.sessionId = sessionId; 43 | cart.items = []; 44 | return await this.cartsRepository.save(cart); 45 | } 46 | } 47 | 48 | async updateCart(updateData: CartDto, user: User | null, sessionId?: string) { 49 | const cart = await this.getCart(user, sessionId); 50 | cart.items = []; 51 | for (const { productId, quantity } of updateData.items) { 52 | const product = await this.productsService.getProduct(productId); 53 | const item = new CartItem(); 54 | item.product = product; 55 | item.quantity = quantity; 56 | cart.items.push(item); 57 | } 58 | return await this.cartsRepository.save(cart); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/carts/dto/cart-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsPositive } from 'class-validator'; 2 | 3 | export class CartItemDto { 4 | @IsNumber() 5 | @IsNotEmpty() 6 | productId: number; 7 | 8 | @IsNumber() 9 | @IsNotEmpty() 10 | @IsPositive() 11 | quantity: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/carts/dto/cart.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNotEmptyObject, ValidateNested } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | import { CartItemDto } from './cart-item.dto'; 4 | 5 | export class CartDto { 6 | @IsNotEmpty({ each: true }) 7 | @ValidateNested({ each: true }) 8 | @IsNotEmptyObject({ nullable: false }, { each: true }) 9 | @Type(() => CartItemDto) 10 | items: CartItemDto[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/carts/models/cart-item.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinTable, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { Product } from '../../catalog/products/models/product.entity'; 9 | import { Cart } from './cart.entity'; 10 | 11 | @Entity('cart_items') 12 | export class CartItem { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @ManyToOne(() => Cart, (cart) => cart.items) 17 | cart: Cart; 18 | 19 | @ManyToOne(() => Product, { 20 | eager: true, 21 | onDelete: 'CASCADE', 22 | }) 23 | @JoinTable() 24 | product: Product; 25 | 26 | @Column() 27 | quantity: number; 28 | } 29 | -------------------------------------------------------------------------------- /src/carts/models/cart.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | OneToMany, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { User } from '../../users/models/user.entity'; 11 | import { CartItem } from './cart-item.entity'; 12 | 13 | @Entity('carts') 14 | export class Cart { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @UpdateDateColumn() 19 | updated: Date; 20 | 21 | @OneToOne(() => User, { 22 | nullable: true, 23 | orphanedRowAction: 'delete', 24 | onDelete: 'CASCADE', 25 | }) 26 | @JoinColumn() 27 | user?: User; 28 | 29 | @Column({ nullable: true }) 30 | sessionId?: string; 31 | 32 | @OneToMany(() => CartItem, (item) => item.cart, { 33 | cascade: true, 34 | eager: true, 35 | }) 36 | items: CartItem[]; 37 | } 38 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/attribute-types.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Post, 9 | Put, 10 | } from '@nestjs/common'; 11 | import { AttributeType } from './models/attribute-type.entity'; 12 | import { AttributeTypesService } from './attribute-types.service'; 13 | import { Roles } from '../../auth/decorators/roles.decorator'; 14 | import { Role } from '../../users/models/role.enum'; 15 | import { AttributeTypeDto } from './dto/attribute-type.dto'; 16 | import { 17 | ApiBadRequestResponse, 18 | ApiCreatedResponse, 19 | ApiForbiddenResponse, 20 | ApiNotFoundResponse, 21 | ApiOkResponse, 22 | ApiTags, 23 | ApiUnauthorizedResponse, 24 | } from '@nestjs/swagger'; 25 | 26 | @ApiTags('attribute types') 27 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 28 | @ApiForbiddenResponse({ description: 'User not authorized' }) 29 | @Controller('attribute-types') 30 | export class AttributeTypesController { 31 | constructor(private readonly attributeTypesService: AttributeTypesService) {} 32 | 33 | @Get() 34 | @Roles(Role.Admin, Role.Manager) 35 | @ApiOkResponse({ 36 | type: [AttributeType], 37 | description: 'List of attribute types', 38 | }) 39 | async getAttributeTypes(): Promise { 40 | return this.attributeTypesService.getAttributeTypes(); 41 | } 42 | 43 | @Post() 44 | @Roles(Role.Admin, Role.Manager) 45 | @ApiCreatedResponse({ 46 | type: AttributeType, 47 | description: 'Attribute type created', 48 | }) 49 | @ApiBadRequestResponse({ description: 'Invalid attribute type data' }) 50 | async createAttributeType( 51 | @Body() attributeType: AttributeTypeDto, 52 | ): Promise { 53 | return this.attributeTypesService.createAttributeType(attributeType); 54 | } 55 | 56 | @Put('/:id') 57 | @Roles(Role.Admin, Role.Manager) 58 | @ApiOkResponse({ type: AttributeType, description: 'Attribute type updated' }) 59 | @ApiNotFoundResponse({ description: 'Attribute type not found' }) 60 | @ApiBadRequestResponse({ description: 'Invalid attribute type data' }) 61 | async updateAttributeType( 62 | @Param('id', ParseIntPipe) id: number, 63 | @Body() attributeType: AttributeTypeDto, 64 | ): Promise { 65 | return await this.attributeTypesService.updateAttributeType( 66 | id, 67 | attributeType, 68 | ); 69 | } 70 | 71 | @Delete('/:id') 72 | @Roles(Role.Admin, Role.Manager) 73 | @ApiOkResponse({ description: 'Attribute type deleted' }) 74 | @ApiNotFoundResponse({ description: 'Attribute type not found' }) 75 | async deleteAttributeType( 76 | @Param('id', ParseIntPipe) id: number, 77 | ): Promise { 78 | await this.attributeTypesService.deleteAttributeType(id); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/attribute-types.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { AttributeType } from './models/attribute-type.entity'; 4 | import { AttributeTypesService } from './attribute-types.service'; 5 | 6 | @Injectable() 7 | export class AttributeTypesExporter implements Exporter { 8 | constructor(private attributeTypesService: AttributeTypesService) {} 9 | 10 | async export(): Promise { 11 | const attributeTypes = await this.attributeTypesService.getAttributeTypes(); 12 | const preparedAttributeTypes: AttributeType[] = []; 13 | for (const attributeType of attributeTypes) { 14 | preparedAttributeTypes.push(this.prepareAttributeType(attributeType)); 15 | } 16 | return preparedAttributeTypes; 17 | } 18 | 19 | private prepareAttributeType(attributeType: AttributeType) { 20 | const preparedAttributeType = new AttributeType(); 21 | preparedAttributeType.id = attributeType.id; 22 | preparedAttributeType.name = attributeType.name; 23 | preparedAttributeType.valueType = attributeType.valueType; 24 | return preparedAttributeType; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/attribute-types.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AttributeTypesService } from './attribute-types.service'; 3 | import { Importer } from '../../import-export/models/importer.interface'; 4 | import { Collection } from '../../import-export/models/collection.type'; 5 | import { AttributeType } from './models/attribute-type.entity'; 6 | import { ParseError } from '../../errors/parse.error'; 7 | import { AttributeValueType } from './models/attribute-value-type.enum'; 8 | import { IdMap } from '../../import-export/models/id-map.type'; 9 | 10 | @Injectable() 11 | export class AttributeTypesImporter implements Importer { 12 | constructor(private attributeTypesService: AttributeTypesService) {} 13 | 14 | async import(attributeTypes: Collection): Promise { 15 | const parsedAttributeTypes = this.parseAttributeTypes(attributeTypes); 16 | const idMap: IdMap = {}; 17 | for (const attributeType of parsedAttributeTypes) { 18 | const { id: newId } = 19 | await this.attributeTypesService.createAttributeType(attributeType); 20 | idMap[attributeType.id] = newId; 21 | } 22 | return idMap; 23 | } 24 | 25 | async clear() { 26 | const attributeTypes = await this.attributeTypesService.getAttributeTypes(); 27 | let deleted = 0; 28 | for (const attributeType of attributeTypes) { 29 | await this.attributeTypesService.deleteAttributeType(attributeType.id); 30 | deleted += 1; 31 | } 32 | return deleted; 33 | } 34 | 35 | private parseAttributeTypes(attributeTypes: Collection) { 36 | const parsedAttributeTypes: AttributeType[] = []; 37 | for (const attributeType of attributeTypes) { 38 | parsedAttributeTypes.push(this.parseAttributeType(attributeType)); 39 | } 40 | return parsedAttributeTypes; 41 | } 42 | 43 | private parseAttributeType(attributeType: Collection[number]) { 44 | const parsedAttributeType = new AttributeType(); 45 | try { 46 | parsedAttributeType.id = attributeType.id as number; 47 | parsedAttributeType.name = attributeType.name as string; 48 | parsedAttributeType.valueType = 49 | attributeType.valueType as AttributeValueType; 50 | } catch (e) { 51 | throw new ParseError('attributeType'); 52 | } 53 | return parsedAttributeType; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/attribute-types.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AttributeTypesService } from './attribute-types.service'; 3 | import { AttributeTypesController } from './attribute-types.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { AttributeType } from './models/attribute-type.entity'; 6 | import { AttributeTypesExporter } from './attribute-types.exporter'; 7 | import { AttributeTypesImporter } from './attribute-types.importer'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([AttributeType])], 11 | providers: [ 12 | AttributeTypesService, 13 | AttributeTypesExporter, 14 | AttributeTypesImporter, 15 | ], 16 | controllers: [AttributeTypesController], 17 | exports: [ 18 | AttributeTypesService, 19 | AttributeTypesExporter, 20 | AttributeTypesImporter, 21 | ], 22 | }) 23 | export class AttributeTypesModule {} 24 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/attribute-types.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { AttributeType } from './models/attribute-type.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { AttributeTypeDto } from './dto/attribute-type.dto'; 6 | import { NotFoundError } from '../../errors/not-found.error'; 7 | import { AttributeValueType } from './models/attribute-value-type.enum'; 8 | import { 9 | isBooleanString, 10 | isHexColor, 11 | isNumberString, 12 | isString, 13 | } from 'class-validator'; 14 | import { TypeCheckError } from '../../errors/type-check.error'; 15 | 16 | @Injectable() 17 | export class AttributeTypesService { 18 | constructor( 19 | @InjectRepository(AttributeType) 20 | readonly attributeTypesRepository: Repository, 21 | ) {} 22 | 23 | async getAttributeTypes(): Promise { 24 | return this.attributeTypesRepository.find(); 25 | } 26 | 27 | async getAttributeType(id: number): Promise { 28 | const attributeType = await this.attributeTypesRepository.findOne({ 29 | where: { id }, 30 | }); 31 | if (!attributeType) { 32 | throw new NotFoundError('attribute type', 'id', id.toString()); 33 | } 34 | return attributeType; 35 | } 36 | 37 | async createAttributeType( 38 | attributeTypeData: AttributeTypeDto, 39 | ): Promise { 40 | const attributeType = new AttributeType(); 41 | attributeType.name = attributeTypeData.name; 42 | attributeType.valueType = attributeTypeData.valueType; 43 | return this.attributeTypesRepository.save(attributeType); 44 | } 45 | 46 | async updateAttributeType( 47 | attributeTypeId: number, 48 | attributeTypeData: AttributeTypeDto, 49 | ): Promise { 50 | const attributeType = await this.attributeTypesRepository.findOne({ 51 | where: { id: attributeTypeId }, 52 | }); 53 | if (!attributeType) { 54 | throw new NotFoundError('attribute type'); 55 | } 56 | attributeType.name = attributeTypeData.name; 57 | attributeType.valueType = attributeTypeData.valueType; 58 | return this.attributeTypesRepository.save(attributeType); 59 | } 60 | 61 | async deleteAttributeType(attributeTypeId: number): Promise { 62 | const attributeType = await this.attributeTypesRepository.findOne({ 63 | where: { id: attributeTypeId }, 64 | }); 65 | if (!attributeType) { 66 | throw new NotFoundError('attribute type'); 67 | } 68 | await this.attributeTypesRepository.delete({ id: attributeTypeId }); 69 | return true; 70 | } 71 | 72 | async checkAttributeType(type: AttributeValueType, value: string) { 73 | (<[AttributeValueType, (value: any) => boolean][]>[ 74 | [AttributeValueType.String, isString], 75 | [AttributeValueType.Number, isNumberString], 76 | [AttributeValueType.Boolean, isBooleanString], 77 | [AttributeValueType.Color, isHexColor], 78 | ]).forEach((check) => { 79 | if (type === check[0] && !check[1](value)) { 80 | throw new TypeCheckError('attribute value', check[0]); 81 | } 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/dto/attribute-type.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; 2 | import { AttributeValueType } from '../models/attribute-value-type.enum'; 3 | 4 | export class AttributeTypeDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | name: string; 8 | 9 | @IsString() 10 | @IsEnum(AttributeValueType) 11 | valueType: AttributeValueType; 12 | } 13 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/models/attribute-type.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Attribute } from '../../products/models/attribute.entity'; 3 | import { AttributeValueType } from './attribute-value-type.enum'; 4 | 5 | @Entity('attribute_types') 6 | export class AttributeType { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column() 11 | name: string; 12 | 13 | @Column({ 14 | type: 'enum', 15 | enum: AttributeValueType, 16 | default: AttributeValueType.String, 17 | }) 18 | valueType: AttributeValueType; 19 | 20 | @OneToMany(() => Attribute, (attribute) => attribute.type, { 21 | onDelete: 'CASCADE', 22 | }) 23 | attributes: Attribute[]; 24 | } 25 | -------------------------------------------------------------------------------- /src/catalog/attribute-types/models/attribute-value-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum AttributeValueType { 2 | String = 'string', 3 | Number = 'number', 4 | Boolean = 'boolean', 5 | Color = 'color', 6 | } 7 | -------------------------------------------------------------------------------- /src/catalog/catalog.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AttributeTypesModule } from './attribute-types/attribute-types.module'; 3 | import { CategoriesModule } from './categories/categories.module'; 4 | import { ProductRatingsModule } from './product-ratings/product-ratings.module'; 5 | import { ProductsModule } from './products/products.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | AttributeTypesModule, 10 | CategoriesModule, 11 | ProductRatingsModule, 12 | ProductsModule, 13 | ], 14 | exports: [ 15 | AttributeTypesModule, 16 | CategoriesModule, 17 | ProductRatingsModule, 18 | ProductsModule, 19 | ], 20 | }) 21 | export class CatalogModule {} 22 | -------------------------------------------------------------------------------- /src/catalog/categories/categories.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { Category } from './models/category.entity'; 4 | import { CategoriesService } from './categories.service'; 5 | 6 | @Injectable() 7 | export class CategoriesExporter implements Exporter { 8 | constructor(private categoriesService: CategoriesService) {} 9 | 10 | async export(): Promise { 11 | const categories = await this.categoriesService.getCategories(true); 12 | const preparedCategories: Category[] = []; 13 | for (const category of categories) { 14 | preparedCategories.push(this.prepareCategory(category)); 15 | } 16 | return preparedCategories; 17 | } 18 | 19 | private prepareCategory(category: Category) { 20 | const preparedCategory = new Category() as any; 21 | preparedCategory.id = category.id; 22 | preparedCategory.name = category.name; 23 | preparedCategory.description = category.description; 24 | preparedCategory.slug = category.slug; 25 | preparedCategory.parentCategoryId = category.parentCategory?.id; 26 | preparedCategory.groups = category.groups.map(({ name }) => ({ name })); 27 | preparedCategory.products = category.products.map(({ id }) => ({ id })); 28 | return preparedCategory; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/catalog/categories/categories.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CategoriesController } from './categories.controller'; 3 | import { CategoriesService } from './categories.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Category } from './models/category.entity'; 6 | import { CategoryGroup } from './models/category-group.entity'; 7 | import { ProductsModule } from '../products/products.module'; 8 | import { CategoriesExporter } from './categories.exporter'; 9 | import { CategoriesImporter } from './categories.importer'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Category, CategoryGroup]), 14 | ProductsModule, 15 | ], 16 | controllers: [CategoriesController], 17 | providers: [CategoriesService, CategoriesExporter, CategoriesImporter], 18 | exports: [CategoriesExporter, CategoriesImporter], 19 | }) 20 | export class CategoriesModule {} 21 | -------------------------------------------------------------------------------- /src/catalog/categories/dto/category-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class CategoryCreateDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | 8 | @IsString() 9 | description: string; 10 | 11 | @IsString() 12 | @IsOptional() 13 | slug?: string; 14 | 15 | @IsNumber() 16 | @IsOptional() 17 | parentCategoryId?: number; 18 | } 19 | -------------------------------------------------------------------------------- /src/catalog/categories/dto/category-group.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class CategoryGroupDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/catalog/categories/dto/category-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsNotEmptyObject, 4 | IsOptional, 5 | IsString, 6 | ValidateNested, 7 | } from 'class-validator'; 8 | import { CategoryGroupDto } from './category-group.dto'; 9 | import { Type } from 'class-transformer'; 10 | 11 | export class CategoryUpdateDto { 12 | @IsString() 13 | @IsNotEmpty() 14 | @IsOptional() 15 | name?: string; 16 | 17 | @IsString() 18 | @IsOptional() 19 | description?: string; 20 | 21 | @IsString() 22 | @IsOptional() 23 | slug?: string; 24 | 25 | @IsOptional() 26 | parentCategoryId?: number; 27 | 28 | @IsOptional() 29 | @IsNotEmpty({ each: true }) 30 | @ValidateNested({ each: true }) 31 | @IsNotEmptyObject({ nullable: false }, { each: true }) 32 | @Type(() => CategoryGroupDto) 33 | groups?: CategoryGroupDto[]; 34 | } 35 | -------------------------------------------------------------------------------- /src/catalog/categories/models/category-group.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Category } from './category.entity'; 3 | 4 | @Entity('category_groups') 5 | export class CategoryGroup { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | name: string; 11 | 12 | @ManyToMany(() => Category, (category) => category.groups, { 13 | orphanedRowAction: 'delete', 14 | }) 15 | categories: Category[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/catalog/categories/models/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinTable, 5 | ManyToMany, 6 | ManyToOne, 7 | OneToMany, 8 | PrimaryGeneratedColumn, 9 | } from 'typeorm'; 10 | import { Product } from '../../products/models/product.entity'; 11 | import { CategoryGroup } from './category-group.entity'; 12 | 13 | @Entity('categories') 14 | export class Category { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | name: string; 20 | 21 | @Column() 22 | description: string; 23 | 24 | @Column({ nullable: true }) 25 | slug?: string; 26 | 27 | @ManyToOne(() => Category, (category) => category.childCategories, { 28 | onDelete: 'SET NULL', 29 | nullable: true, 30 | }) 31 | parentCategory?: Category; 32 | 33 | @OneToMany(() => Category, (category) => category.parentCategory, { 34 | onDelete: 'SET NULL', 35 | }) 36 | childCategories: Category[]; 37 | 38 | @ManyToMany( 39 | () => CategoryGroup, 40 | (categoryGroup) => categoryGroup.categories, 41 | { eager: true, onDelete: 'CASCADE' }, 42 | ) 43 | @JoinTable() 44 | groups: CategoryGroup[]; 45 | 46 | @ManyToMany(() => Product, { 47 | cascade: true, 48 | onDelete: 'CASCADE', 49 | }) 50 | @JoinTable() 51 | products: Product[]; 52 | } 53 | -------------------------------------------------------------------------------- /src/catalog/product-ratings/dto/product-rating.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNumber, 3 | IsOptional, 4 | IsPositive, 5 | IsString, 6 | Max, 7 | } from 'class-validator'; 8 | 9 | export class ProductRatingDto { 10 | @IsNumber() 11 | @IsPositive() 12 | @Max(5) 13 | rating: number; 14 | 15 | @IsOptional() 16 | @IsString() 17 | comment?: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/catalog/product-ratings/models/product-rating.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | OneToMany, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { Product } from '../../products/models/product.entity'; 11 | import { User } from '../../../users/models/user.entity'; 12 | import { ProductRatingPhoto } from '../product-rating-photos/models/product-rating-photo.entity'; 13 | 14 | @Entity('product_ratings') 15 | export class ProductRating { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @CreateDateColumn() 20 | created: Date; 21 | 22 | @UpdateDateColumn() 23 | updated: Date; 24 | 25 | @ManyToOne(() => User) 26 | user: User; 27 | 28 | @ManyToOne(() => Product, (product) => product.ratings, { 29 | orphanedRowAction: 'delete', 30 | }) 31 | product: Product; 32 | 33 | @Column() 34 | rating: number; 35 | 36 | @Column({ nullable: true }) 37 | comment?: string; 38 | 39 | @OneToMany(() => ProductRatingPhoto, (photo) => photo.productRating, { 40 | eager: true, 41 | onDelete: 'CASCADE', 42 | cascade: true, 43 | }) 44 | photos: ProductRatingPhoto[]; 45 | } 46 | -------------------------------------------------------------------------------- /src/catalog/product-ratings/product-rating-photos/models/product-rating-photo.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne } from 'typeorm'; 2 | import { ProductRating } from '../../models/product-rating.entity'; 3 | import { Photo } from '../../../../local-files/models/photo.entity'; 4 | 5 | @Entity('product_rating_photos') 6 | export class ProductRatingPhoto extends Photo { 7 | @ManyToOne(() => ProductRating, (rating) => rating.photos, { 8 | onDelete: 'CASCADE', 9 | orphanedRowAction: 'delete', 10 | }) 11 | productRating: ProductRating; 12 | } 13 | -------------------------------------------------------------------------------- /src/catalog/product-ratings/product-rating-photos/product-rating-photos.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductRatingPhotosService } from './product-rating-photos.service'; 3 | import { ProductRatingPhotosController } from './product-rating-photos.controller'; 4 | import { MulterModule } from '@nestjs/platform-express'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { TypeOrmModule } from '@nestjs/typeorm'; 7 | import { ProductRating } from '../models/product-rating.entity'; 8 | import { ProductRatingPhoto } from './models/product-rating-photo.entity'; 9 | import { LocalFilesModule } from '../../../local-files/local-files.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([ProductRating, ProductRatingPhoto]), 14 | MulterModule.registerAsync({ 15 | imports: [ConfigModule], 16 | useFactory: async (configService: ConfigService) => ({ 17 | dest: configService.get('uploadPath'), 18 | }), 19 | inject: [ConfigService], 20 | }), 21 | LocalFilesModule, 22 | ], 23 | providers: [ProductRatingPhotosService], 24 | controllers: [ProductRatingPhotosController], 25 | }) 26 | export class ProductRatingPhotosModule {} 27 | -------------------------------------------------------------------------------- /src/catalog/product-ratings/product-ratings.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductRatingsController } from './product-ratings.controller'; 3 | import { ProductRatingsService } from './product-ratings.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { ProductRating } from './models/product-rating.entity'; 6 | import { SettingsModule } from '../../settings/settings.module'; 7 | import { ProductRatingPhotosModule } from './product-rating-photos/product-rating-photos.module'; 8 | import { ProductsModule } from '../products/products.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([ProductRating]), 13 | ProductRatingPhotosModule, 14 | ProductsModule, 15 | SettingsModule, 16 | ], 17 | controllers: [ProductRatingsController], 18 | providers: [ProductRatingsService], 19 | }) 20 | export class ProductRatingsModule {} 21 | -------------------------------------------------------------------------------- /src/catalog/product-ratings/product-ratings.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Repository } from 'typeorm'; 3 | import { ProductRating } from './models/product-rating.entity'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { ProductRatingDto } from './dto/product-rating.dto'; 6 | import { NotFoundError } from '../../errors/not-found.error'; 7 | import { User } from '../../users/models/user.entity'; 8 | import { SettingsService } from '../../settings/settings.service'; 9 | import { ProductsService } from '../products/products.service'; 10 | 11 | @Injectable() 12 | export class ProductRatingsService { 13 | constructor( 14 | @InjectRepository(ProductRating) 15 | private readonly productRatingsRepository: Repository, 16 | private productsService: ProductsService, 17 | private settingsService: SettingsService, 18 | ) {} 19 | 20 | async getProductRatings(productId: number): Promise { 21 | const rating = await this.productRatingsRepository.find({ 22 | where: { product: { id: productId } }, 23 | relations: ['user'], 24 | }); 25 | if ( 26 | (await this.settingsService.getSettingValueByName( 27 | 'Product rating photos', 28 | )) !== 'true' 29 | ) { 30 | rating.forEach((r) => (r.photos = [])); 31 | } 32 | return rating; 33 | } 34 | 35 | async getProductRating( 36 | id: number, 37 | productId: number, 38 | ): Promise { 39 | const productRating = await this.productRatingsRepository.findOne({ 40 | where: { id, product: { id: productId } }, 41 | }); 42 | if (!productRating) { 43 | throw new NotFoundError('product rating'); 44 | } 45 | return productRating; 46 | } 47 | 48 | async createProductRating( 49 | user: User, 50 | productId: number, 51 | createData: ProductRatingDto, 52 | ): Promise { 53 | const product = await this.productsService.getProduct(productId); 54 | const newProductRating = new ProductRating(); 55 | newProductRating.user = user; 56 | newProductRating.product = product; 57 | newProductRating.rating = createData.rating; 58 | newProductRating.comment = createData.comment; 59 | return this.productRatingsRepository.save(newProductRating); 60 | } 61 | 62 | async checkProductRatingUser(id: number, userId: number): Promise { 63 | const productRating = await this.productRatingsRepository.findOne({ 64 | where: { id, user: { id: userId } }, 65 | }); 66 | return !!productRating; 67 | } 68 | 69 | async updateProductRating( 70 | productId: number, 71 | id: number, 72 | updateData: ProductRatingDto, 73 | ): Promise { 74 | const productRating = await this.getProductRating(id, productId); 75 | productRating.rating = updateData.rating; 76 | productRating.comment = updateData.comment; 77 | return this.productRatingsRepository.save(productRating); 78 | } 79 | 80 | async deleteProductRating(productId: number, id: number): Promise { 81 | await this.getProductRating(id, productId); 82 | await this.productRatingsRepository.delete({ 83 | id, 84 | product: { id: productId }, 85 | }); 86 | return true; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/catalog/products/dto/attribute.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class AttributeDto { 4 | @IsString() 5 | value: string; 6 | 7 | @IsNumber() 8 | typeId: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/catalog/products/dto/product-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsBoolean, 3 | IsNotEmpty, 4 | IsNumber, 5 | IsOptional, 6 | IsString, 7 | Min, 8 | } from 'class-validator'; 9 | 10 | export class ProductCreateDto { 11 | @IsString() 12 | @IsNotEmpty() 13 | name: string; 14 | 15 | @IsNumber() 16 | @Min(0) 17 | price: number; 18 | 19 | @IsBoolean() 20 | @IsOptional() 21 | visible?: boolean; 22 | 23 | @IsString() 24 | description: string; 25 | 26 | @IsNumber() 27 | @Min(0) 28 | stock: number; 29 | } 30 | -------------------------------------------------------------------------------- /src/catalog/products/dto/product-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsBoolean, 3 | IsNotEmpty, 4 | IsNumber, 5 | IsOptional, 6 | IsString, 7 | Min, 8 | } from 'class-validator'; 9 | 10 | export class ProductUpdateDto { 11 | @IsString() 12 | @IsOptional() 13 | @IsNotEmpty() 14 | name?: string; 15 | 16 | @IsNumber() 17 | @Min(0) 18 | @IsOptional() 19 | price?: number; 20 | 21 | @IsBoolean() 22 | @IsOptional() 23 | visible?: boolean; 24 | 25 | @IsString() 26 | @IsOptional() 27 | description?: string; 28 | 29 | @IsNumber() 30 | @Min(0) 31 | @IsOptional() 32 | stock?: number; 33 | 34 | @IsString() 35 | @IsOptional() 36 | photosOrder?: string; 37 | } 38 | -------------------------------------------------------------------------------- /src/catalog/products/models/attribute.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { AttributeType } from '../../attribute-types/models/attribute-type.entity'; 3 | import { Product } from './product.entity'; 4 | 5 | @Entity('attributes') 6 | export class Attribute { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @ManyToOne(() => Product, (product) => product.attributes, { 11 | onDelete: 'CASCADE', 12 | orphanedRowAction: 'delete', 13 | }) 14 | product: Product; 15 | 16 | @Column() 17 | value: string; 18 | 19 | @ManyToOne(() => AttributeType, (attributeType) => attributeType.attributes, { 20 | eager: true, 21 | onDelete: 'CASCADE', 22 | orphanedRowAction: 'delete', 23 | }) 24 | type: AttributeType; 25 | } 26 | -------------------------------------------------------------------------------- /src/catalog/products/models/product.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | OneToMany, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | import { Attribute } from './attribute.entity'; 10 | import { ProductPhoto } from '../product-photos/models/product-photo.entity'; 11 | import { ProductRating } from '../../product-ratings/models/product-rating.entity'; 12 | 13 | @Entity('products') 14 | export class Product { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @CreateDateColumn() 19 | created: Date; 20 | 21 | @UpdateDateColumn() 22 | updated: Date; 23 | 24 | @Column() 25 | name: string; 26 | 27 | @Column({ type: 'double precision' }) 28 | price: number; 29 | 30 | @Column({ default: true }) 31 | visible: boolean; 32 | 33 | @Column() 34 | description: string; 35 | 36 | @Column() 37 | stock: number; 38 | 39 | @OneToMany(() => Attribute, (attribute) => attribute.product, { 40 | eager: true, 41 | onDelete: 'CASCADE', 42 | cascade: true, 43 | }) 44 | attributes: Attribute[]; 45 | 46 | @OneToMany(() => ProductPhoto, (photo) => photo.product, { 47 | eager: true, 48 | onDelete: 'CASCADE', 49 | cascade: true, 50 | }) 51 | photos: ProductPhoto[]; 52 | 53 | @Column({ default: '' }) 54 | photosOrder: string; 55 | 56 | @OneToMany(() => ProductRating, (rating) => rating.product, { 57 | onDelete: 'CASCADE', 58 | cascade: true, 59 | }) 60 | ratings: ProductRating[]; 61 | } 62 | -------------------------------------------------------------------------------- /src/catalog/products/product-photos/models/product-photo.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne } from 'typeorm'; 2 | import { Product } from '../../models/product.entity'; 3 | import { Photo } from '../../../../local-files/models/photo.entity'; 4 | 5 | @Entity('product_photos') 6 | export class ProductPhoto extends Photo { 7 | @ManyToOne(() => Product, (product) => product.photos, { 8 | onDelete: 'CASCADE', 9 | orphanedRowAction: 'delete', 10 | }) 11 | product: Product; 12 | } 13 | -------------------------------------------------------------------------------- /src/catalog/products/product-photos/product-photos.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ProductPhoto } from './models/product-photo.entity'; 3 | import { ProductPhotosService } from './product-photos.service'; 4 | import { Exporter } from '../../../import-export/models/exporter.interface'; 5 | import * as mime from 'mime-types'; 6 | 7 | @Injectable() 8 | export class ProductPhotosExporter implements Exporter { 9 | constructor(private productPhotosService: ProductPhotosService) {} 10 | 11 | async export(): Promise { 12 | const productPhotos = await this.productPhotosService.getProductPhotos(); 13 | productPhotos.sort((a, b) => { 14 | if (a.product.id !== b.product.id) { 15 | return a.product.id - b.product.id; 16 | } 17 | const photosOrder = a.product.photosOrder.split(','); 18 | return ( 19 | photosOrder.indexOf(a.id.toString()) - 20 | photosOrder.indexOf(b.id.toString()) 21 | ); 22 | }); 23 | const preparedProductPhotos: ProductPhoto[] = []; 24 | for (const productPhoto of productPhotos) { 25 | preparedProductPhotos.push(this.prepareProductPhoto(productPhoto)); 26 | } 27 | return preparedProductPhotos; 28 | } 29 | 30 | private prepareProductPhoto(productPhoto: ProductPhoto) { 31 | const preparedProductPhoto = new ProductPhoto() as any; 32 | preparedProductPhoto.id = productPhoto.id; 33 | preparedProductPhoto.productId = productPhoto.product.id; 34 | preparedProductPhoto.path = 35 | productPhoto.path + '.' + mime.extension(productPhoto.mimeType); 36 | preparedProductPhoto.mimeType = productPhoto.mimeType; 37 | return preparedProductPhoto; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/catalog/products/product-photos/product-photos.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ProductPhotosService } from './product-photos.service'; 3 | import { ProductPhoto } from './models/product-photo.entity'; 4 | import { Importer } from '../../../import-export/models/importer.interface'; 5 | import { Collection } from '../../../import-export/models/collection.type'; 6 | import { IdMap } from '../../../import-export/models/id-map.type'; 7 | import { ParseError } from '../../../errors/parse.error'; 8 | import { Product } from '../models/product.entity'; 9 | 10 | @Injectable() 11 | export class ProductPhotosImporter implements Importer { 12 | constructor(private productPhotosService: ProductPhotosService) {} 13 | 14 | async import( 15 | productPhotos: Collection, 16 | idMaps: Record, 17 | ): Promise { 18 | const parsedProductPhotos = this.parseProductPhotos( 19 | productPhotos, 20 | idMaps.products, 21 | ); 22 | const idMap: IdMap = {}; 23 | for (const productPhoto of parsedProductPhotos) { 24 | const { id: newId } = await this.productPhotosService.createProductPhoto( 25 | productPhoto.product.id, 26 | productPhoto.path, 27 | productPhoto.mimeType, 28 | ); 29 | idMap[productPhoto.id] = newId; 30 | } 31 | return idMap; 32 | } 33 | 34 | async clear() { 35 | const productPhotos = await this.productPhotosService.getProductPhotos(); 36 | let deleted = 0; 37 | for (const productPhoto of productPhotos) { 38 | await this.productPhotosService.deleteProductPhoto( 39 | productPhoto.product.id, 40 | productPhoto.id, 41 | ); 42 | deleted += 1; 43 | } 44 | return deleted; 45 | } 46 | 47 | private parseProductPhotos(productPhotos: Collection, productsIdMap: IdMap) { 48 | const parsedProductPhotos: ProductPhoto[] = []; 49 | for (const productPhoto of productPhotos) { 50 | parsedProductPhotos.push( 51 | this.parseProductPhoto(productPhoto, productsIdMap), 52 | ); 53 | } 54 | return parsedProductPhotos; 55 | } 56 | 57 | private parseProductPhoto( 58 | productPhoto: Collection[number], 59 | productsIdMap: IdMap, 60 | ) { 61 | const parsedProductPhoto = new ProductPhoto(); 62 | try { 63 | parsedProductPhoto.id = productPhoto.id as number; 64 | parsedProductPhoto.product = { 65 | id: productsIdMap[productPhoto.productId as number], 66 | } as Product; 67 | parsedProductPhoto.path = productPhoto.path as string; 68 | parsedProductPhoto.mimeType = productPhoto.mimeType as string; 69 | } catch (e) { 70 | throw new ParseError('product-photo'); 71 | } 72 | return parsedProductPhoto; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/catalog/products/product-photos/product-photos.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductPhotosService } from './product-photos.service'; 3 | import { ProductPhotosController } from './product-photos.controller'; 4 | import { MulterModule } from '@nestjs/platform-express'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { LocalFilesModule } from '../../../local-files/local-files.module'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | import { Product } from '../models/product.entity'; 9 | import { ProductPhoto } from './models/product-photo.entity'; 10 | import { ProductPhotosExporter } from './product-photos.exporter'; 11 | import { ProductPhotosImporter } from './product-photos.importer'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([Product, ProductPhoto]), 16 | MulterModule.registerAsync({ 17 | imports: [ConfigModule], 18 | useFactory: async (configService: ConfigService) => ({ 19 | dest: configService.get('uploadPath'), 20 | }), 21 | inject: [ConfigService], 22 | }), 23 | LocalFilesModule, 24 | ], 25 | providers: [ 26 | ProductPhotosService, 27 | ProductPhotosExporter, 28 | ProductPhotosImporter, 29 | ], 30 | controllers: [ProductPhotosController], 31 | exports: [ProductPhotosExporter, ProductPhotosImporter], 32 | }) 33 | export class ProductPhotosModule {} 34 | -------------------------------------------------------------------------------- /src/catalog/products/products.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { Product } from './models/product.entity'; 4 | import { ProductsService } from './products.service'; 5 | import { Attribute } from './models/attribute.entity'; 6 | 7 | @Injectable() 8 | export class ProductsExporter implements Exporter { 9 | constructor(private productsService: ProductsService) {} 10 | 11 | async export(): Promise { 12 | const products = await this.productsService.getProducts(true); 13 | const preparedProducts: Product[] = []; 14 | for (const product of products) { 15 | preparedProducts.push(this.prepareProduct(product)); 16 | } 17 | return preparedProducts; 18 | } 19 | 20 | private prepareProduct(product: Product) { 21 | const preparedProduct = new Product(); 22 | preparedProduct.id = product.id; 23 | preparedProduct.name = product.name; 24 | preparedProduct.description = product.description; 25 | preparedProduct.price = product.price; 26 | preparedProduct.stock = product.stock; 27 | preparedProduct.visible = product.visible; 28 | preparedProduct.attributes = product.attributes.map((a) => 29 | this.prepareAttribute(a), 30 | ) as Attribute[]; 31 | return preparedProduct; 32 | } 33 | 34 | private prepareAttribute(attribute: Product['attributes'][number]) { 35 | const preparedAttribute: Record = {}; 36 | preparedAttribute.value = attribute.value; 37 | preparedAttribute.typeId = attribute.type.id; 38 | return preparedAttribute; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/catalog/products/products.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Importer } from '../../import-export/models/importer.interface'; 3 | import { Collection } from '../../import-export/models/collection.type'; 4 | import { ParseError } from '../../errors/parse.error'; 5 | import { IdMap } from '../../import-export/models/id-map.type'; 6 | import { ProductsService } from './products.service'; 7 | import { Product } from './models/product.entity'; 8 | import { Attribute } from './models/attribute.entity'; 9 | import { AttributeType } from '../attribute-types/models/attribute-type.entity'; 10 | 11 | @Injectable() 12 | export class ProductsImporter implements Importer { 13 | constructor(private productsService: ProductsService) {} 14 | 15 | async import( 16 | products: Collection, 17 | idMaps: Record, 18 | ): Promise { 19 | const parsedProducts = this.parseProducts(products, idMaps.attributeTypes); 20 | const idMap: IdMap = {}; 21 | for (const product of parsedProducts) { 22 | const { id, ...createDto } = product; 23 | const { id: newId } = await this.productsService.createProduct(createDto); 24 | idMap[product.id] = newId; 25 | } 26 | return idMap; 27 | } 28 | 29 | async clear() { 30 | const products = await this.productsService.getProducts(true); 31 | let deleted = 0; 32 | for (const product of products) { 33 | await this.productsService.deleteProduct(product.id); 34 | deleted += 1; 35 | } 36 | return deleted; 37 | } 38 | 39 | private parseProducts(products: Collection, attributeTypesIdMap: IdMap) { 40 | const parsedProducts: Product[] = []; 41 | for (const product of products) { 42 | parsedProducts.push(this.parseProduct(product, attributeTypesIdMap)); 43 | } 44 | return parsedProducts; 45 | } 46 | 47 | private parseProduct( 48 | product: Collection[number], 49 | attributeTypesIdMap: IdMap, 50 | ) { 51 | const parsedProduct = new Product(); 52 | try { 53 | parsedProduct.id = product.id as number; 54 | parsedProduct.name = product.name as string; 55 | parsedProduct.description = product.description as string; 56 | parsedProduct.price = product.price as number; 57 | parsedProduct.stock = product.stock as number; 58 | parsedProduct.visible = product.visible as boolean; 59 | if (typeof product.attributes === 'string') { 60 | product.attributes = JSON.parse(product.attributes); 61 | } 62 | parsedProduct.attributes = (product.attributes as Collection).map((a) => 63 | this.parseAttribute(a, attributeTypesIdMap), 64 | ); 65 | } catch (e) { 66 | throw new ParseError('product'); 67 | } 68 | return parsedProduct; 69 | } 70 | 71 | private parseAttribute( 72 | attribute: Collection[number], 73 | attributeTypesIdMap: IdMap, 74 | ) { 75 | const parsedAttribute = new Attribute(); 76 | try { 77 | parsedAttribute.value = attribute.value as string; 78 | parsedAttribute.type = { 79 | id: attributeTypesIdMap[attribute.typeId as number], 80 | } as AttributeType; 81 | } catch (e) { 82 | throw new ParseError('attribute'); 83 | } 84 | return parsedAttribute; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/catalog/products/products.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductsController } from './products.controller'; 3 | import { ProductsService } from './products.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Product } from './models/product.entity'; 6 | import { Attribute } from './models/attribute.entity'; 7 | import { ProductPhotosModule } from './product-photos/product-photos.module'; 8 | import { AttributeTypesModule } from '../attribute-types/attribute-types.module'; 9 | import { ProductsExporter } from './products.exporter'; 10 | import { ProductsImporter } from './products.importer'; 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([Product, Attribute]), 15 | ProductPhotosModule, 16 | AttributeTypesModule, 17 | ], 18 | controllers: [ProductsController], 19 | providers: [ProductsService, ProductsExporter, ProductsImporter], 20 | exports: [ 21 | ProductsService, 22 | ProductsExporter, 23 | ProductsImporter, 24 | ProductPhotosModule, 25 | ], 26 | }) 27 | export class ProductsModule {} 28 | -------------------------------------------------------------------------------- /src/config/configuration.schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi'; 2 | 3 | export const schema = Joi.object({ 4 | PORT: Joi.number().default(3000), 5 | POSTGRES_HOST: Joi.string().default('postgres'), 6 | POSTGRES_PORT: Joi.number().default(5432), 7 | POSTGRES_USER: Joi.string().default('postgres'), 8 | POSTGRES_PASSWORD: Joi.string().default('postgres'), 9 | POSTGRES_DB: Joi.string().default('ecommerce-platform'), 10 | SESSION_SECRET: Joi.string().default('secret'), 11 | REDIS_HOST: Joi.string().default('redis'), 12 | REDIS_PORT: Joi.number().default(6379), 13 | UPLOAD_PATH: Joi.string().default('./uploads'), 14 | NODE_ENV: Joi.string() 15 | .valid('development', 'production', 'test') 16 | .default('development'), 17 | ADMIN_EMAIL: Joi.string().required(), 18 | ADMIN_PASSWORD: Joi.string().required(), 19 | }); 20 | -------------------------------------------------------------------------------- /src/config/configuration.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | port: process.env.PORT, 3 | postgres: { 4 | host: process.env.POSTGRES_HOST, 5 | port: process.env.POSTGRES_PORT, 6 | username: process.env.POSTGRES_USER, 7 | password: process.env.POSTGRES_PASSWORD, 8 | database: process.env.POSTGRES_DB, 9 | }, 10 | session: { 11 | secret: process.env.SESSION_SECRET, 12 | maxAge: 1000 * 60 * 60 * 24 * 7, 13 | }, 14 | redis: { 15 | host: process.env.REDIS_HOST, 16 | port: process.env.REDIS_PORT, 17 | }, 18 | uploadPath: process.env.UPLOAD_PATH, 19 | nodeEnv: process.env.NODE_ENV, 20 | admin: { 21 | email: process.env.ADMIN_EMAIL, 22 | password: process.env.ADMIN_PASSWORD, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/errors/conflict.error.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from './service-error'; 2 | 3 | export class ConflictError extends ServiceError { 4 | constructor(entityName: string, property?: string, value?: string) { 5 | super(); 6 | if (property && value) { 7 | this.message = `${entityName} could not be saved because of a conflict on ${property}=${value}`; 8 | } else { 9 | this.message = `${entityName} could not be saved because of a data conflict`; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/errors/generic.error.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from './service-error'; 2 | 3 | export class GenericError extends ServiceError { 4 | constructor(message: string) { 5 | super(); 6 | this.message = message; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/errors/not-found.error.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from './service-error'; 2 | 3 | export class NotFoundError extends ServiceError { 4 | constructor( 5 | entityName: string, 6 | searchProperty?: string, 7 | searchValue?: string, 8 | ) { 9 | super(); 10 | if (searchProperty && searchValue) { 11 | this.message = `${entityName} with ${searchProperty}=${searchValue} not found`; 12 | } else { 13 | this.message = `${entityName} not found`; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/errors/not-related.error.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from './service-error'; 2 | 3 | export class NotRelatedError extends ServiceError { 4 | constructor(entityName: string, entityName2: string) { 5 | super(); 6 | this.message = `there is no relation between ${entityName} and ${entityName2}`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/errors/parse.error.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from './service-error'; 2 | 3 | export class ParseError extends ServiceError { 4 | constructor(name: string) { 5 | super(); 6 | this.message = `There was an error while parsing ${name}`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/errors/service-error.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | CallHandler, 4 | ConflictException, 5 | ExecutionContext, 6 | HttpException, 7 | Injectable, 8 | NestInterceptor, 9 | NotFoundException, 10 | } from '@nestjs/common'; 11 | import { catchError, Observable } from 'rxjs'; 12 | import { ServiceError } from './service-error'; 13 | import { NotFoundError } from './not-found.error'; 14 | import { ConflictError } from './conflict.error'; 15 | 16 | @Injectable() 17 | export class ServiceErrorInterceptor implements NestInterceptor { 18 | intercept(context: ExecutionContext, next: CallHandler): Observable { 19 | return next.handle().pipe( 20 | catchError((error) => { 21 | if (error instanceof ServiceError) { 22 | throw ServiceErrorInterceptor.getError(error); 23 | } 24 | throw error; 25 | }), 26 | ); 27 | } 28 | 29 | private static getError(error: ServiceError): HttpException { 30 | if (error instanceof NotFoundError) { 31 | return new NotFoundException([error.message]); 32 | } else if (error instanceof ConflictError) { 33 | return new ConflictException([error.message]); 34 | } 35 | return new BadRequestException([error.message]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/errors/service-error.ts: -------------------------------------------------------------------------------- 1 | export abstract class ServiceError extends Error { 2 | protected constructor(public message: string = '') { 3 | super(message); 4 | 5 | /* istanbul ignore if */ 6 | /* istanbul ignore else */ 7 | if (Object.setPrototypeOf) { 8 | Object.setPrototypeOf(this, new.target.prototype); 9 | } else { 10 | (this as any).__proto__ = new.target.prototype; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/errors/type-check.error.ts: -------------------------------------------------------------------------------- 1 | import { ServiceError } from './service-error'; 2 | 3 | export class TypeCheckError extends ServiceError { 4 | constructor(name: string, type: string) { 5 | super(); 6 | this.message = `${name} is not of type ${type}`; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/import-export/data-type.utils.ts: -------------------------------------------------------------------------------- 1 | import { dataTypeDependencies } from './models/data-type-dependencies.data'; 2 | import { GenericError } from '../errors/generic.error'; 3 | import { DataType } from './models/data-type.enum'; 4 | 5 | const checkDataTypeDependencies = (data: string[]) => { 6 | for (const type of data) { 7 | const dependencies = dataTypeDependencies.find((d) => d[0] === type)?.[1]; 8 | if (!dependencies) { 9 | throw new GenericError(`"${type}" is not recognized data type`); 10 | } 11 | for (const dependency of dependencies) { 12 | if (!data.includes(dependency)) { 13 | throw new GenericError(`"${type}" depends on "${dependency}"`); 14 | } 15 | } 16 | } 17 | }; 18 | 19 | const checkDataType = (type: string): type is DataType => { 20 | return ( 21 | type in DataType || 22 | (Object.keys(DataType) as Array).some( 23 | (k) => DataType[k] === type, 24 | ) 25 | ); 26 | }; 27 | 28 | export { checkDataTypeDependencies, checkDataType }; 29 | -------------------------------------------------------------------------------- /src/import-export/dto/export.dto.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from '../models/data-type.enum'; 2 | import { ArrayNotEmpty, IsArray, IsEnum, IsIn } from 'class-validator'; 3 | 4 | export class ExportDto { 5 | @IsArray() 6 | @ArrayNotEmpty() 7 | @IsEnum(DataType, { each: true }) 8 | data: DataType[]; 9 | 10 | @IsIn(['json', 'csv']) 11 | format: 'json' | 'csv'; 12 | } 13 | -------------------------------------------------------------------------------- /src/import-export/dto/import.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBooleanString, IsOptional } from 'class-validator'; 2 | 3 | export class ImportDto { 4 | @IsBooleanString() 5 | @IsOptional() 6 | clear?: string; 7 | 8 | @IsBooleanString() 9 | @IsOptional() 10 | noImport?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/import-export/export.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Res } from '@nestjs/common'; 2 | import { 3 | ApiCreatedResponse, 4 | ApiForbiddenResponse, 5 | ApiProduces, 6 | ApiTags, 7 | ApiUnauthorizedResponse, 8 | } from '@nestjs/swagger'; 9 | import { Roles } from '../auth/decorators/roles.decorator'; 10 | import { Role } from '../users/models/role.enum'; 11 | import { ExportService } from './export.service'; 12 | import { Response } from 'express'; 13 | import { ExportDto } from './dto/export.dto'; 14 | import { fileResponseSchema } from '../local-files/models/file-response.schema'; 15 | 16 | @ApiTags('import-export') 17 | @Controller('export') 18 | @Roles(Role.Admin) 19 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 20 | @ApiForbiddenResponse({ description: 'User is not admin' }) 21 | export class ExportController { 22 | constructor(private exportService: ExportService) {} 23 | 24 | @Post('') 25 | @ApiCreatedResponse({ 26 | schema: fileResponseSchema, 27 | description: 'Exported data', 28 | }) 29 | @ApiProduces('application/json', 'application/gzip') 30 | async export( 31 | @Res({ passthrough: true }) res: Response, 32 | @Body() data: ExportDto, 33 | ) { 34 | const contentType = 35 | data.format === 'csv' ? 'application/gzip' : 'application/json'; 36 | res.header('Content-Type', contentType); 37 | res.header( 38 | 'Content-Disposition', 39 | `attachment; filename="${this.exportService.getFilename(data.format)}"`, 40 | ); 41 | res.header('Access-Control-Expose-Headers', 'Content-Disposition'); 42 | return await this.exportService.export(data.data, data.format); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/import-export/import-export.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ImportController } from './import.controller'; 3 | import { ExportController } from './export.controller'; 4 | import { ExportService } from './export.service'; 5 | import { ImportService } from './import.service'; 6 | import { SettingsModule } from '../settings/settings.module'; 7 | import { UsersModule } from '../users/users.module'; 8 | import { CatalogModule } from '../catalog/catalog.module'; 9 | import { JsonSerializer } from './json-serializer.service'; 10 | import { ZipSerializer } from './zip-serializer.service'; 11 | import { WishlistsModule } from '../wishlists/wishlists.module'; 12 | import { SalesModule } from '../sales/sales.module'; 13 | import { PagesModule } from '../pages/pages.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | SettingsModule, 18 | PagesModule, 19 | UsersModule, 20 | CatalogModule, 21 | WishlistsModule, 22 | SalesModule, 23 | ], 24 | controllers: [ExportController, ImportController], 25 | providers: [ExportService, ImportService, JsonSerializer, ZipSerializer], 26 | }) 27 | export class ImportExportModule {} 28 | -------------------------------------------------------------------------------- /src/import-export/import.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | FileTypeValidator, 5 | ParseFilePipe, 6 | Post, 7 | UploadedFile, 8 | UseInterceptors, 9 | } from '@nestjs/common'; 10 | import { 11 | ApiBody, 12 | ApiConsumes, 13 | ApiCreatedResponse, 14 | ApiForbiddenResponse, 15 | ApiTags, 16 | ApiUnauthorizedResponse, 17 | } from '@nestjs/swagger'; 18 | import { Roles } from '../auth/decorators/roles.decorator'; 19 | import { Role } from '../users/models/role.enum'; 20 | import { ImportService } from './import.service'; 21 | import { FileInterceptor } from '@nestjs/platform-express'; 22 | import * as multer from 'multer'; 23 | import { ImportDto } from './dto/import.dto'; 24 | import { ImportStatus } from './models/import-status.interface'; 25 | 26 | @ApiTags('import-export') 27 | @Controller('import') 28 | @Roles(Role.Admin) 29 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 30 | @ApiForbiddenResponse({ description: 'User is not admin' }) 31 | export class ImportController { 32 | constructor(private importService: ImportService) {} 33 | 34 | @Post('') 35 | @ApiBody({ 36 | schema: { 37 | type: 'object', 38 | properties: { 39 | file: { 40 | type: 'string', 41 | format: 'binary', 42 | }, 43 | clear: { 44 | type: 'string', 45 | nullable: true, 46 | }, 47 | noImport: { 48 | type: 'string', 49 | nullable: true, 50 | }, 51 | }, 52 | }, 53 | }) 54 | @ApiConsumes('multipart/form-data') 55 | @UseInterceptors( 56 | FileInterceptor('file', { 57 | storage: multer.memoryStorage(), 58 | }), 59 | ) 60 | @ApiCreatedResponse({ 61 | type: ImportStatus, 62 | description: 'Import status', 63 | }) 64 | async import( 65 | @UploadedFile( 66 | new ParseFilePipe({ 67 | validators: [ 68 | new FileTypeValidator({ 69 | fileType: /(^application\/json$)|(^application\/(x-)?gzip$)/, 70 | }), 71 | ], 72 | }), 73 | ) 74 | file: Express.Multer.File, 75 | @Body() data: ImportDto, 76 | ) { 77 | return await this.importService.import( 78 | file.buffer, 79 | file.mimetype, 80 | data.clear === 'true', 81 | data.noImport === 'true', 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/import-export/json-serializer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, StreamableFile } from '@nestjs/common'; 2 | import { FileSerializer } from './models/file-serializer.interface'; 3 | import { Readable } from 'stream'; 4 | 5 | @Injectable() 6 | export class JsonSerializer implements FileSerializer { 7 | async parse(data: Buffer): Promise> { 8 | return JSON.parse(data.toString()); 9 | } 10 | 11 | async serialize(data: Record): Promise { 12 | const parsed = JSON.stringify(data); 13 | return new StreamableFile(Readable.from([parsed]), { 14 | type: 'application/json', 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/import-export/models/collection.type.ts: -------------------------------------------------------------------------------- 1 | type Collection = Record< 2 | string, 3 | string | number | boolean | null | Collection 4 | >[]; 5 | 6 | export { Collection }; 7 | -------------------------------------------------------------------------------- /src/import-export/models/data-type-dependencies.data.ts: -------------------------------------------------------------------------------- 1 | import { DataType } from './data-type.enum'; 2 | 3 | const dataTypeDependencies: [DataType, DataType[]][] = [ 4 | [DataType.Settings, []], 5 | [DataType.Pages, []], 6 | [DataType.Users, []], 7 | [DataType.AttributeTypes, []], 8 | [DataType.Products, [DataType.AttributeTypes]], 9 | [DataType.ProductPhotos, [DataType.Products]], 10 | [DataType.Categories, [DataType.Products]], 11 | [DataType.Wishlists, [DataType.Users, DataType.Products]], 12 | [DataType.DeliveryMethods, []], 13 | [DataType.PaymentMethods, []], 14 | [ 15 | DataType.Orders, 16 | [ 17 | DataType.Users, 18 | DataType.Products, 19 | DataType.DeliveryMethods, 20 | DataType.PaymentMethods, 21 | ], 22 | ], 23 | [DataType.Returns, [DataType.Orders]], 24 | ]; 25 | 26 | export { dataTypeDependencies }; 27 | -------------------------------------------------------------------------------- /src/import-export/models/data-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum DataType { 2 | Settings = 'settings', 3 | Pages = 'pages', 4 | Users = 'users', 5 | AttributeTypes = 'attributeTypes', 6 | Products = 'products', 7 | ProductPhotos = 'productPhotos', 8 | Categories = 'categories', 9 | Wishlists = 'wishlists', 10 | DeliveryMethods = 'deliveryMethods', 11 | PaymentMethods = 'paymentMethods', 12 | Orders = 'orders', 13 | Returns = 'returns', 14 | } 15 | -------------------------------------------------------------------------------- /src/import-export/models/exporter.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Exporter { 2 | export(): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/import-export/models/file-serializer.interface.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from './collection.type'; 2 | import { StreamableFile } from '@nestjs/common'; 3 | 4 | export interface FileSerializer { 5 | parse(data: Buffer): Promise>; 6 | serialize(data: Record): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/import-export/models/id-map.type.ts: -------------------------------------------------------------------------------- 1 | type IdMap = Record; 2 | 3 | export { IdMap }; 4 | -------------------------------------------------------------------------------- /src/import-export/models/import-status.interface.ts: -------------------------------------------------------------------------------- 1 | export class ImportStatus { 2 | deleted: Record; 3 | added: Record; 4 | errors: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/import-export/models/importer.interface.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from './collection.type'; 2 | import { IdMap } from './id-map.type'; 3 | 4 | export interface Importer { 5 | import(data: Collection, idMaps?: Record): Promise; 6 | clear(): Promise; 7 | } 8 | -------------------------------------------------------------------------------- /src/local-files/local-files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LocalFilesService } from './local-files.service'; 3 | import { SettingsModule } from '../settings/settings.module'; 4 | 5 | @Module({ 6 | imports: [SettingsModule], 7 | providers: [LocalFilesService], 8 | exports: [LocalFilesService], 9 | }) 10 | export class LocalFilesModule {} 11 | -------------------------------------------------------------------------------- /src/local-files/local-files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, StreamableFile } from '@nestjs/common'; 2 | import * as sharp from 'sharp'; 3 | import { createReadStream } from 'fs'; 4 | import * as path from 'path'; 5 | import { SettingsService } from '../settings/settings.service'; 6 | 7 | @Injectable() 8 | export class LocalFilesService { 9 | constructor(private settingsService: SettingsService) {} 10 | 11 | async getPhoto(filepath: string, mimeType: string) { 12 | const stream = createReadStream(path.join(process.cwd(), filepath)); 13 | 14 | const res = new StreamableFile(stream, { 15 | type: mimeType, 16 | disposition: 'inline', 17 | }); 18 | 19 | res.setErrorHandler(() => { 20 | return; 21 | }); 22 | 23 | return res; 24 | } 25 | 26 | async savePhoto( 27 | file: Express.Multer.File, 28 | ): Promise<{ path: string; mimeType: string }> { 29 | if ( 30 | (await this.settingsService.getSettingValueByName( 31 | 'Convert images to JPEG', 32 | )) !== 'true' 33 | ) { 34 | return { path: file.path, mimeType: file.mimetype }; 35 | } 36 | const buffer = await sharp(file.path) 37 | .flatten({ background: '#ffffff' }) 38 | .jpeg({ quality: 95, mozjpeg: true }) 39 | .toBuffer(); 40 | await sharp(buffer).toFile(file.path); 41 | return { path: file.path, mimeType: 'image/jpeg' }; 42 | } 43 | 44 | async createPhotoThumbnail(path: string): Promise { 45 | const outputPath = `${path}-thumbnail`; 46 | const size = Math.abs( 47 | parseInt( 48 | await this.settingsService.getSettingValueByName('Thumbnail size'), 49 | ), 50 | ); 51 | await sharp(path) 52 | .resize(size, size, { fit: 'contain', background: '#ffffff' }) 53 | .jpeg({ quality: 80, mozjpeg: true }) 54 | .toFile(outputPath); 55 | return outputPath; 56 | } 57 | 58 | async createPhotoPlaceholder(path: string): Promise { 59 | const res = await sharp(path) 60 | .resize(12, 12, { fit: 'contain', background: '#ffffff' }) 61 | .toBuffer(); 62 | return `data:image/png;base64,${res.toString('base64')}`; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/local-files/models/file-body.schema.ts: -------------------------------------------------------------------------------- 1 | const fileBodySchema = { 2 | type: 'object', 3 | properties: { 4 | file: { 5 | type: 'string', 6 | format: 'binary', 7 | }, 8 | }, 9 | }; 10 | 11 | export { fileBodySchema }; 12 | -------------------------------------------------------------------------------- /src/local-files/models/file-response.schema.ts: -------------------------------------------------------------------------------- 1 | const fileResponseSchema = { 2 | type: 'string', 3 | format: 'binary', 4 | }; 5 | 6 | export { fileResponseSchema }; 7 | -------------------------------------------------------------------------------- /src/local-files/models/photo.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | export abstract class Photo { 4 | @PrimaryGeneratedColumn() 5 | id: number; 6 | 7 | @Column() 8 | path: string; 9 | 10 | @Column() 11 | mimeType: string; 12 | 13 | @Column() 14 | thumbnailPath: string; 15 | 16 | @Column({ nullable: true }) 17 | placeholderBase64: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | import { AppModule } from './app.module'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { RedocModule } from 'nestjs-redoc'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | const configService = app.get(ConfigService); 10 | 11 | if (configService.get('nodeEnv') === 'development') { 12 | app.enableCors({ origin: true, credentials: true }); 13 | } 14 | 15 | const swaggerConfig = new DocumentBuilder() 16 | .setTitle('E-commerce platform API') 17 | .setVersion(process.env.npm_package_version ?? '1.0.0') 18 | .build(); 19 | const document = SwaggerModule.createDocument(app, swaggerConfig, { 20 | operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, 21 | }); 22 | await RedocModule.setup('docs', app, document, {}); 23 | 24 | const port = configService.get('port'); 25 | await app.listen(port); 26 | } 27 | bootstrap(); 28 | -------------------------------------------------------------------------------- /src/pages/dto/page-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class PageCreateDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | title: string; 7 | 8 | @IsString() 9 | @IsOptional() 10 | slug?: string; 11 | 12 | @IsString() 13 | @IsNotEmpty() 14 | content: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/dto/page-group.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class PageGroupDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/dto/page-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsNotEmptyObject, 4 | IsOptional, 5 | IsString, 6 | ValidateNested, 7 | } from 'class-validator'; 8 | import { PageGroupDto } from './page-group.dto'; 9 | import { Type } from 'class-transformer'; 10 | 11 | export class PageUpdateDto { 12 | @IsString() 13 | @IsNotEmpty() 14 | @IsOptional() 15 | title?: string; 16 | 17 | @IsString() 18 | @IsOptional() 19 | slug?: string; 20 | 21 | @IsString() 22 | @IsNotEmpty() 23 | @IsOptional() 24 | content?: string; 25 | 26 | @IsOptional() 27 | @IsNotEmpty({ each: true }) 28 | @ValidateNested({ each: true }) 29 | @IsNotEmptyObject({ nullable: false }, { each: true }) 30 | @Type(() => PageGroupDto) 31 | groups?: PageGroupDto[]; 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/models/page-group.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Page } from './page.entity'; 3 | 4 | @Entity('page_groups') 5 | export class PageGroup { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | name: string; 11 | 12 | @ManyToMany(() => Page, (page) => page.groups, { 13 | orphanedRowAction: 'delete', 14 | }) 15 | pages: Page[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/models/page.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinTable, 6 | ManyToMany, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { PageGroup } from './page-group.entity'; 11 | 12 | @Entity('pages') 13 | export class Page { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @CreateDateColumn() 18 | created: Date; 19 | 20 | @UpdateDateColumn() 21 | updated: Date; 22 | 23 | @Column() 24 | title: string; 25 | 26 | @Column({ nullable: true }) 27 | slug: string; 28 | 29 | @Column() 30 | content: string; 31 | 32 | @ManyToMany(() => PageGroup, (pageGroup) => pageGroup.pages, { 33 | eager: true, 34 | onDelete: 'CASCADE', 35 | }) 36 | @JoinTable() 37 | groups: PageGroup[]; 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/pages.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | } from '@nestjs/common'; 11 | import { PagesService } from './pages.service'; 12 | import { 13 | ApiBadRequestResponse, 14 | ApiCreatedResponse, 15 | ApiForbiddenResponse, 16 | ApiNotFoundResponse, 17 | ApiOkResponse, 18 | ApiTags, 19 | ApiUnauthorizedResponse, 20 | } from '@nestjs/swagger'; 21 | import { Roles } from '../auth/decorators/roles.decorator'; 22 | import { Role } from '../users/models/role.enum'; 23 | import { PageCreateDto } from './dto/page-create.dto'; 24 | import { PageUpdateDto } from './dto/page-update.dto'; 25 | import { Page } from './models/page.entity'; 26 | import { PageGroup } from './models/page-group.entity'; 27 | 28 | @ApiTags('pages') 29 | @Controller('pages') 30 | export class PagesController { 31 | constructor(private pagesService: PagesService) {} 32 | 33 | @Get() 34 | @ApiOkResponse({ type: [Page], description: 'List of all pages' }) 35 | async getPages() { 36 | return await this.pagesService.getPages(); 37 | } 38 | 39 | @Get('groups') 40 | @ApiOkResponse({ type: [PageGroup], description: 'List of all page groups' }) 41 | async getPageGroups() { 42 | return await this.pagesService.getPageGroups(); 43 | } 44 | 45 | @Get(':id') 46 | @ApiNotFoundResponse({ description: 'Page not found' }) 47 | @ApiOkResponse({ type: Page, description: 'Page with given id' }) 48 | async getPage(@Param('id', ParseIntPipe) id: number) { 49 | return await this.pagesService.getPage(id); 50 | } 51 | 52 | @Post() 53 | @Roles(Role.Admin) 54 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 55 | @ApiForbiddenResponse({ description: 'User is not admin' }) 56 | @ApiCreatedResponse({ type: Page, description: 'Page created' }) 57 | @ApiBadRequestResponse({ description: 'Invalid page data' }) 58 | async createPage(@Body() data: PageCreateDto) { 59 | return await this.pagesService.createPage(data); 60 | } 61 | 62 | @Patch(':id') 63 | @Roles(Role.Admin) 64 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 65 | @ApiForbiddenResponse({ description: 'User is not admin' }) 66 | @ApiNotFoundResponse({ description: 'Page not found' }) 67 | @ApiOkResponse({ type: Page, description: 'Page updated' }) 68 | @ApiBadRequestResponse({ description: 'Invalid page data' }) 69 | async updatePage( 70 | @Param('id', ParseIntPipe) id: number, 71 | @Body() data: PageUpdateDto, 72 | ) { 73 | return await this.pagesService.updatePage(id, data); 74 | } 75 | 76 | @Delete(':id') 77 | @Roles(Role.Admin) 78 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 79 | @ApiForbiddenResponse({ description: 'User is not admin' }) 80 | @ApiNotFoundResponse({ description: 'Page not found' }) 81 | @ApiOkResponse({ type: Page, description: 'Page deleted' }) 82 | async deletePage(@Param('id', ParseIntPipe) id: number) { 83 | await this.pagesService.deletePage(id); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/pages/pages.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../import-export/models/exporter.interface'; 3 | import { Page } from './models/page.entity'; 4 | import { PagesService } from './pages.service'; 5 | 6 | @Injectable() 7 | export class PagesExporter implements Exporter { 8 | constructor(private pagesService: PagesService) {} 9 | 10 | async export(): Promise { 11 | const pages = await this.pagesService.getPages(); 12 | const preparedPages: Page[] = []; 13 | for (const page of pages) { 14 | preparedPages.push(this.preparePage(page)); 15 | } 16 | return preparedPages; 17 | } 18 | 19 | private preparePage(page: Page) { 20 | const preparedPage = new Page() as any; 21 | preparedPage.id = page.id; 22 | preparedPage.title = page.title; 23 | preparedPage.content = page.content; 24 | preparedPage.slug = page.slug; 25 | preparedPage.groups = page.groups.map(({ name }) => ({ name })); 26 | return preparedPage; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/pages.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Importer } from '../import-export/models/importer.interface'; 3 | import { Collection } from '../import-export/models/collection.type'; 4 | import { ParseError } from '../errors/parse.error'; 5 | import { IdMap } from '../import-export/models/id-map.type'; 6 | import { PagesService } from './pages.service'; 7 | import { Page } from './models/page.entity'; 8 | import { PageGroup } from './models/page-group.entity'; 9 | 10 | @Injectable() 11 | export class PagesImporter implements Importer { 12 | constructor(private pagesService: PagesService) {} 13 | 14 | async import(pages: Collection): Promise { 15 | const parsedPages = this.parsePages(pages); 16 | const idMap: IdMap = {}; 17 | for (const page of parsedPages) { 18 | const { id, ...createDto } = page; 19 | const { id: newId } = await this.pagesService.createPage(createDto); 20 | idMap[page.id] = newId; 21 | } 22 | for (const page of parsedPages) { 23 | await this.pagesService.updatePage(idMap[page.id], { 24 | groups: page.groups, 25 | }); 26 | } 27 | return idMap; 28 | } 29 | 30 | async clear() { 31 | const pages = await this.pagesService.getPages(); 32 | let deleted = 0; 33 | for (const page of pages) { 34 | await this.pagesService.deletePage(page.id); 35 | deleted += 1; 36 | } 37 | return deleted; 38 | } 39 | 40 | private parsePages(pages: Collection) { 41 | const parsedPages: Page[] = []; 42 | for (const page of pages) { 43 | parsedPages.push(this.parsePage(page)); 44 | } 45 | return parsedPages; 46 | } 47 | 48 | private parsePage(page: Collection[number]) { 49 | const parsedPage = new Page(); 50 | try { 51 | parsedPage.id = page.id as number; 52 | parsedPage.title = page.title as string; 53 | parsedPage.content = page.content as string; 54 | parsedPage.slug = page.slug as string; 55 | if (typeof page.groups === 'string') { 56 | page.groups = JSON.parse(page.groups); 57 | } 58 | parsedPage.groups = (page.groups as Collection).map((group) => 59 | this.parsePageGroup(group), 60 | ); 61 | } catch (e) { 62 | throw new ParseError('page'); 63 | } 64 | return parsedPage; 65 | } 66 | 67 | private parsePageGroup(group: Collection[number]) { 68 | const parsedGroup = new PageGroup(); 69 | try { 70 | parsedGroup.name = group.name as string; 71 | } catch (e) { 72 | throw new ParseError('page group'); 73 | } 74 | return parsedGroup; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/pages/pages.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PagesController } from './pages.controller'; 3 | import { PagesService } from './pages.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Page } from './models/page.entity'; 6 | import { PageGroup } from './models/page-group.entity'; 7 | import { PagesExporter } from './pages.exporter'; 8 | import { PagesImporter } from './pages.importer'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Page, PageGroup])], 12 | controllers: [PagesController], 13 | providers: [PagesService, PagesExporter, PagesImporter], 14 | exports: [PagesExporter, PagesImporter], 15 | }) 16 | export class PagesModule {} 17 | -------------------------------------------------------------------------------- /src/pages/pages.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Page } from './models/page.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { PageCreateDto } from './dto/page-create.dto'; 6 | import { PageUpdateDto } from './dto/page-update.dto'; 7 | import { PageGroup } from './models/page-group.entity'; 8 | import { NotFoundError } from '../errors/not-found.error'; 9 | 10 | @Injectable() 11 | export class PagesService { 12 | constructor( 13 | @InjectRepository(Page) private pagesRepository: Repository, 14 | @InjectRepository(PageGroup) 15 | private pageGroupsRepository: Repository, 16 | ) {} 17 | 18 | async getPages() { 19 | return this.pagesRepository.find(); 20 | } 21 | 22 | async getPageGroups() { 23 | return this.pageGroupsRepository.find({ relations: ['pages'] }); 24 | } 25 | 26 | async getPage(id: number) { 27 | const page = await this.pagesRepository.findOne({ 28 | where: { id }, 29 | }); 30 | if (!page) { 31 | throw new NotFoundError('page', 'id', id.toString()); 32 | } 33 | return page; 34 | } 35 | 36 | async createPage(pageData: PageCreateDto) { 37 | const page = new Page(); 38 | Object.assign(page, pageData); 39 | return this.pagesRepository.save(page); 40 | } 41 | 42 | async updatePage(id: number, pageData: PageUpdateDto) { 43 | const page = await this.getPage(id); 44 | Object.assign(page, pageData); 45 | if (pageData.groups) { 46 | page.groups = []; 47 | for (const groupData of pageData.groups) { 48 | let group = await this.pageGroupsRepository.findOne({ 49 | where: { name: groupData.name }, 50 | }); 51 | if (!group) { 52 | group = new PageGroup(); 53 | group.name = groupData.name; 54 | group = await this.pageGroupsRepository.save(group); 55 | } 56 | page.groups.push(group); 57 | } 58 | } 59 | return this.pagesRepository.save(page); 60 | } 61 | 62 | async deletePage(id: number) { 63 | await this.getPage(id); 64 | await this.pagesRepository.delete({ id }); 65 | return true; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/redis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './redis.module'; 2 | export * from './redis.constants'; 3 | -------------------------------------------------------------------------------- /src/redis/redis.constants.ts: -------------------------------------------------------------------------------- 1 | export const REDIS_CLIENT = Symbol('REDIS_CLIENT'); 2 | -------------------------------------------------------------------------------- /src/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { REDIS_CLIENT } from './redis.constants'; 3 | import * as Redis from 'redis'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | @Module({ 7 | providers: [ 8 | { 9 | provide: REDIS_CLIENT, 10 | useFactory: (configService: ConfigService) => 11 | Redis.createClient({ 12 | host: configService.get('redis.host'), 13 | port: configService.get('redis.port'), 14 | }), 15 | inject: [ConfigService], 16 | }, 17 | ], 18 | exports: [REDIS_CLIENT], 19 | }) 20 | export class RedisModule {} 21 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/delivery-methods.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Post, 8 | Put, 9 | } from '@nestjs/common'; 10 | import { DeliveryMethodsService } from './delivery-methods.service'; 11 | import { DeliveryMethod } from './models/delivery-method.entity'; 12 | import { DeliveryMethodDto } from './dto/delivery-method.dto'; 13 | import { Roles } from '../../auth/decorators/roles.decorator'; 14 | import { Role } from '../../users/models/role.enum'; 15 | import { 16 | ApiBadRequestResponse, 17 | ApiCreatedResponse, 18 | ApiForbiddenResponse, 19 | ApiNotFoundResponse, 20 | ApiOkResponse, 21 | ApiTags, 22 | ApiUnauthorizedResponse, 23 | } from '@nestjs/swagger'; 24 | 25 | @ApiTags('delivery methods') 26 | @Controller('delivery-methods') 27 | export class DeliveryMethodsController { 28 | constructor( 29 | private readonly deliveryMethodsService: DeliveryMethodsService, 30 | ) {} 31 | 32 | @Get() 33 | @ApiOkResponse({ 34 | type: [DeliveryMethod], 35 | description: 'List all delivery methods', 36 | }) 37 | async getDeliveryMethods(): Promise { 38 | return this.deliveryMethodsService.getMethods(); 39 | } 40 | 41 | @Post() 42 | @Roles(Role.Admin) 43 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 44 | @ApiForbiddenResponse({ description: 'User not authorized' }) 45 | @ApiBadRequestResponse({ description: 'Invalid delivery method data' }) 46 | @ApiCreatedResponse({ 47 | type: DeliveryMethod, 48 | description: 'Delivery method created', 49 | }) 50 | async createDeliveryMethod( 51 | @Body() body: DeliveryMethodDto, 52 | ): Promise { 53 | return this.deliveryMethodsService.createMethod(body); 54 | } 55 | 56 | @Put(':id') 57 | @Roles(Role.Admin) 58 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 59 | @ApiForbiddenResponse({ description: 'User not authorized' }) 60 | @ApiNotFoundResponse({ description: 'Delivery method not found' }) 61 | @ApiBadRequestResponse({ description: 'Invalid delivery method data' }) 62 | @ApiOkResponse({ 63 | type: DeliveryMethod, 64 | description: 'Delivery method updated', 65 | }) 66 | async updateDeliveryMethod( 67 | @Param('id') id: number, 68 | @Body() body: DeliveryMethodDto, 69 | ): Promise { 70 | return await this.deliveryMethodsService.updateMethod(id, body); 71 | } 72 | 73 | @Delete(':id') 74 | @Roles(Role.Admin) 75 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 76 | @ApiForbiddenResponse({ description: 'User not authorized' }) 77 | @ApiNotFoundResponse({ description: 'Delivery method not found' }) 78 | @ApiOkResponse({ description: 'Delivery method deleted' }) 79 | async deleteDeliveryMethod(@Param('id') id: number): Promise { 80 | await this.deliveryMethodsService.deleteMethod(id); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/delivery-methods.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { DeliveryMethod } from './models/delivery-method.entity'; 4 | import { DeliveryMethodsService } from './delivery-methods.service'; 5 | 6 | @Injectable() 7 | export class DeliveryMethodsExporter implements Exporter { 8 | constructor(private deliveryMethodsService: DeliveryMethodsService) {} 9 | 10 | async export(): Promise { 11 | const deliveryMethods = await this.deliveryMethodsService.getMethods(); 12 | const preparedDeliveryMethods: DeliveryMethod[] = []; 13 | for (const deliveryMethod of deliveryMethods) { 14 | preparedDeliveryMethods.push(this.prepareDeliveryMethod(deliveryMethod)); 15 | } 16 | return preparedDeliveryMethods; 17 | } 18 | 19 | private prepareDeliveryMethod(deliveryMethod: DeliveryMethod) { 20 | const preparedDeliveryMethod = new DeliveryMethod(); 21 | preparedDeliveryMethod.id = deliveryMethod.id; 22 | preparedDeliveryMethod.name = deliveryMethod.name; 23 | preparedDeliveryMethod.description = deliveryMethod.description; 24 | preparedDeliveryMethod.price = deliveryMethod.price; 25 | return preparedDeliveryMethod; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/delivery-methods.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Importer } from '../../import-export/models/importer.interface'; 3 | import { Collection } from '../../import-export/models/collection.type'; 4 | import { ParseError } from '../../errors/parse.error'; 5 | import { IdMap } from '../../import-export/models/id-map.type'; 6 | import { DeliveryMethodsService } from './delivery-methods.service'; 7 | import { DeliveryMethod } from './models/delivery-method.entity'; 8 | 9 | @Injectable() 10 | export class DeliveryMethodsImporter implements Importer { 11 | constructor(private deliveryMethodsService: DeliveryMethodsService) {} 12 | 13 | async import(deliveryMethods: Collection): Promise { 14 | const parsedDeliveryMethods = this.parseDeliveryMethods(deliveryMethods); 15 | const idMap: IdMap = {}; 16 | for (const deliveryMethod of parsedDeliveryMethods) { 17 | const { id, ...createDto } = deliveryMethod; 18 | const { id: newId } = await this.deliveryMethodsService.createMethod( 19 | createDto, 20 | ); 21 | idMap[deliveryMethod.id] = newId; 22 | } 23 | return idMap; 24 | } 25 | 26 | async clear() { 27 | const deliveryMethods = await this.deliveryMethodsService.getMethods(); 28 | let deleted = 0; 29 | for (const deliveryMethod of deliveryMethods) { 30 | await this.deliveryMethodsService.deleteMethod(deliveryMethod.id); 31 | deleted += 1; 32 | } 33 | return deleted; 34 | } 35 | 36 | private parseDeliveryMethods(deliveryMethods: Collection) { 37 | const parsedDeliveryMethods: DeliveryMethod[] = []; 38 | for (const deliveryMethod of deliveryMethods) { 39 | parsedDeliveryMethods.push(this.parseDeliveryMethod(deliveryMethod)); 40 | } 41 | return parsedDeliveryMethods; 42 | } 43 | 44 | private parseDeliveryMethod(deliveryMethod: Collection[number]) { 45 | const parsedDeliveryMethod = new DeliveryMethod(); 46 | try { 47 | parsedDeliveryMethod.id = deliveryMethod.id as number; 48 | parsedDeliveryMethod.name = deliveryMethod.name as string; 49 | parsedDeliveryMethod.description = deliveryMethod.description as string; 50 | parsedDeliveryMethod.price = deliveryMethod.price as number; 51 | } catch (e) { 52 | throw new ParseError('deliveryMethod'); 53 | } 54 | return parsedDeliveryMethod; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/delivery-methods.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DeliveryMethodsController } from './delivery-methods.controller'; 3 | import { DeliveryMethodsService } from './delivery-methods.service'; 4 | import { DeliveryMethod } from './models/delivery-method.entity'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { DeliveryMethodsExporter } from './delivery-methods.exporter'; 7 | import { DeliveryMethodsImporter } from './delivery-methods.importer'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([DeliveryMethod])], 11 | controllers: [DeliveryMethodsController], 12 | providers: [ 13 | DeliveryMethodsService, 14 | DeliveryMethodsExporter, 15 | DeliveryMethodsImporter, 16 | ], 17 | exports: [ 18 | DeliveryMethodsService, 19 | DeliveryMethodsExporter, 20 | DeliveryMethodsImporter, 21 | ], 22 | }) 23 | export class DeliveryMethodsModule {} 24 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/delivery-methods.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Repository } from 'typeorm'; 3 | import { DeliveryMethod } from './models/delivery-method.entity'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { DeliveryMethodDto } from './dto/delivery-method.dto'; 6 | import { NotFoundError } from '../../errors/not-found.error'; 7 | 8 | @Injectable() 9 | export class DeliveryMethodsService { 10 | constructor( 11 | @InjectRepository(DeliveryMethod) 12 | private readonly deliveryMethodsRepository: Repository, 13 | ) {} 14 | 15 | async getMethods(): Promise { 16 | return this.deliveryMethodsRepository.find(); 17 | } 18 | 19 | async getMethod(id: number): Promise { 20 | const method = await this.deliveryMethodsRepository.findOne({ 21 | where: { id }, 22 | }); 23 | if (!method) { 24 | throw new NotFoundError('delivery method', 'id', id.toString()); 25 | } 26 | return method; 27 | } 28 | 29 | async createMethod(methodData: DeliveryMethodDto): Promise { 30 | const method = new DeliveryMethod(); 31 | method.name = methodData.name; 32 | method.description = methodData.description; 33 | method.price = methodData.price; 34 | return this.deliveryMethodsRepository.save(method); 35 | } 36 | 37 | async updateMethod( 38 | id: number, 39 | methodData: DeliveryMethodDto, 40 | ): Promise { 41 | const method = await this.getMethod(id); 42 | method.name = methodData.name; 43 | method.description = methodData.description; 44 | method.price = methodData.price; 45 | return this.deliveryMethodsRepository.save(method); 46 | } 47 | 48 | async deleteMethod(id: number): Promise { 49 | await this.getMethod(id); 50 | await this.deliveryMethodsRepository.delete({ id }); 51 | return true; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/dto/delivery-method.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; 2 | 3 | export class DeliveryMethodDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | 8 | @IsString() 9 | description: string; 10 | 11 | @IsNumber() 12 | @IsNotEmpty() 13 | price: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/sales/delivery-methods/models/delivery-method.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('delivery_methods') 4 | export class DeliveryMethod { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | name: string; 10 | 11 | @Column() 12 | description: string; 13 | 14 | @Column({ type: 'double precision' }) 15 | price: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/sales/orders/dto/order-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsNotEmptyObject, 5 | IsOptional, 6 | IsPhoneNumber, 7 | IsString, 8 | ValidateNested, 9 | } from 'class-validator'; 10 | import { OrderItemDto } from './order-item.dto'; 11 | import { Type } from 'class-transformer'; 12 | import { OrderDeliveryDto } from './order-delivery.dto'; 13 | import { OrderPaymentDto } from './order-payment.dto'; 14 | 15 | export class OrderCreateDto { 16 | @IsNotEmpty({ each: true }) 17 | @ValidateNested({ each: true }) 18 | @IsNotEmptyObject({ nullable: false }, { each: true }) 19 | @Type(() => OrderItemDto) 20 | items: OrderItemDto[]; 21 | 22 | @IsString() 23 | @IsNotEmpty() 24 | fullName: string; 25 | 26 | @IsEmail() 27 | @IsNotEmpty() 28 | contactEmail: string; 29 | 30 | @IsPhoneNumber() 31 | @IsNotEmpty() 32 | contactPhone: string; 33 | 34 | @IsString() 35 | @IsOptional() 36 | message?: string; 37 | 38 | @IsNotEmpty() 39 | @IsNotEmptyObject() 40 | @ValidateNested() 41 | @Type(() => OrderDeliveryDto) 42 | delivery: OrderDeliveryDto; 43 | 44 | @IsNotEmpty() 45 | @IsNotEmptyObject() 46 | @ValidateNested() 47 | @Type(() => OrderPaymentDto) 48 | payment: OrderPaymentDto; 49 | } 50 | -------------------------------------------------------------------------------- /src/sales/orders/dto/order-delivery.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsInt, 3 | IsISO31661Alpha2, 4 | IsNotEmpty, 5 | IsOptional, 6 | IsPostalCode, 7 | IsString, 8 | } from 'class-validator'; 9 | 10 | export class OrderDeliveryDto { 11 | @IsInt() 12 | @IsNotEmpty() 13 | methodId: number; 14 | 15 | @IsOptional() 16 | deliveryStatus?: string; 17 | 18 | @IsString() 19 | @IsNotEmpty() 20 | address: string; 21 | 22 | @IsString() 23 | @IsNotEmpty() 24 | city: string; 25 | 26 | @IsOptional() 27 | @IsString() 28 | @IsPostalCode('any') 29 | postalCode?: string; 30 | 31 | @IsString() 32 | @IsNotEmpty() 33 | @IsISO31661Alpha2() 34 | country: string; 35 | } 36 | -------------------------------------------------------------------------------- /src/sales/orders/dto/order-item.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsPositive } from 'class-validator'; 2 | 3 | export class OrderItemDto { 4 | @IsNumber() 5 | @IsNotEmpty() 6 | productId: number; 7 | 8 | @IsNumber() 9 | @IsNotEmpty() 10 | @IsPositive() 11 | quantity: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/sales/orders/dto/order-payment.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsOptional } from 'class-validator'; 2 | 3 | export class OrderPaymentDto { 4 | @IsInt() 5 | @IsNotEmpty() 6 | methodId: number; 7 | 8 | @IsOptional() 9 | paymentStatus?: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/sales/orders/dto/order-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsEnum, 4 | IsNotEmpty, 5 | IsOptional, 6 | IsPhoneNumber, 7 | IsString, 8 | ValidateNested, 9 | } from 'class-validator'; 10 | import { OrderStatus } from '../models/order-status.enum'; 11 | import { OrderItemDto } from './order-item.dto'; 12 | import { Type } from 'class-transformer'; 13 | import { OrderDeliveryDto } from './order-delivery.dto'; 14 | import { OrderPaymentDto } from './order-payment.dto'; 15 | 16 | export class OrderUpdateDto { 17 | @IsNotEmpty({ each: true }) 18 | @ValidateNested({ each: true }) 19 | @Type(() => OrderItemDto) 20 | @IsOptional() 21 | items?: OrderItemDto[]; 22 | 23 | @IsString() 24 | @IsOptional() 25 | fullName?: string; 26 | 27 | @IsEmail() 28 | @IsOptional() 29 | contactEmail?: string; 30 | 31 | @IsPhoneNumber() 32 | @IsOptional() 33 | contactPhone?: string; 34 | 35 | @IsString() 36 | @IsOptional() 37 | message?: string; 38 | 39 | @IsEnum(OrderStatus) 40 | @IsOptional() 41 | status?: OrderStatus; 42 | 43 | @IsOptional() 44 | @ValidateNested() 45 | @Type(() => OrderDeliveryDto) 46 | delivery?: OrderDeliveryDto; 47 | 48 | @IsOptional() 49 | @ValidateNested() 50 | @Type(() => OrderPaymentDto) 51 | payment?: OrderPaymentDto; 52 | } 53 | -------------------------------------------------------------------------------- /src/sales/orders/models/order-delivery.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | ManyToOne, 5 | OneToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { Order } from './order.entity'; 9 | import { DeliveryMethod } from '../../delivery-methods/models/delivery-method.entity'; 10 | 11 | @Entity('order_deliveries') 12 | export class OrderDelivery { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @OneToOne(() => Order, (order) => order.delivery, { 17 | onDelete: 'CASCADE', 18 | orphanedRowAction: 'delete', 19 | }) 20 | order: Order; 21 | 22 | @ManyToOne(() => DeliveryMethod, { 23 | eager: true, 24 | onDelete: 'SET NULL', 25 | orphanedRowAction: 'nullify', 26 | }) 27 | method: DeliveryMethod; 28 | 29 | @Column({ default: '' }) 30 | deliveryStatus: string; 31 | 32 | @Column() 33 | address: string; 34 | 35 | @Column() 36 | city: string; 37 | 38 | @Column({ nullable: true }) 39 | postalCode?: string; 40 | 41 | @Column() 42 | country: string; 43 | } 44 | -------------------------------------------------------------------------------- /src/sales/orders/models/order-item.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { Order } from './order.entity'; 3 | import { Product } from '../../../catalog/products/models/product.entity'; 4 | 5 | @Entity('order_items') 6 | export class OrderItem { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @ManyToOne(() => Order, (order) => order.items, { 11 | onDelete: 'CASCADE', 12 | orphanedRowAction: 'delete', 13 | }) 14 | order: Order; 15 | 16 | @ManyToOne(() => Product) 17 | product: Product; 18 | 19 | @Column() 20 | quantity: number; 21 | 22 | @Column({ type: 'double precision' }) 23 | price: number; 24 | } 25 | -------------------------------------------------------------------------------- /src/sales/orders/models/order-payment.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | ManyToOne, 5 | OneToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { Order } from './order.entity'; 9 | import { PaymentMethod } from '../../payment-methods/models/payment-method.entity'; 10 | 11 | @Entity('order_payments') 12 | export class OrderPayment { 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @OneToOne(() => Order, (order) => order.payment, { 17 | onDelete: 'CASCADE', 18 | orphanedRowAction: 'delete', 19 | }) 20 | order: Order; 21 | 22 | @ManyToOne(() => PaymentMethod, { 23 | eager: true, 24 | onDelete: 'SET NULL', 25 | orphanedRowAction: 'nullify', 26 | }) 27 | method: PaymentMethod; 28 | 29 | @Column({ default: '' }) 30 | paymentStatus: string; 31 | } 32 | -------------------------------------------------------------------------------- /src/sales/orders/models/order-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum OrderStatus { 2 | Pending = 'pending', 3 | Failed = 'failed', 4 | Confirmed = 'confirmed', 5 | Open = 'open', 6 | Cancelled = 'cancelled', 7 | Delivered = 'delivered', 8 | Refunded = 'refunded', 9 | } 10 | -------------------------------------------------------------------------------- /src/sales/orders/models/order.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | OneToMany, 8 | OneToOne, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import { User } from '../../../users/models/user.entity'; 13 | import { OrderStatus } from './order-status.enum'; 14 | import { OrderItem } from './order-item.entity'; 15 | import { OrderDelivery } from './order-delivery.entity'; 16 | import { OrderPayment } from './order-payment.entity'; 17 | import { Return } from '../../returns/models/return.entity'; 18 | 19 | @Entity('orders') 20 | export class Order { 21 | @PrimaryGeneratedColumn() 22 | id: number; 23 | 24 | @CreateDateColumn() 25 | created: Date; 26 | 27 | @UpdateDateColumn() 28 | updated: Date; 29 | 30 | @ManyToOne(() => User, { 31 | nullable: true, 32 | onDelete: 'SET NULL', 33 | }) 34 | user?: User; 35 | 36 | @OneToMany(() => OrderItem, (item) => item.order, { 37 | cascade: true, 38 | }) 39 | items: OrderItem[]; 40 | 41 | @Column({ 42 | type: 'enum', 43 | enum: OrderStatus, 44 | default: OrderStatus.Pending, 45 | }) 46 | status: OrderStatus; 47 | 48 | @OneToOne(() => OrderDelivery, (delivery) => delivery.order, { 49 | cascade: true, 50 | onUpdate: 'CASCADE', 51 | onDelete: 'RESTRICT', 52 | nullable: false, 53 | }) 54 | @JoinColumn() 55 | delivery: OrderDelivery; 56 | 57 | @OneToOne(() => OrderPayment, (payment) => payment.order, { 58 | cascade: true, 59 | onUpdate: 'CASCADE', 60 | onDelete: 'RESTRICT', 61 | nullable: false, 62 | }) 63 | @JoinColumn() 64 | payment: OrderPayment; 65 | 66 | @Column() 67 | fullName: string; 68 | 69 | @Column() 70 | contactEmail: string; 71 | 72 | @Column() 73 | contactPhone: string; 74 | 75 | @Column({ nullable: true }) 76 | message?: string; 77 | 78 | @OneToOne(() => Return, (r) => r.order, { 79 | nullable: true, 80 | }) 81 | return?: Return; 82 | } 83 | -------------------------------------------------------------------------------- /src/sales/orders/orders.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { Order } from './models/order.entity'; 4 | import { OrdersService } from './orders.service'; 5 | import { OrderDelivery } from './models/order-delivery.entity'; 6 | import { OrderPayment } from './models/order-payment.entity'; 7 | import { OrderItem } from './models/order-item.entity'; 8 | 9 | @Injectable() 10 | export class OrdersExporter implements Exporter { 11 | constructor(private ordersService: OrdersService) {} 12 | 13 | async export(): Promise { 14 | const orders = await this.ordersService.getOrders(true, true); 15 | const preparedOrders: Order[] = []; 16 | for (const order of orders) { 17 | preparedOrders.push(this.prepareOrder(order)); 18 | } 19 | return preparedOrders; 20 | } 21 | 22 | private prepareOrder(order: Order) { 23 | const preparedOrder = new Order() as any; 24 | preparedOrder.id = order.id; 25 | preparedOrder.created = order.created; 26 | preparedOrder.updated = order.updated; 27 | preparedOrder.userId = order.user?.id ?? null; 28 | preparedOrder.status = order.status; 29 | preparedOrder.fullName = order.fullName; 30 | preparedOrder.contactPhone = order.contactPhone; 31 | preparedOrder.contactEmail = order.contactEmail; 32 | preparedOrder.message = order.message; 33 | preparedOrder.delivery = this.prepareOrderDelivery(order.delivery); 34 | preparedOrder.payment = this.prepareOrderPayment(order.payment); 35 | preparedOrder.items = order.items.map((item) => 36 | this.prepareOrderItem(item), 37 | ); 38 | return preparedOrder; 39 | } 40 | 41 | private prepareOrderDelivery(delivery: OrderDelivery) { 42 | const preparedDelivery = new OrderDelivery() as any; 43 | preparedDelivery.methodId = delivery.method.id; 44 | preparedDelivery.deliveryStatus = delivery.deliveryStatus; 45 | preparedDelivery.address = delivery.address; 46 | preparedDelivery.city = delivery.city; 47 | preparedDelivery.postalCode = delivery.postalCode; 48 | preparedDelivery.country = delivery.country; 49 | return preparedDelivery; 50 | } 51 | 52 | private prepareOrderPayment(payment: OrderPayment) { 53 | const preparedPayment = new OrderPayment() as any; 54 | preparedPayment.methodId = payment.method.id; 55 | preparedPayment.paymentStatus = payment.paymentStatus; 56 | return preparedPayment; 57 | } 58 | 59 | private prepareOrderItem(item: OrderItem) { 60 | const preparedItem = new OrderItem() as any; 61 | preparedItem.productId = item.product.id; 62 | preparedItem.quantity = item.quantity; 63 | preparedItem.price = item.price; 64 | return preparedItem; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/sales/orders/orders.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OrdersService } from './orders.service'; 3 | import { OrdersController } from './orders.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Order } from './models/order.entity'; 6 | import { UsersModule } from '../../users/users.module'; 7 | import { OrderItem } from './models/order-item.entity'; 8 | import { OrderDelivery } from './models/order-delivery.entity'; 9 | import { OrderPayment } from './models/order-payment.entity'; 10 | import { OrdersSubscriber } from './orders.subscriber'; 11 | import { CatalogModule } from '../../catalog/catalog.module'; 12 | import { DeliveryMethodsModule } from '../delivery-methods/delivery-methods.module'; 13 | import { PaymentMethodsModule } from '../payment-methods/payment-methods.module'; 14 | import { OrdersExporter } from './orders.exporter'; 15 | import { OrdersImporter } from './orders.importer'; 16 | 17 | @Module({ 18 | imports: [ 19 | TypeOrmModule.forFeature([Order, OrderItem, OrderDelivery, OrderPayment]), 20 | UsersModule, 21 | CatalogModule, 22 | DeliveryMethodsModule, 23 | PaymentMethodsModule, 24 | ], 25 | providers: [OrdersService, OrdersSubscriber, OrdersExporter, OrdersImporter], 26 | controllers: [OrdersController], 27 | exports: [OrdersService, OrdersExporter, OrdersImporter], 28 | }) 29 | export class OrdersModule {} 30 | -------------------------------------------------------------------------------- /src/sales/orders/orders.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterUpdate, 3 | BeforeInsert, 4 | DataSource, 5 | EntitySubscriberInterface, 6 | EventSubscriber, 7 | InsertEvent, 8 | UpdateEvent, 9 | } from 'typeorm'; 10 | import { Order } from './models/order.entity'; 11 | import { ProductsService } from '../../catalog/products/products.service'; 12 | import { OrderItem } from './models/order-item.entity'; 13 | import { OrderStatus } from './models/order-status.enum'; 14 | 15 | @EventSubscriber() 16 | export class OrdersSubscriber implements EntitySubscriberInterface { 17 | constructor( 18 | dataSource: DataSource, 19 | private productsService: ProductsService, 20 | ) { 21 | dataSource.subscribers.push(this); 22 | } 23 | 24 | listenTo() { 25 | return Order; 26 | } 27 | 28 | @BeforeInsert() 29 | async beforeInsert(event: InsertEvent) { 30 | if (!(await this.productsService.checkProductsStocks(event.entity.items))) { 31 | event.entity.status = OrderStatus.Failed; 32 | return; 33 | } 34 | await this.productsService.updateProductsStocks( 35 | 'subtract', 36 | event.entity.items, 37 | ); 38 | } 39 | 40 | @AfterUpdate() 41 | async afterUpdate(event: UpdateEvent) { 42 | if (!event.entity) { 43 | return; 44 | } 45 | if ( 46 | event.databaseEntity.items.map((i) => i.id).join(',') !== 47 | event.entity.items.map((i: OrderItem) => i.id).join(',') 48 | ) { 49 | if ( 50 | !(await this.productsService.checkProductsStocks(event.entity.items)) 51 | ) { 52 | event.entity.status = OrderStatus.Failed; 53 | return; 54 | } 55 | await this.productsService.updateProductsStocks( 56 | 'subtract', 57 | event.entity.items, 58 | ); 59 | } 60 | if ( 61 | ['pending', 'confirmed', 'open', 'delivered'].includes( 62 | event.databaseEntity.status, 63 | ) && 64 | ['failed', 'cancelled', 'refunded'].includes(event.entity.status) 65 | ) { 66 | await this.productsService.updateProductsStocks( 67 | 'add', 68 | event.entity.items, 69 | ); 70 | } 71 | if ( 72 | ['failed', 'cancelled', 'refunded'].includes( 73 | event.databaseEntity.status, 74 | ) && 75 | ['pending', 'confirmed', 'open', 'delivered'].includes( 76 | event.entity.status, 77 | ) 78 | ) { 79 | if ( 80 | !(await this.productsService.checkProductsStocks(event.entity.items)) 81 | ) { 82 | event.entity.status = OrderStatus.Failed; 83 | return; 84 | } 85 | await this.productsService.updateProductsStocks( 86 | 'subtract', 87 | event.entity.items, 88 | ); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/sales/payment-methods/dto/payment-method.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; 2 | 3 | export class PaymentMethodDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | 8 | @IsString() 9 | description: string; 10 | 11 | @IsNumber() 12 | @IsNotEmpty() 13 | price: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/sales/payment-methods/models/payment-method.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('payment_methods') 4 | export class PaymentMethod { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | name: string; 10 | 11 | @Column() 12 | description: string; 13 | 14 | @Column({ type: 'double precision' }) 15 | price: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/sales/payment-methods/payment-methods.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Post, 9 | Put, 10 | } from '@nestjs/common'; 11 | import { PaymentMethodsService } from './payment-methods.service'; 12 | import { PaymentMethod } from './models/payment-method.entity'; 13 | import { PaymentMethodDto } from './dto/payment-method.dto'; 14 | import { Role } from '../../users/models/role.enum'; 15 | import { Roles } from '../../auth/decorators/roles.decorator'; 16 | import { 17 | ApiBadRequestResponse, 18 | ApiCreatedResponse, 19 | ApiForbiddenResponse, 20 | ApiNotFoundResponse, 21 | ApiOkResponse, 22 | ApiTags, 23 | ApiUnauthorizedResponse, 24 | } from '@nestjs/swagger'; 25 | 26 | @ApiTags('payment methods') 27 | @Controller('payment-methods') 28 | export class PaymentMethodsController { 29 | constructor(private readonly paymentMethodsService: PaymentMethodsService) {} 30 | 31 | @Get() 32 | @ApiOkResponse({ 33 | type: [PaymentMethod], 34 | description: 'List all payment methods', 35 | }) 36 | async getPaymentMethods(): Promise { 37 | return this.paymentMethodsService.getMethods(); 38 | } 39 | 40 | @Post() 41 | @Roles(Role.Admin) 42 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 43 | @ApiForbiddenResponse({ description: 'User not authorized' }) 44 | @ApiBadRequestResponse({ description: 'Invalid payment method data' }) 45 | @ApiCreatedResponse({ 46 | type: PaymentMethod, 47 | description: 'Payment method created', 48 | }) 49 | async createPaymentMethod( 50 | @Body() methodData: PaymentMethodDto, 51 | ): Promise { 52 | return this.paymentMethodsService.createMethod(methodData); 53 | } 54 | 55 | @Put(':id') 56 | @Roles(Role.Admin) 57 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 58 | @ApiForbiddenResponse({ description: 'User not authorized' }) 59 | @ApiNotFoundResponse({ description: 'Payment method not found' }) 60 | @ApiBadRequestResponse({ description: 'Invalid payment method data' }) 61 | @ApiOkResponse({ type: PaymentMethod, description: 'Payment method updated' }) 62 | async updatePaymentMethod( 63 | @Param('id', ParseIntPipe) id: number, 64 | @Body() methodData: PaymentMethodDto, 65 | ): Promise { 66 | return await this.paymentMethodsService.updateMethod(id, methodData); 67 | } 68 | 69 | @Delete(':id') 70 | @Roles(Role.Admin) 71 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 72 | @ApiForbiddenResponse({ description: 'User not authorized' }) 73 | @ApiNotFoundResponse({ description: 'Payment method not found' }) 74 | @ApiOkResponse({ description: 'Payment method deleted' }) 75 | async deletePaymentMethod( 76 | @Param('id', ParseIntPipe) id: number, 77 | ): Promise { 78 | await this.paymentMethodsService.deleteMethod(id); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/sales/payment-methods/payment-methods.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { PaymentMethod } from './models/payment-method.entity'; 4 | import { PaymentMethodsService } from './payment-methods.service'; 5 | 6 | @Injectable() 7 | export class PaymentMethodsExporter implements Exporter { 8 | constructor(private paymentMethodsService: PaymentMethodsService) {} 9 | 10 | async export(): Promise { 11 | const paymentMethods = await this.paymentMethodsService.getMethods(); 12 | const preparedPaymentMethods: PaymentMethod[] = []; 13 | for (const paymentMethod of paymentMethods) { 14 | preparedPaymentMethods.push(this.preparePaymentMethod(paymentMethod)); 15 | } 16 | return preparedPaymentMethods; 17 | } 18 | 19 | private preparePaymentMethod(paymentMethod: PaymentMethod) { 20 | const preparedPaymentMethod = new PaymentMethod(); 21 | preparedPaymentMethod.id = paymentMethod.id; 22 | preparedPaymentMethod.name = paymentMethod.name; 23 | preparedPaymentMethod.description = paymentMethod.description; 24 | preparedPaymentMethod.price = paymentMethod.price; 25 | return preparedPaymentMethod; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/sales/payment-methods/payment-methods.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Importer } from '../../import-export/models/importer.interface'; 3 | import { Collection } from '../../import-export/models/collection.type'; 4 | import { ParseError } from '../../errors/parse.error'; 5 | import { IdMap } from '../../import-export/models/id-map.type'; 6 | import { PaymentMethodsService } from './payment-methods.service'; 7 | import { PaymentMethod } from './models/payment-method.entity'; 8 | 9 | @Injectable() 10 | export class PaymentMethodsImporter implements Importer { 11 | constructor(private paymentMethodsService: PaymentMethodsService) {} 12 | 13 | async import(paymentMethods: Collection): Promise { 14 | const parsedPaymentMethods = this.parsePaymentMethods(paymentMethods); 15 | const idMap: IdMap = {}; 16 | for (const paymentMethod of parsedPaymentMethods) { 17 | const { id, ...createDto } = paymentMethod; 18 | const { id: newId } = await this.paymentMethodsService.createMethod( 19 | createDto, 20 | ); 21 | idMap[paymentMethod.id] = newId; 22 | } 23 | return idMap; 24 | } 25 | 26 | async clear() { 27 | const paymentMethods = await this.paymentMethodsService.getMethods(); 28 | let deleted = 0; 29 | for (const paymentMethod of paymentMethods) { 30 | await this.paymentMethodsService.deleteMethod(paymentMethod.id); 31 | deleted += 1; 32 | } 33 | return deleted; 34 | } 35 | 36 | private parsePaymentMethods(paymentMethods: Collection) { 37 | const parsedPaymentMethods: PaymentMethod[] = []; 38 | for (const paymentMethod of paymentMethods) { 39 | parsedPaymentMethods.push(this.parsePaymentMethod(paymentMethod)); 40 | } 41 | return parsedPaymentMethods; 42 | } 43 | 44 | private parsePaymentMethod(paymentMethod: Collection[number]) { 45 | const parsedPaymentMethod = new PaymentMethod(); 46 | try { 47 | parsedPaymentMethod.id = paymentMethod.id as number; 48 | parsedPaymentMethod.name = paymentMethod.name as string; 49 | parsedPaymentMethod.description = paymentMethod.description as string; 50 | parsedPaymentMethod.price = paymentMethod.price as number; 51 | } catch (e) { 52 | throw new ParseError('paymentMethod'); 53 | } 54 | return parsedPaymentMethod; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/sales/payment-methods/payment-methods.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PaymentMethodsService } from './payment-methods.service'; 3 | import { PaymentMethodsController } from './payment-methods.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { PaymentMethod } from './models/payment-method.entity'; 6 | import { PaymentMethodsExporter } from './payment-methods.exporter'; 7 | import { PaymentMethodsImporter } from './payment-methods.importer'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([PaymentMethod])], 11 | providers: [ 12 | PaymentMethodsService, 13 | PaymentMethodsExporter, 14 | PaymentMethodsImporter, 15 | ], 16 | controllers: [PaymentMethodsController], 17 | exports: [ 18 | PaymentMethodsService, 19 | PaymentMethodsExporter, 20 | PaymentMethodsImporter, 21 | ], 22 | }) 23 | export class PaymentMethodsModule {} 24 | -------------------------------------------------------------------------------- /src/sales/payment-methods/payment-methods.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PaymentMethod } from './models/payment-method.entity'; 3 | import { Repository } from 'typeorm'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { PaymentMethodDto } from './dto/payment-method.dto'; 6 | import { NotFoundError } from '../../errors/not-found.error'; 7 | 8 | @Injectable() 9 | export class PaymentMethodsService { 10 | constructor( 11 | @InjectRepository(PaymentMethod) 12 | private readonly paymentMethodsRepository: Repository, 13 | ) {} 14 | 15 | async getMethods(): Promise { 16 | return this.paymentMethodsRepository.find(); 17 | } 18 | 19 | async getMethod(id: number): Promise { 20 | const method = await this.paymentMethodsRepository.findOne({ 21 | where: { id }, 22 | }); 23 | if (!method) { 24 | throw new NotFoundError('payment method', 'id', id.toString()); 25 | } 26 | return method; 27 | } 28 | 29 | async createMethod(methodData: PaymentMethodDto): Promise { 30 | const method = new PaymentMethod(); 31 | method.name = methodData.name; 32 | method.description = methodData.description; 33 | method.price = methodData.price; 34 | return this.paymentMethodsRepository.save(method); 35 | } 36 | 37 | async updateMethod( 38 | id: number, 39 | methodData: PaymentMethodDto, 40 | ): Promise { 41 | const method = await this.getMethod(id); 42 | Object.assign(method, methodData); 43 | return this.paymentMethodsRepository.save(method); 44 | } 45 | 46 | async deleteMethod(id: number): Promise { 47 | await this.getMethod(id); 48 | await this.paymentMethodsRepository.delete({ id }); 49 | return true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/sales/returns/dto/return-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class ReturnCreateDto { 4 | @IsInt() 5 | @IsNotEmpty() 6 | orderId: number; 7 | 8 | @IsString() 9 | message: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/sales/returns/dto/return-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsOptional, IsString } from 'class-validator'; 2 | import { ReturnStatus } from '../models/return-status.enum'; 3 | 4 | export class ReturnUpdateDto { 5 | @IsString() 6 | @IsOptional() 7 | message?: string; 8 | 9 | @IsString() 10 | @IsOptional() 11 | @IsEnum(ReturnStatus) 12 | status?: ReturnStatus; 13 | } 14 | -------------------------------------------------------------------------------- /src/sales/returns/models/return-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ReturnStatus { 2 | Open = 'open', 3 | Accepted = 'accepted', 4 | Rejected = 'rejected', 5 | Cancelled = 'cancelled', 6 | Completed = 'completed', 7 | } 8 | -------------------------------------------------------------------------------- /src/sales/returns/models/return.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinColumn, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { Order } from '../../orders/models/order.entity'; 11 | import { ReturnStatus } from './return-status.enum'; 12 | 13 | @Entity('returns') 14 | export class Return { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @CreateDateColumn() 19 | created: Date; 20 | 21 | @UpdateDateColumn() 22 | updated: Date; 23 | 24 | @OneToOne(() => Order) 25 | @JoinColumn() 26 | order: Order; 27 | 28 | @Column({ nullable: true }) 29 | message?: string; 30 | 31 | @Column({ 32 | type: 'enum', 33 | enum: ReturnStatus, 34 | default: ReturnStatus.Open, 35 | }) 36 | status: ReturnStatus; 37 | } 38 | -------------------------------------------------------------------------------- /src/sales/returns/returns.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | ForbiddenException, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | } from '@nestjs/common'; 11 | import { ReturnsService } from './returns.service'; 12 | import { Roles } from '../../auth/decorators/roles.decorator'; 13 | import { Return } from './models/return.entity'; 14 | import { User } from '../../users/models/user.entity'; 15 | import { ReqUser } from '../../auth/decorators/user.decorator'; 16 | import { ReturnCreateDto } from './dto/return-create.dto'; 17 | import { ReturnUpdateDto } from './dto/return-update.dto'; 18 | import { Role } from '../../users/models/role.enum'; 19 | import { 20 | ApiBadRequestResponse, 21 | ApiCreatedResponse, 22 | ApiForbiddenResponse, 23 | ApiNotFoundResponse, 24 | ApiOkResponse, 25 | ApiTags, 26 | ApiUnauthorizedResponse, 27 | } from '@nestjs/swagger'; 28 | import { OrdersService } from '../orders/orders.service'; 29 | 30 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 31 | @ApiForbiddenResponse({ description: 'User not authorized' }) 32 | @ApiTags('returns') 33 | @Controller('returns') 34 | export class ReturnsController { 35 | constructor( 36 | private returnsService: ReturnsService, 37 | private ordersService: OrdersService, 38 | ) {} 39 | 40 | @Get() 41 | @Roles(Role.Admin, Role.Manager, Role.Sales) 42 | @ApiOkResponse({ type: [Return], description: 'List all returns' }) 43 | async getReturns(): Promise { 44 | return this.returnsService.getReturns(); 45 | } 46 | 47 | @Get('/:id') 48 | @Roles(Role.Admin, Role.Manager, Role.Sales, Role.Customer) 49 | @ApiNotFoundResponse({ description: 'Return not found' }) 50 | @ApiOkResponse({ type: Return, description: 'Return with given id' }) 51 | async getReturn( 52 | @ReqUser() user: User, 53 | @Param('id', ParseIntPipe) id: number, 54 | ): Promise { 55 | const checkUser = await this.returnsService.checkReturnUser(user.id, id); 56 | if (!checkUser && user.role === Role.Customer) { 57 | throw new ForbiddenException(); 58 | } 59 | return await this.returnsService.getReturn(id); 60 | } 61 | 62 | @Post('') 63 | @ApiBadRequestResponse({ description: 'Invalid return data' }) 64 | @ApiCreatedResponse({ type: Return, description: 'Return created' }) 65 | @Roles(Role.Admin, Role.Manager, Role.Sales, Role.Customer) 66 | async createReturn( 67 | @ReqUser() user: User, 68 | @Body() body: ReturnCreateDto, 69 | ): Promise { 70 | const checkUser = await this.ordersService.checkOrderUser( 71 | user.id, 72 | body.orderId, 73 | ); 74 | if (!checkUser && (!user.role || user.role === Role.Customer)) { 75 | throw new ForbiddenException(); 76 | } 77 | return await this.returnsService.createReturn(body); 78 | } 79 | 80 | @Patch('/:id') 81 | @ApiNotFoundResponse({ description: 'Return not found' }) 82 | @ApiBadRequestResponse({ description: 'Invalid return data' }) 83 | @ApiOkResponse({ type: Return, description: 'Return updated' }) 84 | @Roles(Role.Admin, Role.Manager, Role.Sales) 85 | async updateReturn( 86 | @Param('id', ParseIntPipe) id: number, 87 | @Body() body: ReturnUpdateDto, 88 | ): Promise { 89 | return await this.returnsService.updateReturn(id, body); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/sales/returns/returns.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../../import-export/models/exporter.interface'; 3 | import { Return } from './models/return.entity'; 4 | import { ReturnsService } from './returns.service'; 5 | 6 | @Injectable() 7 | export class ReturnsExporter implements Exporter { 8 | constructor(private returnsService: ReturnsService) {} 9 | 10 | async export(): Promise { 11 | const returns = await this.returnsService.getReturns(); 12 | const preparedReturns: Return[] = []; 13 | for (const r of returns) { 14 | preparedReturns.push(this.prepareReturn(r)); 15 | } 16 | return preparedReturns; 17 | } 18 | 19 | private prepareReturn(r: Return) { 20 | const preparedReturn = new Return() as any; 21 | preparedReturn.id = r.id; 22 | preparedReturn.created = r.created; 23 | preparedReturn.updated = r.updated; 24 | preparedReturn.message = r.message; 25 | preparedReturn.status = r.status; 26 | preparedReturn.orderId = r.order.id; 27 | return preparedReturn; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/sales/returns/returns.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Importer } from '../../import-export/models/importer.interface'; 3 | import { Collection } from '../../import-export/models/collection.type'; 4 | import { ParseError } from '../../errors/parse.error'; 5 | import { IdMap } from '../../import-export/models/id-map.type'; 6 | import { ReturnsService } from './returns.service'; 7 | import { Return } from './models/return.entity'; 8 | import { ReturnStatus } from './models/return-status.enum'; 9 | import { ReturnCreateDto } from './dto/return-create.dto'; 10 | 11 | @Injectable() 12 | export class ReturnsImporter implements Importer { 13 | constructor(private returnsService: ReturnsService) {} 14 | 15 | async import( 16 | returns: Collection, 17 | idMaps: Record, 18 | ): Promise { 19 | const parsedReturns = this.parseReturns(returns, idMaps.orders); 20 | const idMap: IdMap = {}; 21 | for (const r of parsedReturns) { 22 | const { id, status, ...createDto } = r as any; 23 | const { id: newId } = await this.returnsService.createReturn( 24 | createDto, 25 | true, 26 | ); 27 | await this.returnsService.updateReturn(newId, { status }, true); 28 | idMap[r.id] = newId; 29 | } 30 | return idMap; 31 | } 32 | 33 | async clear() { 34 | const returns = await this.returnsService.getReturns(); 35 | let deleted = 0; 36 | for (const r of returns) { 37 | await this.returnsService.deleteReturn(r.id); 38 | deleted += 1; 39 | } 40 | return deleted; 41 | } 42 | 43 | private parseReturns(returns: Collection, ordersIdMap: IdMap) { 44 | const parsedReturns: Return[] = []; 45 | for (const r of returns) { 46 | parsedReturns.push(this.parseReturn(r, ordersIdMap)); 47 | } 48 | return parsedReturns; 49 | } 50 | 51 | private parseReturn(r: Collection[number], ordersIdMap: IdMap) { 52 | const parsedReturn = new ReturnCreateDto() as any; 53 | try { 54 | parsedReturn.id = r.id as number; 55 | parsedReturn.created = new Date(r.created as string); 56 | parsedReturn.updated = new Date(r.updated as string); 57 | parsedReturn.message = r.message as string; 58 | parsedReturn.status = r.status as ReturnStatus; 59 | parsedReturn.orderId = ordersIdMap[r.orderId as number]; 60 | } catch (e) { 61 | throw new ParseError('return'); 62 | } 63 | return parsedReturn; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/sales/returns/returns.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ReturnsService } from './returns.service'; 3 | import { ReturnsController } from './returns.controller'; 4 | import { Return } from './models/return.entity'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { ReturnsSubscriber } from './returns.subscriber'; 7 | import { OrdersModule } from '../orders/orders.module'; 8 | import { ReturnsExporter } from './returns.exporter'; 9 | import { ReturnsImporter } from './returns.importer'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([Return]), OrdersModule], 13 | providers: [ 14 | ReturnsService, 15 | ReturnsSubscriber, 16 | ReturnsExporter, 17 | ReturnsImporter, 18 | ], 19 | controllers: [ReturnsController], 20 | exports: [ReturnsExporter, ReturnsImporter], 21 | }) 22 | export class ReturnsModule {} 23 | -------------------------------------------------------------------------------- /src/sales/returns/returns.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Return } from './models/return.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { ReturnCreateDto } from './dto/return-create.dto'; 6 | import { ReturnUpdateDto } from './dto/return-update.dto'; 7 | import { NotFoundError } from '../../errors/not-found.error'; 8 | import { ConflictError } from '../../errors/conflict.error'; 9 | import { OrdersService } from '../orders/orders.service'; 10 | 11 | @Injectable() 12 | export class ReturnsService { 13 | constructor( 14 | @InjectRepository(Return) 15 | private readonly returnsRepository: Repository, 16 | private ordersService: OrdersService, 17 | ) {} 18 | 19 | async getReturns(): Promise { 20 | return this.returnsRepository.find({ 21 | relations: ['order', 'order.items'], 22 | }); 23 | } 24 | 25 | async getReturn(id: number): Promise { 26 | const foundReturn = await this.returnsRepository.findOne({ 27 | where: { id }, 28 | relations: [ 29 | 'order', 30 | 'order.user', 31 | 'order.items', 32 | 'order.items.product', 33 | 'order.delivery', 34 | 'order.payment', 35 | ], 36 | }); 37 | if (!foundReturn) { 38 | throw new NotFoundError('return', 'id', id.toString()); 39 | } 40 | return foundReturn; 41 | } 42 | 43 | async checkReturnUser(userId: number, id: number): Promise { 44 | const foundReturn = await this.returnsRepository.findOne({ 45 | where: { id, order: { user: { id: userId } } }, 46 | relations: ['order', 'order.user'], 47 | }); 48 | return !!foundReturn; 49 | } 50 | 51 | async createReturn( 52 | returnDto: ReturnCreateDto, 53 | ignoreSubscribers = false, 54 | ): Promise { 55 | const newReturn = new Return(); 56 | const order = await this.ordersService.getOrder(returnDto.orderId); 57 | try { 58 | newReturn.order = order; 59 | newReturn.message = returnDto.message; 60 | return await this.returnsRepository.save(newReturn, { 61 | listeners: !ignoreSubscribers, 62 | }); 63 | } catch (e) { 64 | throw new ConflictError('return'); 65 | } 66 | } 67 | 68 | async updateReturn( 69 | id: number, 70 | returnDto: ReturnUpdateDto, 71 | ignoreSubscribers = false, 72 | ): Promise { 73 | const foundReturn = await this.getReturn(id); 74 | Object.assign(foundReturn, returnDto); 75 | return this.returnsRepository.save(foundReturn, { 76 | listeners: !ignoreSubscribers, 77 | }); 78 | } 79 | 80 | async deleteReturn(id: number): Promise { 81 | await this.getReturn(id); 82 | await this.returnsRepository.delete({ id }); 83 | return true; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/sales/returns/returns.subscriber.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BeforeInsert, 3 | BeforeUpdate, 4 | DataSource, 5 | EntitySubscriberInterface, 6 | EventSubscriber, 7 | InsertEvent, 8 | UpdateEvent, 9 | } from 'typeorm'; 10 | import { Return } from './models/return.entity'; 11 | import { OrdersService } from '../orders/orders.service'; 12 | import { OrderStatus } from '../orders/models/order-status.enum'; 13 | 14 | @EventSubscriber() 15 | export class ReturnsSubscriber implements EntitySubscriberInterface { 16 | constructor(dataSource: DataSource, private ordersService: OrdersService) { 17 | dataSource.subscribers.push(this); 18 | } 19 | 20 | listenTo() { 21 | return Return; 22 | } 23 | 24 | @BeforeInsert() 25 | async beforeInsert(event: InsertEvent) { 26 | await this.ordersService.updateOrder(event.entity.order.id, { 27 | status: OrderStatus.Refunded, 28 | }); 29 | } 30 | 31 | @BeforeUpdate() 32 | async beforeUpdate(event: UpdateEvent) { 33 | if (!event.entity) { 34 | return; 35 | } 36 | if (['rejected', 'cancelled'].includes(event.entity.status)) { 37 | await this.ordersService.updateOrder(event.databaseEntity.order.id, { 38 | status: OrderStatus.Delivered, 39 | }); 40 | } 41 | if (['open', 'accepted', 'completed'].includes(event.entity.status)) { 42 | await this.ordersService.updateOrder(event.databaseEntity.order.id, { 43 | status: OrderStatus.Refunded, 44 | }); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/sales/sales.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DeliveryMethodsModule } from './delivery-methods/delivery-methods.module'; 3 | import { PaymentMethodsModule } from './payment-methods/payment-methods.module'; 4 | import { OrdersModule } from './orders/orders.module'; 5 | import { ReturnsModule } from './returns/returns.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | DeliveryMethodsModule, 10 | PaymentMethodsModule, 11 | OrdersModule, 12 | ReturnsModule, 13 | ], 14 | exports: [ 15 | DeliveryMethodsModule, 16 | PaymentMethodsModule, 17 | OrdersModule, 18 | ReturnsModule, 19 | ], 20 | }) 21 | export class SalesModule {} 22 | -------------------------------------------------------------------------------- /src/settings/builtin-settings.data.ts: -------------------------------------------------------------------------------- 1 | import { SettingCreateDto } from './dto/setting-create.dto'; 2 | import { SettingType } from './models/setting-type.enum'; 3 | 4 | export const BUILTIN_SETTINGS: SettingCreateDto[] = [ 5 | { 6 | builtin: true, 7 | name: 'Currency', 8 | description: 'The currency to use for all prices', 9 | type: SettingType.Currency, 10 | defaultValue: 'USD', 11 | }, 12 | { 13 | builtin: true, 14 | name: 'Countries', 15 | description: 'Possible countries for delivery', 16 | type: SettingType.CountriesList, 17 | defaultValue: 'US,CA', 18 | }, 19 | { 20 | builtin: true, 21 | name: 'Convert images to JPEG', 22 | description: 23 | 'Automatically convert uploaded images to JPEG format (recommended)', 24 | type: SettingType.Boolean, 25 | defaultValue: 'true', 26 | }, 27 | { 28 | builtin: true, 29 | name: 'Thumbnail size', 30 | description: 31 | 'Size of automatically generated thumbnails (in pixels, [SIZE]x[SIZE])', 32 | type: SettingType.Number, 33 | defaultValue: '400', 34 | }, 35 | { 36 | builtin: true, 37 | name: 'Product ratings', 38 | description: 'Allow users to leave product ratings', 39 | type: SettingType.Boolean, 40 | defaultValue: 'true', 41 | }, 42 | { 43 | builtin: true, 44 | name: 'Product rating photos', 45 | description: 'Allow users to upload photos for product ratings', 46 | type: SettingType.Boolean, 47 | defaultValue: 'true', 48 | }, 49 | ]; 50 | -------------------------------------------------------------------------------- /src/settings/dto/setting-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; 2 | import { SettingType } from '../models/setting-type.enum'; 3 | import { ApiProperty } from '@nestjs/swagger'; 4 | 5 | export class SettingCreateDto { 6 | @ApiProperty({ type: 'boolean', required: false }) 7 | @IsBoolean() 8 | builtin = false; 9 | 10 | @IsString() 11 | name: string; 12 | 13 | @IsString() 14 | @IsOptional() 15 | description?: string; 16 | 17 | @IsString() 18 | @IsEnum(SettingType) 19 | type: SettingType; 20 | 21 | @IsString() 22 | defaultValue: string; 23 | 24 | @IsString() 25 | @IsOptional() 26 | value?: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/settings/dto/setting-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class SettingUpdateDto { 4 | @IsString() 5 | value: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/settings/guards/features-enabled.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | NotFoundException, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { FEATURES_KEY } from './features.decorator'; 9 | import { SettingsService } from '../settings.service'; 10 | 11 | @Injectable() 12 | export class FeaturesEnabledGuard implements CanActivate { 13 | constructor( 14 | private readonly reflector: Reflector, 15 | private settingService: SettingsService, 16 | ) {} 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | const features = this.reflector.getAllAndOverride(FEATURES_KEY, [ 20 | context.getHandler(), 21 | context.getClass(), 22 | ]); 23 | if (!features) { 24 | return true; 25 | } 26 | for (const feature of features) { 27 | const enabled = await this.settingService.getSettingValueByName(feature); 28 | if (enabled !== 'true') { 29 | throw new NotFoundException(); 30 | } 31 | } 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/settings/guards/features.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const FEATURES_KEY = 'features'; 4 | export const Features = (...features: string[]) => 5 | SetMetadata(FEATURES_KEY, features); 6 | -------------------------------------------------------------------------------- /src/settings/models/setting-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SettingType { 2 | Boolean = 'boolean', 3 | Number = 'number', 4 | String = 'string', 5 | Currency = 'currencyCode', 6 | CurrenciesList = 'currenciesList', 7 | Country = 'country', 8 | CountriesList = 'countriesList', 9 | } 10 | -------------------------------------------------------------------------------- /src/settings/models/setting.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | Index, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | import { SettingType } from './setting-type.enum'; 9 | 10 | @Entity('settings') 11 | export class Setting { 12 | @PrimaryGeneratedColumn() 13 | id: number; 14 | 15 | @UpdateDateColumn() 16 | updated: Date; 17 | 18 | @Column() 19 | builtin: boolean; 20 | 21 | @Index({ unique: true }) 22 | @Column({ unique: true }) 23 | name: string; 24 | 25 | @Column({ nullable: true }) 26 | description?: string; 27 | 28 | @Column({ 29 | type: 'enum', 30 | enum: SettingType, 31 | default: SettingType.String, 32 | }) 33 | type: SettingType; 34 | 35 | @Column() 36 | defaultValue: string; 37 | 38 | @Column() 39 | value: string; 40 | } 41 | -------------------------------------------------------------------------------- /src/settings/settings.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | ParseIntPipe, 8 | Patch, 9 | Post, 10 | } from '@nestjs/common'; 11 | import { SettingsService } from './settings.service'; 12 | import { Setting } from './models/setting.entity'; 13 | import { SettingCreateDto } from './dto/setting-create.dto'; 14 | import { SettingUpdateDto } from './dto/setting-update.dto'; 15 | import { Roles } from '../auth/decorators/roles.decorator'; 16 | import { Role } from '../users/models/role.enum'; 17 | import { 18 | ApiBadRequestResponse, 19 | ApiConflictResponse, 20 | ApiCreatedResponse, 21 | ApiForbiddenResponse, 22 | ApiNotFoundResponse, 23 | ApiOkResponse, 24 | ApiTags, 25 | ApiUnauthorizedResponse, 26 | } from '@nestjs/swagger'; 27 | 28 | @ApiTags('settings') 29 | @Controller('settings') 30 | export class SettingsController { 31 | constructor(private settingsService: SettingsService) {} 32 | 33 | @Get() 34 | @ApiOkResponse({ type: [Setting], description: 'List of all settings' }) 35 | async getSettings(): Promise { 36 | return this.settingsService.getSettings(); 37 | } 38 | 39 | @Get('/:id(\\d+)') 40 | @ApiOkResponse({ type: Setting, description: 'Setting with given id' }) 41 | @ApiNotFoundResponse({ description: 'Setting not found' }) 42 | async getSetting(@Param('id', ParseIntPipe) id: number): Promise { 43 | return this.settingsService.getSetting(id); 44 | } 45 | 46 | @Get('/:name/value') 47 | @ApiOkResponse({ 48 | type: String, 49 | description: 'Value of the setting with given name', 50 | }) 51 | @ApiNotFoundResponse({ description: 'Setting not found' }) 52 | async getSettingValueByName(@Param('name') name: string): Promise { 53 | return this.settingsService.getSettingValueByName(name); 54 | } 55 | 56 | @Post() 57 | @Roles(Role.Admin) 58 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 59 | @ApiForbiddenResponse({ description: 'User not authorized' }) 60 | @ApiBadRequestResponse({ description: 'Invalid setting data' }) 61 | @ApiCreatedResponse({ type: Setting, description: 'Setting created' }) 62 | @ApiConflictResponse({ 63 | description: 'Setting with given name already exists', 64 | }) 65 | async createSetting(@Body() data: SettingCreateDto): Promise { 66 | return this.settingsService.createSetting(data); 67 | } 68 | 69 | @Patch('/:id') 70 | @Roles(Role.Admin) 71 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 72 | @ApiForbiddenResponse({ description: 'User not authorized' }) 73 | @ApiBadRequestResponse({ description: 'Invalid setting data' }) 74 | @ApiNotFoundResponse({ description: 'Setting not found' }) 75 | @ApiOkResponse({ type: Setting, description: 'Setting updated' }) 76 | async updateSetting( 77 | @Param('id') id: number, 78 | @Body() data: SettingUpdateDto, 79 | ): Promise { 80 | return this.settingsService.updateSetting(id, data); 81 | } 82 | 83 | @Delete('/:id') 84 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 85 | @ApiForbiddenResponse({ description: 'User not authorized' }) 86 | @ApiNotFoundResponse({ description: 'Setting not found' }) 87 | @ApiOkResponse({ description: 'Setting deleted' }) 88 | @Roles(Role.Admin) 89 | async deleteSetting(@Param('id') id: number): Promise { 90 | await this.settingsService.deleteSetting(id); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/settings/settings.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SettingsService } from './settings.service'; 3 | import { Exporter } from '../import-export/models/exporter.interface'; 4 | import { Setting } from './models/setting.entity'; 5 | 6 | @Injectable() 7 | export class SettingsExporter implements Exporter { 8 | constructor(private settingsService: SettingsService) {} 9 | 10 | async export(): Promise { 11 | const settings = await this.settingsService.getSettings(); 12 | const preparedSettings: Setting[] = []; 13 | for (const setting of settings) { 14 | preparedSettings.push(this.prepareSetting(setting)); 15 | } 16 | return preparedSettings; 17 | } 18 | 19 | private prepareSetting(setting: Setting) { 20 | const preparedSetting = new Setting(); 21 | preparedSetting.builtin = setting.builtin; 22 | preparedSetting.name = setting.name; 23 | preparedSetting.description = setting.description; 24 | preparedSetting.defaultValue = setting.defaultValue; 25 | preparedSetting.value = setting.value; 26 | preparedSetting.type = setting.type; 27 | return preparedSetting; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/settings/settings.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SettingsService } from './settings.service'; 3 | import { Collection } from '../import-export/models/collection.type'; 4 | import { Setting } from './models/setting.entity'; 5 | import { SettingType } from './models/setting-type.enum'; 6 | import { ParseError } from '../errors/parse.error'; 7 | import { Importer } from '../import-export/models/importer.interface'; 8 | import { IdMap } from '../import-export/models/id-map.type'; 9 | 10 | @Injectable() 11 | export class SettingsImporter implements Importer { 12 | constructor(private settingsService: SettingsService) {} 13 | 14 | async import(settings: Collection): Promise { 15 | const parsedSettings = this.parseSettings(settings); 16 | const idMap: IdMap = {}; 17 | for (const setting of parsedSettings) { 18 | const found = await this.settingsService.findSettingByName(setting.name); 19 | if (found) { 20 | await this.settingsService.updateSetting(found.id, { 21 | value: setting.value, 22 | }); 23 | idMap[setting.id] = found.id; 24 | } else { 25 | const { id: newId } = await this.settingsService.createSetting(setting); 26 | idMap[newId] = newId; 27 | } 28 | } 29 | return idMap; 30 | } 31 | 32 | async clear() { 33 | const settings = await this.settingsService.getSettings(); 34 | let deleted = 0; 35 | for (const setting of settings) { 36 | await this.settingsService.deleteSetting(setting.id, true); 37 | deleted += 1; 38 | } 39 | return deleted; 40 | } 41 | 42 | private parseSettings(settings: Collection) { 43 | const parsedSettings: Setting[] = []; 44 | for (const setting of settings) { 45 | parsedSettings.push(this.parseSetting(setting)); 46 | } 47 | return parsedSettings; 48 | } 49 | 50 | private parseSetting(setting: Collection[number]) { 51 | const parsedSetting = new Setting(); 52 | try { 53 | parsedSetting.builtin = setting.builtin as boolean; 54 | parsedSetting.name = setting.name as string; 55 | parsedSetting.description = setting.description as string; 56 | parsedSetting.defaultValue = setting.defaultValue as string; 57 | parsedSetting.value = setting.value as string; 58 | parsedSetting.type = setting.type as SettingType; 59 | } catch (e) { 60 | throw new ParseError('setting'); 61 | } 62 | return parsedSetting; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/settings/settings.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SettingsService } from './settings.service'; 3 | import { SettingsController } from './settings.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Setting } from './models/setting.entity'; 6 | import { SettingsExporter } from './settings.exporter'; 7 | import { SettingsImporter } from './settings.importer'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Setting])], 11 | providers: [SettingsService, SettingsExporter, SettingsImporter], 12 | controllers: [SettingsController], 13 | exports: [SettingsService, SettingsExporter, SettingsImporter], 14 | }) 15 | export class SettingsModule {} 16 | -------------------------------------------------------------------------------- /src/users/dto/user-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator'; 2 | import { Role } from '../models/role.enum'; 3 | 4 | export class UserUpdateDto { 5 | @IsEmail() 6 | @IsOptional() 7 | email?: string; 8 | 9 | @IsString() 10 | @IsOptional() 11 | firstName?: string; 12 | 13 | @IsString() 14 | @IsOptional() 15 | lastName?: string; 16 | 17 | @IsEnum(Role) 18 | @IsOptional() 19 | role?: Role; 20 | } 21 | -------------------------------------------------------------------------------- /src/users/models/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | Customer = 'customer', 3 | Sales = 'sales', 4 | Manager = 'manager', 5 | Admin = 'admin', 6 | Disabled = 'disabled', 7 | } 8 | -------------------------------------------------------------------------------- /src/users/models/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | Index, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { Role } from './role.enum'; 9 | import { Exclude } from 'class-transformer'; 10 | import { ApiHideProperty } from '@nestjs/swagger'; 11 | 12 | @Entity('users') 13 | export class User { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @CreateDateColumn() 18 | registered: Date; 19 | 20 | @Column({ nullable: true }) 21 | firstName?: string; 22 | 23 | @Column({ nullable: true }) 24 | lastName?: string; 25 | 26 | @Index({ unique: true }) 27 | @Column({ unique: true }) 28 | email: string; 29 | 30 | @ApiHideProperty() 31 | @Exclude() 32 | @Column({ select: false }) 33 | password: string; 34 | 35 | @Column({ 36 | type: 'enum', 37 | enum: Role, 38 | default: Role.Customer, 39 | }) 40 | role: Role; 41 | } 42 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { UsersService } from './users.service'; 11 | import { Roles } from '../auth/decorators/roles.decorator'; 12 | import { Role } from './models/role.enum'; 13 | import { User } from './models/user.entity'; 14 | import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; 15 | import { UserUpdateDto } from './dto/user-update.dto'; 16 | import { ReqUser } from '../auth/decorators/user.decorator'; 17 | import { 18 | ApiBadRequestResponse, 19 | ApiForbiddenResponse, 20 | ApiNotFoundResponse, 21 | ApiOkResponse, 22 | ApiTags, 23 | ApiUnauthorizedResponse, 24 | } from '@nestjs/swagger'; 25 | 26 | @ApiTags('users') 27 | @Controller('users') 28 | @ApiUnauthorizedResponse({ description: 'User is not logged in' }) 29 | export class UsersController { 30 | constructor(private readonly usersService: UsersService) {} 31 | 32 | @Get('me') 33 | @UseGuards(SessionAuthGuard) 34 | @ApiOkResponse({ 35 | type: User, 36 | description: 'Currently logged in user', 37 | }) 38 | async getCurrentUser(@ReqUser() user: User): Promise { 39 | return this.usersService.getUser(user.id); 40 | } 41 | 42 | @Get() 43 | @Roles(Role.Admin) 44 | @ApiOkResponse({ 45 | type: [User], 46 | description: 'List of all users', 47 | }) 48 | @ApiForbiddenResponse({ description: 'User is not admin' }) 49 | async getUsers(): Promise { 50 | return this.usersService.getUsers(); 51 | } 52 | 53 | @Get('/:id') 54 | @Roles(Role.Admin) 55 | @ApiOkResponse({ 56 | type: User, 57 | description: 'User with given id', 58 | }) 59 | @ApiForbiddenResponse({ description: 'User is not admin' }) 60 | async getUser(@Param('id') id: number): Promise { 61 | return await this.usersService.getUser(id); 62 | } 63 | 64 | @Patch('/:id') 65 | @Roles(Role.Admin) 66 | @ApiOkResponse({ 67 | type: User, 68 | description: 'User successfully updated', 69 | }) 70 | @ApiForbiddenResponse({ description: 'User is not admin' }) 71 | @ApiNotFoundResponse({ description: 'User not found' }) 72 | @ApiBadRequestResponse({ description: 'Invalid update data' }) 73 | async updateUser( 74 | @Param('id') id: number, 75 | @Body() update: UserUpdateDto, 76 | ): Promise { 77 | return await this.usersService.updateUser(id, update); 78 | } 79 | 80 | @Delete('/:id') 81 | @Roles(Role.Admin) 82 | @ApiOkResponse({ 83 | description: 'User successfully deleted', 84 | }) 85 | @ApiForbiddenResponse({ description: 'User is not admin' }) 86 | @ApiNotFoundResponse({ description: 'User not found' }) 87 | async deleteUser(@Param('id') id: number): Promise { 88 | await this.usersService.deleteUser(id); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/users/users.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Exporter } from '../import-export/models/exporter.interface'; 3 | import { User } from './models/user.entity'; 4 | import { UsersService } from './users.service'; 5 | 6 | @Injectable() 7 | export class UsersExporter implements Exporter { 8 | constructor(private usersService: UsersService) {} 9 | 10 | async export(): Promise { 11 | const users = await this.usersService.getUsers(); 12 | const preparedUsers: User[] = []; 13 | for (const user of users) { 14 | preparedUsers.push(this.prepareUser(user)); 15 | } 16 | return preparedUsers; 17 | } 18 | 19 | private prepareUser(user: User) { 20 | const preparedUser = new User(); 21 | preparedUser.id = user.id; 22 | preparedUser.email = user.email; 23 | preparedUser.role = user.role; 24 | preparedUser.firstName = user.firstName; 25 | preparedUser.lastName = user.lastName; 26 | return preparedUser; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/users/users.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Importer } from '../import-export/models/importer.interface'; 3 | import { Collection } from '../import-export/models/collection.type'; 4 | import { User } from './models/user.entity'; 5 | import { ParseError } from '../errors/parse.error'; 6 | import { UsersService } from './users.service'; 7 | import { Role } from './models/role.enum'; 8 | import { IdMap } from '../import-export/models/id-map.type'; 9 | 10 | @Injectable() 11 | export class UsersImporter implements Importer { 12 | constructor(private usersService: UsersService) {} 13 | 14 | async import(users: Collection): Promise { 15 | const parsedUsers = this.parseUsers(users); 16 | const idMap: IdMap = {}; 17 | for (const user of parsedUsers) { 18 | const found = await this.usersService.findUserByEmail(user.email); 19 | if (found) { 20 | idMap[user.id] = found.id; 21 | } else { 22 | const { id: newId } = await this.usersService.addUser( 23 | user.email, 24 | '', 25 | user.firstName, 26 | user.lastName, 27 | ); 28 | await this.usersService.updateUser(newId, { role: user.role }); 29 | idMap[user.id] = newId; 30 | } 31 | } 32 | return idMap; 33 | } 34 | 35 | async clear() { 36 | const users = await this.usersService.getUsers(); 37 | let deleted = 0; 38 | for (const user of users) { 39 | if (user.role === Role.Admin) { 40 | continue; 41 | } 42 | await this.usersService.deleteUser(user.id); 43 | deleted += 1; 44 | } 45 | return deleted; 46 | } 47 | 48 | private parseUsers(users: Collection) { 49 | const parsedUsers: User[] = []; 50 | for (const user of users) { 51 | parsedUsers.push(this.parseUser(user)); 52 | } 53 | return parsedUsers; 54 | } 55 | 56 | private parseUser(user: Collection[number]) { 57 | const parsedUser = new User(); 58 | try { 59 | parsedUser.id = user.id as number; 60 | parsedUser.email = user.email as string; 61 | parsedUser.role = user.role as Role; 62 | parsedUser.firstName = user.firstName as string; 63 | parsedUser.lastName = user.lastName as string; 64 | } catch (e) { 65 | throw new ParseError('user'); 66 | } 67 | return parsedUser; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from './users.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from './models/user.entity'; 6 | import { UsersExporter } from './users.exporter'; 7 | import { UsersImporter } from './users.importer'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([User])], 11 | controllers: [UsersController], 12 | providers: [UsersService, UsersExporter, UsersImporter], 13 | exports: [UsersService, UsersExporter, UsersImporter], 14 | }) 15 | export class UsersModule {} 16 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { User } from './models/user.entity'; 5 | import { UserUpdateDto } from './dto/user-update.dto'; 6 | import { NotFoundError } from '../errors/not-found.error'; 7 | import { ConflictError } from '../errors/conflict.error'; 8 | 9 | @Injectable() 10 | export class UsersService { 11 | constructor( 12 | @InjectRepository(User) private readonly usersRepository: Repository, 13 | ) {} 14 | 15 | async addUser( 16 | email: string, 17 | hashedPassword: string, 18 | firstName?: string, 19 | lastName?: string, 20 | ): Promise { 21 | try { 22 | const user = new User(); 23 | user.email = email; 24 | user.password = hashedPassword; 25 | user.firstName = firstName; 26 | user.lastName = lastName; 27 | const savedUser = await this.usersRepository.save(user); 28 | const { password, ...toReturn } = savedUser; 29 | return toReturn as User; 30 | } catch (error) { 31 | throw new ConflictError('user', 'email', email); 32 | } 33 | } 34 | 35 | async findUserByEmail(email: string): Promise { 36 | return await this.usersRepository.findOne({ 37 | where: { email }, 38 | }); 39 | } 40 | 41 | async findUserToLogin(email: string): Promise { 42 | return await this.usersRepository.findOne({ 43 | where: { email }, 44 | select: { password: true, email: true, id: true, role: true }, 45 | }); 46 | } 47 | 48 | async findUserToSession(id: number): Promise { 49 | return await this.usersRepository.findOne({ 50 | where: { id }, 51 | select: { email: true, id: true, role: true }, 52 | }); 53 | } 54 | 55 | async getUsers(): Promise { 56 | return await this.usersRepository.find(); 57 | } 58 | 59 | async getUser(id: number): Promise { 60 | const user = await this.usersRepository.findOne({ where: { id } }); 61 | if (!user) { 62 | throw new NotFoundError('user', 'id', id.toString()); 63 | } 64 | return user; 65 | } 66 | 67 | async updateUser(id: number, update: UserUpdateDto): Promise { 68 | const user = await this.usersRepository.findOne({ where: { id } }); 69 | if (!user) { 70 | throw new NotFoundError('user', 'id', id.toString()); 71 | } 72 | Object.assign(user, update); 73 | await this.usersRepository.save(user); 74 | return user; 75 | } 76 | 77 | async deleteUser(id: number): Promise { 78 | const user = await this.usersRepository.findOne({ 79 | where: { id }, 80 | }); 81 | if (!user) { 82 | throw new NotFoundError('user', 'id', id.toString()); 83 | } 84 | await this.usersRepository.delete({ id }); 85 | return true; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/wishlists/dto/wishlist-create.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class WishlistCreateDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | 8 | @IsInt({ each: true }) 9 | productIds: number[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/wishlists/dto/wishlist-update.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class WishlistUpdateDto { 4 | @IsString() 5 | @IsOptional() 6 | name?: string; 7 | 8 | @IsInt({ each: true }) 9 | @IsOptional() 10 | productIds?: number[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/wishlists/models/wishlist.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | JoinTable, 6 | ManyToMany, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { User } from '../../users/models/user.entity'; 12 | import { Product } from '../../catalog/products/models/product.entity'; 13 | 14 | @Entity('wishlists') 15 | export class Wishlist { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @CreateDateColumn() 20 | created: Date; 21 | 22 | @UpdateDateColumn() 23 | updated: Date; 24 | 25 | @Column() 26 | name: string; 27 | 28 | @ManyToOne(() => User, { 29 | cascade: true, 30 | onDelete: 'CASCADE', 31 | }) 32 | user: User; 33 | 34 | @ManyToMany(() => Product, { 35 | eager: true, 36 | cascade: true, 37 | onDelete: 'CASCADE', 38 | }) 39 | @JoinTable() 40 | products: Product[]; 41 | } 42 | -------------------------------------------------------------------------------- /src/wishlists/wishlists.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | } from '@nestjs/common'; 10 | import { WishlistsService } from './wishlists.service'; 11 | import { ReqUser } from '../auth/decorators/user.decorator'; 12 | import { User } from '../users/models/user.entity'; 13 | import { Wishlist } from './models/wishlist.entity'; 14 | import { Role } from '../users/models/role.enum'; 15 | import { Roles } from '../auth/decorators/roles.decorator'; 16 | import { WishlistCreateDto } from './dto/wishlist-create.dto'; 17 | import { WishlistUpdateDto } from './dto/wishlist-update.dto'; 18 | import { 19 | ApiCreatedResponse, 20 | ApiForbiddenResponse, 21 | ApiNotFoundResponse, 22 | ApiOkResponse, 23 | ApiTags, 24 | ApiUnauthorizedResponse, 25 | } from '@nestjs/swagger'; 26 | 27 | @ApiTags('wishlists') 28 | @Controller('wishlists') 29 | export class WishlistsController { 30 | constructor(private readonly wishlistsService: WishlistsService) {} 31 | 32 | @Get() 33 | @Roles(Role.Admin, Role.Manager, Role.Sales, Role.Customer) 34 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 35 | @ApiForbiddenResponse({ description: 'User not authorized' }) 36 | @ApiOkResponse({ type: [Wishlist], description: 'User wishlists' }) 37 | async getUserWishlists(@ReqUser() user: User): Promise { 38 | return this.wishlistsService.getUserWishlists(user); 39 | } 40 | 41 | @Post() 42 | @Roles(Role.Admin, Role.Manager, Role.Sales, Role.Customer) 43 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 44 | @ApiForbiddenResponse({ description: 'User not authorized' }) 45 | @ApiCreatedResponse({ type: Wishlist, description: 'Wishlist created' }) 46 | async createWishlist( 47 | @ReqUser() user: User, 48 | @Body() body: WishlistCreateDto, 49 | ): Promise { 50 | return this.wishlistsService.createWishlist(user, body); 51 | } 52 | 53 | @Patch('/:id') 54 | @Roles(Role.Admin, Role.Manager, Role.Sales, Role.Customer) 55 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 56 | @ApiForbiddenResponse({ description: 'User not authorized' }) 57 | @ApiNotFoundResponse({ description: 'Wishlist not found' }) 58 | @ApiOkResponse({ type: Wishlist, description: 'Wishlist updated' }) 59 | async updateWishlist( 60 | @ReqUser() user: User, 61 | @Param('id') id: number, 62 | @Body() body: WishlistUpdateDto, 63 | ): Promise { 64 | return this.wishlistsService.updateWishlist(user, id, body); 65 | } 66 | 67 | @Delete('/:id') 68 | @Roles(Role.Admin, Role.Manager, Role.Sales, Role.Customer) 69 | @ApiUnauthorizedResponse({ description: 'User not logged in' }) 70 | @ApiForbiddenResponse({ description: 'User not authorized' }) 71 | @ApiNotFoundResponse({ description: 'Wishlist not found' }) 72 | @ApiOkResponse({ description: 'Wishlist deleted' }) 73 | async deleteWishlist( 74 | @ReqUser() user: User, 75 | @Param('id') id: number, 76 | ): Promise { 77 | await this.wishlistsService.deleteWishlist(user, id); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/wishlists/wishlists.exporter.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Wishlist } from './models/wishlist.entity'; 3 | import { WishlistsService } from './wishlists.service'; 4 | import { Exporter } from '../import-export/models/exporter.interface'; 5 | 6 | @Injectable() 7 | export class WishlistsExporter implements Exporter { 8 | constructor(private wishlistsService: WishlistsService) {} 9 | 10 | async export(): Promise { 11 | const wishlists = await this.wishlistsService.getWishlists(); 12 | const preparedWishlists: Wishlist[] = []; 13 | for (const wishlist of wishlists) { 14 | preparedWishlists.push(this.prepareWishlist(wishlist)); 15 | } 16 | return preparedWishlists; 17 | } 18 | 19 | private prepareWishlist(wishlist: Wishlist) { 20 | const preparedWishlist = new Wishlist() as any; 21 | preparedWishlist.id = wishlist.id; 22 | preparedWishlist.name = wishlist.name; 23 | preparedWishlist.userId = wishlist.user.id; 24 | preparedWishlist.products = wishlist.products.map(({ id }) => ({ id })); 25 | return preparedWishlist; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/wishlists/wishlists.importer.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { WishlistsService } from './wishlists.service'; 3 | import { Wishlist } from './models/wishlist.entity'; 4 | import { Importer } from '../import-export/models/importer.interface'; 5 | import { Collection } from '../import-export/models/collection.type'; 6 | import { IdMap } from '../import-export/models/id-map.type'; 7 | import { ParseError } from '../errors/parse.error'; 8 | import { User } from '../users/models/user.entity'; 9 | import { Product } from '../catalog/products/models/product.entity'; 10 | 11 | @Injectable() 12 | export class WishlistsImporter implements Importer { 13 | constructor(private wishlistsService: WishlistsService) {} 14 | 15 | async import( 16 | wishlists: Collection, 17 | idMaps: Record, 18 | ): Promise { 19 | const parsedWishlists = this.parseWishlists( 20 | wishlists, 21 | idMaps.users, 22 | idMaps.products, 23 | ); 24 | const idMap: IdMap = {}; 25 | for (const wishlist of parsedWishlists) { 26 | const { id, user, products, ...createDto } = wishlist; 27 | const { id: newId } = await this.wishlistsService.createWishlist( 28 | user, 29 | { 30 | ...createDto, 31 | productIds: products.map(({ id }) => id), 32 | }, 33 | true, 34 | ); 35 | idMap[wishlist.id] = newId; 36 | } 37 | return idMap; 38 | } 39 | 40 | async clear() { 41 | const wishlists = await this.wishlistsService.getWishlists(); 42 | let deleted = 0; 43 | for (const wishlist of wishlists) { 44 | await this.wishlistsService.deleteWishlist( 45 | { id: wishlist.user.id } as User, 46 | wishlist.id, 47 | ); 48 | deleted += 1; 49 | } 50 | return deleted; 51 | } 52 | 53 | private parseWishlists( 54 | wishlists: Collection, 55 | usersIdMap: IdMap, 56 | productsIdMap: IdMap, 57 | ) { 58 | const parsedWishlists: Wishlist[] = []; 59 | for (const wishlist of wishlists) { 60 | parsedWishlists.push( 61 | this.parseWishlist(wishlist, usersIdMap, productsIdMap), 62 | ); 63 | } 64 | return parsedWishlists; 65 | } 66 | 67 | private parseWishlist( 68 | wishlist: Collection[number], 69 | usersIdMap: IdMap, 70 | productsIdMap: IdMap, 71 | ) { 72 | const parsedWishlist = new Wishlist(); 73 | try { 74 | parsedWishlist.id = wishlist.id as number; 75 | parsedWishlist.name = wishlist.name as string; 76 | parsedWishlist.user = { 77 | id: usersIdMap[wishlist.userId as number], 78 | } as User; 79 | parsedWishlist.products = (wishlist.products as Collection).map( 80 | (product) => this.parseProduct(product, productsIdMap), 81 | ); 82 | } catch (e) { 83 | throw new ParseError('wishlist'); 84 | } 85 | return parsedWishlist; 86 | } 87 | 88 | private parseProduct(product: Collection[number], productsIdMap: IdMap) { 89 | try { 90 | return { id: productsIdMap[product.id as number] } as Product; 91 | } catch (e) { 92 | throw new ParseError('product'); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/wishlists/wishlists.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { WishlistsController } from './wishlists.controller'; 3 | import { WishlistsService } from './wishlists.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Wishlist } from './models/wishlist.entity'; 6 | import { CatalogModule } from '../catalog/catalog.module'; 7 | import { WishlistsExporter } from './wishlists.exporter'; 8 | import { WishlistsImporter } from './wishlists.importer'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([Wishlist]), CatalogModule], 12 | controllers: [WishlistsController], 13 | providers: [WishlistsService, WishlistsExporter, WishlistsImporter], 14 | exports: [WishlistsExporter, WishlistsImporter], 15 | }) 16 | export class WishlistsModule {} 17 | -------------------------------------------------------------------------------- /src/wishlists/wishlists.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { Wishlist } from './models/wishlist.entity'; 5 | import { User } from '../users/models/user.entity'; 6 | import { WishlistCreateDto } from './dto/wishlist-create.dto'; 7 | import { WishlistUpdateDto } from './dto/wishlist-update.dto'; 8 | import { NotFoundError } from '../errors/not-found.error'; 9 | import { ProductsService } from '../catalog/products/products.service'; 10 | import { Role } from '../users/models/role.enum'; 11 | 12 | @Injectable() 13 | export class WishlistsService { 14 | constructor( 15 | @InjectRepository(Wishlist) 16 | private readonly wishlistsRepository: Repository, 17 | private productsService: ProductsService, 18 | ) {} 19 | 20 | async getWishlists(): Promise { 21 | return await this.wishlistsRepository.find({ 22 | relations: ['user', 'products'], 23 | }); 24 | } 25 | 26 | async getUserWishlists(user: User): Promise { 27 | return this.wishlistsRepository.find({ where: { user: { id: user.id } } }); 28 | } 29 | 30 | async getWishlist(userId: number, id: number): Promise { 31 | const wishlist = await this.wishlistsRepository.findOne({ 32 | where: { 33 | id, 34 | user: { id: userId }, 35 | }, 36 | }); 37 | if (!wishlist) { 38 | throw new NotFoundError('wishlist'); 39 | } 40 | return wishlist; 41 | } 42 | 43 | async createWishlist( 44 | user: User, 45 | createData: WishlistCreateDto, 46 | withHidden?: boolean, 47 | ): Promise { 48 | const wishlist = new Wishlist(); 49 | wishlist.user = user; 50 | wishlist.name = createData.name; 51 | wishlist.products = []; 52 | for (const productId of createData.productIds) { 53 | const product = await this.productsService.getProduct( 54 | productId, 55 | [Role.Admin, Role.Manager, Role.Sales].includes(user.role) || 56 | withHidden, 57 | ); 58 | wishlist.products.push(product); 59 | } 60 | return this.wishlistsRepository.save(wishlist); 61 | } 62 | 63 | async updateWishlist( 64 | user: User, 65 | id: number, 66 | updateData: WishlistUpdateDto, 67 | ): Promise { 68 | const wishlist = await this.getWishlist(user.id, id); 69 | wishlist.name = updateData.name ?? wishlist.name; 70 | wishlist.products = []; 71 | for (const productId of updateData.productIds ?? []) { 72 | const product = await this.productsService.getProduct( 73 | productId, 74 | [Role.Admin, Role.Manager, Role.Sales].includes(user.role), 75 | ); 76 | wishlist.products.push(product); 77 | } 78 | return this.wishlistsRepository.save(wishlist); 79 | } 80 | 81 | async deleteWishlist(user: User, id: number): Promise { 82 | await this.getWishlist(user.id, id); 83 | await this.wishlistsRepository.delete({ id }); 84 | return true; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/assets/export-bad.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": [ 3 | { 4 | "builtin": true, 5 | "name": "Currency", 6 | "description": "The currency to use for all prices", 7 | "defaultValue": "USD", 8 | "value": "123", 9 | "type": "currencyCode" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/assets/export.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalmarchewczyk/ecommerce-platform-nestjs-api/5d26d72dda34484fe6ee25fc57505a9dae7d294c/test/assets/export.tar.gz -------------------------------------------------------------------------------- /test/assets/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalmarchewczyk/ecommerce-platform-nestjs-api/5d26d72dda34484fe6ee25fc57505a9dae7d294c/test/assets/test.jpg -------------------------------------------------------------------------------- /test/assets/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalmarchewczyk/ecommerce-platform-nestjs-api/5d26d72dda34484fe6ee25fc57505a9dae7d294c/test/assets/test.png -------------------------------------------------------------------------------- /test/assets/test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michalmarchewczyk/ecommerce-platform-nestjs-api/5d26d72dda34484fe6ee25fc57505a9dae7d294c/test/assets/test.txt -------------------------------------------------------------------------------- /test/import-export-rbac.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { AppModule } from '../src/app.module'; 4 | import { Role } from '../src/users/models/role.enum'; 5 | import { TestUsersService } from './utils/test-users/test-users.service'; 6 | import { TestUsersModule } from './utils/test-users/test-users.module'; 7 | import { setupRbacTests } from './utils/setup-rbac-tests'; 8 | 9 | describe('Import/ExportController (e2e)', () => { 10 | let appRBAC: INestApplication; 11 | let testUsersRBAC: TestUsersService; 12 | 13 | beforeAll(async () => { 14 | const moduleFixtureRBAC: TestingModule = await Test.createTestingModule({ 15 | imports: [AppModule, TestUsersModule], 16 | }).compile(); 17 | 18 | appRBAC = moduleFixtureRBAC.createNestApplication(); 19 | testUsersRBAC = moduleFixtureRBAC.get(TestUsersService); 20 | await appRBAC.init(); 21 | await testUsersRBAC.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await appRBAC.close(); 26 | }); 27 | 28 | describe( 29 | 'RBAC for /import and /export', 30 | setupRbacTests( 31 | () => appRBAC, 32 | () => testUsersRBAC, 33 | [ 34 | ['/import (POST)', [Role.Admin]], 35 | ['/export (POST)', [Role.Admin]], 36 | ], 37 | ), 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /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 | "globalSetup": "./test/utils/setup.ts", 10 | "collectCoverageFrom": [ 11 | "src/**/*.ts" 12 | ], 13 | "coveragePathIgnorePatterns": [ 14 | "/node_modules/", 15 | ".spec.ts$", 16 | "/src/main.ts$" 17 | ], 18 | "coverageDirectory": "./test/coverage" 19 | } 20 | -------------------------------------------------------------------------------- /test/utils/dto-generator/dto-generator.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DtoGeneratorService } from './dto-generator.service'; 3 | 4 | @Module({ 5 | providers: [DtoGeneratorService], 6 | exports: [DtoGeneratorService], 7 | }) 8 | export class DtoGeneratorModule {} 9 | -------------------------------------------------------------------------------- /test/utils/generate-file-metadata.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from 'stream'; 2 | 3 | const getRandomString = () => { 4 | return Math.random().toString(36).substring(2, 15); 5 | }; 6 | 7 | export const generateFileMetadata = (data?: string, mimetype?: string) => { 8 | const filename = getRandomString(); 9 | return { 10 | fieldname: 'file', 11 | originalname: `${getRandomString()}.jpg`, 12 | encoding: '8bit', 13 | mimetype: mimetype ?? 'image/jpeg', 14 | size: Math.floor(Math.random() * 1000) + 1, 15 | destination: './uploads', 16 | filename, 17 | path: `./uploads/${filename}`, 18 | buffer: Buffer.from(data ?? 'file'), 19 | stream: new Readable(), 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /test/utils/parse-endpoint.ts: -------------------------------------------------------------------------------- 1 | type endpointMethod = 'get' | 'post' | 'patch' | 'put' | 'delete'; 2 | 3 | export const parseEndpoint = (endpoint: string): [string, endpointMethod] => { 4 | const path = endpoint.split(' ')[0].split('/'); 5 | for (const segment of path) { 6 | if (segment.startsWith(':')) { 7 | path.splice( 8 | path.indexOf(segment), 9 | 1, 10 | Math.floor(Date.now() / 1000).toString(), 11 | ); 12 | } 13 | } 14 | const url = path.join('/'); 15 | let method = endpoint.split(' ')[1]; 16 | method = method.toLowerCase().substring(1, method.length - 1); 17 | return [url, method as endpointMethod]; 18 | }; 19 | -------------------------------------------------------------------------------- /test/utils/repository-mock/repository-mock.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RepositoryMockService } from './repository-mock.service'; 3 | 4 | @Module({ 5 | providers: [RepositoryMockService], 6 | }) 7 | export class RepositoryMockModule {} 8 | -------------------------------------------------------------------------------- /test/utils/setup-rbac-tests.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '../../src/users/models/role.enum'; 2 | import * as request from 'supertest'; 3 | import { parseEndpoint } from './parse-endpoint'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import { TestUsersService } from './test-users/test-users.service'; 6 | import { describe, it, beforeAll, expect } from '@jest/globals'; 7 | 8 | export const setupRbacTests = 9 | ( 10 | getApp: () => INestApplication, 11 | getTestUsers: () => TestUsersService, 12 | endpoints: [string, Role[]][], 13 | ) => 14 | () => { 15 | const cookieHeaders: Record = {}; 16 | const availableRoles = Object.values(Role); 17 | 18 | let app: INestApplication; 19 | let testUsers: TestUsersService; 20 | 21 | beforeAll(async () => { 22 | app = getApp(); 23 | testUsers = getTestUsers(); 24 | for (const role of availableRoles) { 25 | const response = await request(app.getHttpServer()) 26 | .post('/auth/login') 27 | .send({ ...testUsers.getCredentials(role) }); 28 | cookieHeaders[role] = response.headers['set-cookie']; 29 | } 30 | }); 31 | 32 | describe.each(endpoints)('%s', (endpoint, roles) => { 33 | const [url, method] = parseEndpoint(endpoint); 34 | 35 | const testRoles: [Role, boolean][] = availableRoles.map((role) => [ 36 | role, 37 | roles.includes(role), 38 | ]); 39 | 40 | it.each(testRoles)( 41 | `${endpoint} can be accessed by %s: %p`, 42 | async (role, result) => { 43 | const response = await request(app.getHttpServer()) 44 | [method](url) 45 | .set('Cookie', cookieHeaders[role]); 46 | if (result) { 47 | expect(response.status).not.toBe(403); 48 | } else { 49 | expect(response.status).toBe(403); 50 | } 51 | }, 52 | ); 53 | 54 | it(`${endpoint} can be accessed by guest: ${roles.includes( 55 | Role.Disabled, 56 | )}`, async () => { 57 | const response = await request(app.getHttpServer())[method](url); 58 | if (roles.includes(Role.Disabled)) { 59 | expect(response.status).not.toBe(401); 60 | } else { 61 | expect(response.status).toBe(401); 62 | } 63 | }); 64 | }); 65 | }; 66 | -------------------------------------------------------------------------------- /test/utils/setup.ts: -------------------------------------------------------------------------------- 1 | import { TestUsersModule } from './test-users/test-users.module'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { ConfigModule, ConfigService } from '@nestjs/config'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | import { TestUsersService } from './test-users/test-users.service'; 6 | import configuration from '../../src/config/configuration'; 7 | 8 | const setup = async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [ 11 | ConfigModule.forRoot({ 12 | ignoreEnvFile: true, 13 | isGlobal: true, 14 | load: [configuration], 15 | }), 16 | TypeOrmModule.forRootAsync({ 17 | imports: [ConfigModule], 18 | useFactory: (configService: ConfigService) => ({ 19 | type: 'postgres', 20 | host: configService.get('postgres.host'), 21 | port: configService.get('postgres.port'), 22 | username: configService.get('postgres.username'), 23 | password: configService.get('postgres.password'), 24 | database: configService.get('postgres.database'), 25 | entities: [], 26 | synchronize: true, 27 | autoLoadEntities: true, 28 | keepConnectionAlive: true, 29 | dropSchema: true, 30 | }), 31 | inject: [ConfigService], 32 | }), 33 | TestUsersModule, 34 | ], 35 | }).compile(); 36 | 37 | const testUsers = moduleFixture.get(TestUsersService); 38 | await testUsers.init(); 39 | }; 40 | 41 | export default setup; 42 | -------------------------------------------------------------------------------- /test/utils/test-users/test-users.module.ts: -------------------------------------------------------------------------------- 1 | import { TypeOrmModule } from '@nestjs/typeorm'; 2 | import { User } from '../../../src/users/models/user.entity'; 3 | import { TestUsersService } from './test-users.service'; 4 | import { Module } from '@nestjs/common'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([User])], 8 | providers: [TestUsersService], 9 | exports: [TestUsersService], 10 | }) 11 | export class TestUsersModule {} 12 | -------------------------------------------------------------------------------- /test/utils/test-users/test-users.service.ts: -------------------------------------------------------------------------------- 1 | import { InjectRepository } from '@nestjs/typeorm'; 2 | import { User } from '../../../src/users/models/user.entity'; 3 | import { Repository } from 'typeorm'; 4 | import { Injectable } from '@nestjs/common'; 5 | import { Role } from '../../../src/users/models/role.enum'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import * as argon2 from 'argon2'; 8 | 9 | @Injectable() 10 | export class TestUsersService { 11 | private static users: User[] = []; 12 | 13 | constructor( 14 | private readonly configService: ConfigService, 15 | @InjectRepository(User) private readonly usersRepository: Repository, 16 | ) { 17 | if (this.configService.get('nodeEnv') !== 'test') { 18 | throw new Error( 19 | 'Test users service can only be used in test environment', 20 | ); 21 | } 22 | } 23 | 24 | async init(): Promise { 25 | await this.createUser('customer', Role.Customer); 26 | await this.createUser('sales', Role.Sales); 27 | await this.createUser('manager', Role.Manager); 28 | await this.createUser('admin', Role.Admin); 29 | await this.createUser('disabled', Role.Disabled); 30 | } 31 | 32 | async createUser(name: string, role: Role): Promise { 33 | const user = new User(); 34 | user.email = `${name}@test.local`; 35 | user.password = await argon2.hash('test1234'); 36 | user.role = role; 37 | TestUsersService.users.push({ ...user, password: 'test1234' }); 38 | try { 39 | await this.usersRepository.upsert(user, ['email']); 40 | } catch (e) { 41 | return; 42 | } 43 | } 44 | 45 | getCredentials(role: Role): { email: string; password: string } { 46 | const user = TestUsersService.users.find((u) => u.role === role); 47 | if (!user) { 48 | throw new Error(`No test user with role ${role}`); 49 | } 50 | return { email: user.email, password: user.password }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "strictPropertyInitialization": false, 17 | "forceConsistentCasingInFileNames": false, 18 | "noFallthroughCasesInSwitch": false, 19 | "resolveJsonModule": true 20 | } 21 | } 22 | --------------------------------------------------------------------------------