├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── data-source.ts ├── docs ├── 1. MySQL_installation_for_Windows.md ├── 2. nestjs_version_7_to_8_migration.md └── 3. Policy.md ├── ecosystem.config.js ├── index.html ├── nest-cli.json ├── nestia.config.ts ├── ormconfig.ts ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── guards │ │ ├── jwt.guard.ts │ │ ├── kakao.guard.ts │ │ ├── local.guard.ts │ │ └── throttler-behind-proxy.guard.ts │ └── strategies │ │ ├── jwt.strategy.ts │ │ ├── kakao.strategy.ts │ │ └── local.strategy.ts ├── common │ ├── decorators │ │ ├── user-id.decorator.ts │ │ └── user.decorator.ts │ ├── filters │ │ └── http-exception.fiter.ts │ └── interceptors │ │ └── timout.interceptor.ts ├── config │ ├── constant │ │ └── index.ts │ ├── swagger │ │ └── index.ts │ └── typeorm │ │ └── index.ts ├── controllers │ ├── categories.controller.ts │ ├── products.controller.ts │ ├── sellers.controller.ts │ └── users.controller.ts ├── decorators │ ├── is-not-empty-boolean.decorator.ts │ ├── is-not-empty-number.decorator.ts │ ├── is-not-empty-string.decorator.ts │ ├── is-optional-boolean.decorator.ts │ ├── is-optional-number.decorator.ts │ └── is-optional-string.decorator.ts ├── interceptors │ ├── logging.interceptor.ts │ └── transform.interceptor.ts ├── main.ts ├── models │ ├── common │ │ └── time-columns.ts │ ├── dtos │ │ ├── create-user.dto.ts │ │ └── login-user.dto.ts │ ├── repositories │ │ └── products.repository.ts │ └── tables │ │ ├── bodyImage.ts │ │ ├── category.ts │ │ ├── headerImage.ts │ │ ├── option.ts │ │ ├── optionGroup.ts │ │ ├── product.ts │ │ ├── productHasCategory.ts │ │ ├── seller.ts │ │ ├── user.ts │ │ └── userLikeProduct.ts ├── modules │ ├── categories.module.ts │ ├── products.module.ts │ ├── sellers.module.ts │ └── users.module.ts ├── providers │ ├── categories.service.ts │ ├── products.service.ts │ ├── sellers.service.ts │ └── users.service.ts ├── test │ ├── auth.spec.ts │ ├── categories.spec.ts │ ├── products.spec.ts │ └── users.spec.ts ├── types │ └── index.d.ts └── utils │ └── getOffset.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.api.json ├── tsconfig.build.json ├── tsconfig.json └── tsconfig.models.json /.env.example: -------------------------------------------------------------------------------- 1 | DB_TYPE=mysql 2 | 3 | DEVELOPMENT_DB_HOST=localhost 4 | DEVELOPMENT_DB_PORT=3306 5 | DEVELOPMENT_DB_USERNAME=root 6 | DEVELOPMENT_DB_DATABASE=e-commerce-example 7 | DEVELOPMENT_DB_PASSWORD= 8 | 9 | PRODUCTION_DB_TYPE= 10 | PRODUCTION_DB_HOST= 11 | PRODUCTION_DB_PORT= 12 | PRODUCTION_DB_USERNAME= 13 | PRODUCTION_DB_DATABASE= 14 | PRODUCTION_DB_PASSWORD= 15 | 16 | # JWT_TOKEN_KEY_FOR_HASH 17 | ACCESS_KEY=test 18 | 19 | # https://developers.kakao.com/ 20 | KAKAO_REST_API_KEY= -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # Diagnostic reports (https://nodejs.org/api/report.html) 37 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 38 | 39 | # Runtime data 40 | pids 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | 45 | # Directory for instrumented libs generated by jscoverage/JSCover 46 | lib-cov 47 | 48 | # Coverage directory used by tools like istanbul 49 | coverage 50 | *.lcov 51 | 52 | # nyc test coverage 53 | .nyc_output 54 | 55 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 56 | .grunt 57 | 58 | # Bower dependency directory (https://bower.io/) 59 | bower_components 60 | 61 | # node-waf configuration 62 | .lock-wscript 63 | 64 | # Compiled binary addons (https://nodejs.org/api/addons.html) 65 | build/Release 66 | 67 | # Dependency directories 68 | node_modules/ 69 | jspm_packages/ 70 | 71 | # TypeScript v1 declaration files 72 | typings/ 73 | 74 | # TypeScript cache 75 | *.tsbuildinfo 76 | 77 | # Optional npm cache directory 78 | .npm 79 | 80 | # Optional eslint cache 81 | .eslintcache 82 | 83 | # Microbundle cache 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | .node_repl_history 91 | 92 | # Output of 'npm pack' 93 | *.tgz 94 | 95 | # Yarn Integrity file 96 | .yarn-integrity 97 | 98 | # dotenv environment variables file 99 | .env 100 | .env.test 101 | 102 | # parcel-bundler cache (https://parceljs.org/) 103 | .cache 104 | 105 | # Next.js build output 106 | .next 107 | 108 | # Nuxt.js build / generate output 109 | .nuxt 110 | dist 111 | 112 | # Gatsby files 113 | .cache/ 114 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 115 | # https://nextjs.org/blog/next-9-1#public-directory-support 116 | # public 117 | 118 | # vuepress build output 119 | .vuepress/dist 120 | 121 | # Serverless directories 122 | .serverless/ 123 | 124 | # FuseBox cache 125 | .fusebox/ 126 | 127 | # DynamoDB Local files 128 | .dynamodb/ 129 | 130 | # TernJS port file 131 | .tern-port 132 | 133 | packages -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kyungsu Kang 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 | 만약 커머스가 아니라 NestJS에 대한 예제가 필요하다면 아래를 참고하세요. 2 | https://github.com/picktogram/server 3 | 4 | # nestjs-e-commerce 5 | 6 | e-commerce 프로젝트를 진행하기 위한 B.E. 설정 및 기본 코드를 구현합니다. 7 | B.E.나 F.E. 구분할 것 없이 양자에게 도움이 되기를 바랍니다. 8 | 더 나은 방법, 기능 추가 등은 아래로 문의주시면 감사하겠습니다. 9 | 10 | email : kscodebase@gmail.com 11 | 12 | # How to use? 13 | 14 | 1. MySQL 설치 15 | 2. .env에 각종 값들을 모두 기입 ( 기입 후에는 .env.example 파일 이름을 .env로 고쳐주세요. ) 16 | 3. 아래 명령어로 tables를 MySQL 상에 synchronize. 17 | 18 | ```bash 19 | $ npm install # 필요한 node package module을 install 합니다. 20 | $ npm run schema:sync # 정의된 entity들을 즉시 생성합니다. 21 | ``` 22 | 23 | - 만약 잘못된 데이터가 있다면 일단 workbench로 삭제해주세요. 24 | - 또는 schema:drop 후 schema:sync로 DB를 삭제, 재생성해주세요. 25 | 26 | 4. 서버 실행 후 F.E. 에 자유롭게 연동 27 | 28 | ```bash 29 | $ npm run start:dev # 또는 npm run start 30 | ``` 31 | 32 | 5. localhost:3000/api 경로에서 swagger 문서 확인 가능 33 | - 해당 문서를 통해 경로, 기능, Request와 Response를 확인합니다. 34 | 35 | 추후 pm2를 이용한 클러스터링을 설명하겠습니다. 36 | -------------------------------------------------------------------------------- /data-source.ts: -------------------------------------------------------------------------------- 1 | // ./data-source.ts 2 | import { DataSource } from 'typeorm'; 3 | import * as dotenv from 'dotenv'; 4 | import * as path from 'path'; 5 | 6 | dotenv.config(); 7 | 8 | const NODE_ENV = process.env.NODE_ENV; 9 | 10 | export default new DataSource({ 11 | type: 'mysql', 12 | host: process.env[`${NODE_ENV}_DB_HOST`] as string, 13 | port: Number(process.env[`${NODE_ENV}_DB_PORT`]) as number, 14 | username: process.env[`${NODE_ENV}_DB_USERNAME`] as string, 15 | database: process.env[`${NODE_ENV}_DB_DATABASE`] as string, 16 | password: process.env[`${NODE_ENV}_DB_PASSWORD`] as string, 17 | entities: [ 18 | path.join(__dirname, './src/models/tables/*.ts'), 19 | path.join(__dirname, './src/models/tables/*.js'), 20 | ], 21 | synchronize: false, 22 | logging: true, 23 | }); 24 | -------------------------------------------------------------------------------- /docs/1. MySQL_installation_for_Windows.md: -------------------------------------------------------------------------------- 1 | # Windows User들을 위한 MySQL 설치 방법 2 | 3 | ## 1. MySQL 설치 4 | 5 | Windows 10 부터는 내부적으로 Linux를 사용 가능하다. 6 | Windows Ubuntu를 설치하여, bash 환경에서 mysql 을 설치한다. 7 | 사실, Windows 환경에서든 Linux 환경에서든 설치는 크게 어렵지 않다. 8 | 9 | ```bash 10 | $ apt-get update 11 | $ apt-get install mysql-server 12 | ``` 13 | 14 | apt-get 명령어만 가지고도 손 쉽게 MySQL을 설치할 수 있다. 15 | 16 | ## 2. 기존에 Windows에 Workbench를 설치한 경우 17 | 18 | Windows 사용자들 중 아래 같은 문제점을 발견한 사람들이 있을 수 있다. 19 | 20 | ```text 21 | ERROR 2002 (HY000): Can't connect to local MySQL server through socket 22 | '/var/run/mysqld/mysqld.sock' (2) 23 | ``` 24 | 25 | 대충 해당 폴더로 갔을 때 sock 파일이 없다는 의미가 된다. 26 | 나의 경우에는 기존에 문제가 없었으나 workbench를 설치 후 문제가 생겼다. 27 | Error message를 먼저 확인해보자. 28 | MySQL은 error.log를 따로 저장해서 가지기 때문에 이를 확인하면 정확한 원인을 파악할 수 있겠다. 29 | 30 | ```bash 31 | $ cat /var/log/mysql/error.log 32 | ``` 33 | 34 | 나의 경우에는 3306 port가 충돌한 게 문제였다. 35 | 아무래도 workbench와 mysql이 동일한 port를 사용한 탓이다. 36 | Windows에서 mysql workbench가 설치되어, Linux의 mysql과 동일한 Port를 차지한 게 문제이다. 37 | workbench는 아마 사용할 것이므로, 38 | mysql 설정 파일에서 port 번호를 수정한다. 39 | 40 | Windows에서의 MySQL 설정 파일 경로는 `/etc/mysql/mysql.conf.d/mysqld.cnf` 이다. 41 | 여기서 주석처리 되어 있을 port 번호 3306을, 3307으로 수정하고 저장한다. 42 | 43 | ```bash 44 | $ /etc/init.d/mysql restart 45 | ``` 46 | 47 | 다시 MySQL을 재시작한다. 48 | -------------------------------------------------------------------------------- /docs/2. nestjs_version_7_to_8_migration.md: -------------------------------------------------------------------------------- 1 | # nest version이 달라졌을 경우 2 | 3 | ```bash 4 | $ npm uninstall @nestjs/common 5 | $ npm uninstall @nestjs/core 6 | 7 | $ npm install @nestjs/common@latest --force 8 | $ npm install @nestjs/core@latest --force 9 | ``` 10 | 11 | 일일히 수정하기에는 양이 많기 때문에, `--force` option을 사용한다. 12 | -------------------------------------------------------------------------------- /docs/3. Policy.md: -------------------------------------------------------------------------------- 1 | # 생각해봐야 할 주제 2 | 3 | ## Seller 4 | 5 | 1. 판매자는 승인을 받아야 합니다. 6 | - commerce 라면 사업자등록번호를 포함해, 외부 API로 검증해야 합니다. 7 | - 등록되지 않은 사람이 물건을 팔아서는 안 됩니다. 8 | 9 | ## Product 10 | 11 | 1. 상품의 정보제공고시, 정책 안내는 반드시 있어야 합니다. 12 | 2. 상품에는 승인 여부를 나타내는 Boolean 또는 Date 값이 필요합니다. 13 | 14 | - 만약 관리자만이 상품을 올릴 수 있다면 불필요합니다. 15 | - 다만 여기서는 승인된 판매자만이 올릴 수 있다고 가정합니다. 16 | 17 | 3. 상품의 상태는 아래와 같이 나타냅니다. 18 | - 심사 : 승인되지도 삭제되지도 않은 상태 19 | - 판매 : 승인되었고 삭제되지 않은 상태 20 | - 품절 : 승인되었으나 삭제된 상태 21 | - 중단 : 승인되지 않았으나 삭제된 상태 ( 관리자에 의한 판매 중단 ) 22 | - 심사 이후 판매자는 판매, 품절을 결정할 수 있습니다. 23 | - 중단은 관리자만이 결정할 수 있습니다. 24 | 25 | ## Category 26 | 27 | 1. 카테고리에 상품이 없는 경우, getAll에서 제외해야 할 수도 있습니다. 28 | - 상품이 없어도 카테고리는 존재할 수 있으나, 29 | - Client 개발자가 이에 대한 처리를 안해줄 수도 있기 때문입니다. 30 | - 여기서는 별도의 체크를 하지 않았습니다. 31 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'app', 5 | script: './dist/main.js', 6 | instances: 0, 7 | exec_mode: 'cluster', 8 | wait_ready: true, 9 | listen_timeout: 50000, 10 | kill_timeout: 5000, 11 | }, 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /nestia.config.ts: -------------------------------------------------------------------------------- 1 | export = { 2 | input: 'src/controllers', 3 | output: 'src/api', 4 | }; 5 | -------------------------------------------------------------------------------- /ormconfig.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import { join } from 'path'; 3 | 4 | dotenv.config(); 5 | 6 | const NODE_ENV = process.env.NODE_ENV; 7 | 8 | export const ormconfig = { 9 | type: 'mysql', 10 | host: process.env[`${NODE_ENV}_DB_HOST`], 11 | port: Number(process.env[`${NODE_ENV}_DB_PORT`]), 12 | username: process.env[`${NODE_ENV}_DB_USERNAME`], 13 | password: process.env[`${NODE_ENV}_DB_PASSWORD`], 14 | database: process.env[`${NODE_ENV}_DB_DATABASE`], 15 | entities: [join(__dirname, '/src/models/tables/*.ts')], 16 | synchronize: false, 17 | charset: 'utf8mb4', 18 | logging: true, 19 | }; 20 | 21 | export default ormconfig; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-e-commerce", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "build:tsc": "tsc -p tsconfig.build.json", 12 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 13 | "start": "cross-env NODE_ENV=PRODUCTION nest start", 14 | "start:dev": "cross-env NODE_ENV=DEVELOPMENT nest start --watch", 15 | "start:debug": "cross-env NODE_ENV=DEVELOPMENT nest start --debug --watch", 16 | "start:prod": "node dist/main", 17 | "start:pm2": "cross-env NODE_ENV=PRODUCTION pm2 start", 18 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 19 | "test": "jest", 20 | "test:watch": "cross-env NODE_ENV=TEST jest --watch --detectOpenHandles", 21 | "test:cov": "jest --coverage", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "test:e2e": "jest --config ./test/jest-e2e.json", 24 | "nestia": "npx nestia sdk", 25 | "schema:drop": "cross-env NODE_ENV=DEVELOPMENT ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js schema:drop -d ./data-source.ts", 26 | "schema:sync": "cross-env NODE_ENV=DEVELOPMENT ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js schema:sync -d ./data-source.ts", 27 | "build:api": "rimraf packages/api/lib && npx nestia sdk && tsc -p tsconfig.api.json", 28 | "build:models": "rimraf packages/models/lib && tsc -p tsconfig.models.json", 29 | "package:api": "npm run build:api && cd packages/api && npm publish", 30 | "package:models": "npm run build:models && cd packages/models && npm publish" 31 | }, 32 | "dependencies": { 33 | "@nestjs/common": "^9.0.3", 34 | "@nestjs/config": "^2.2.0", 35 | "@nestjs/core": "^9.0.3", 36 | "@nestjs/jwt": "^9.0.0", 37 | "@nestjs/passport": "^9.0.0", 38 | "@nestjs/platform-express": "^9.0.3", 39 | "@nestjs/swagger": "^6.0.3", 40 | "@nestjs/throttler": "^3.0.0", 41 | "@nestjs/typeorm": "^9.0.0", 42 | "bcrypt": "^5.0.1", 43 | "class-transformer": "^0.5.1", 44 | "class-validator": "^0.13.2", 45 | "cross-env": "^7.0.3", 46 | "mysql2": "^2.3.3", 47 | "nestia-fetcher": "^2.0.1", 48 | "passport": "^0.6.0", 49 | "passport-jwt": "^4.0.0", 50 | "passport-kakao": "^1.0.1", 51 | "passport-local": "^1.0.0", 52 | "reflect-metadata": "^0.1.13", 53 | "rimraf": "^3.0.2", 54 | "rxjs": "^7.5.6", 55 | "swagger-ui-express": "^4.4.0", 56 | "typeorm": "^0.3.7" 57 | }, 58 | "devDependencies": { 59 | "@nestjs/cli": "^9.0.0", 60 | "@nestjs/schematics": "^9.0.1", 61 | "@nestjs/testing": "^9.0.3", 62 | "@types/bcrypt": "^5.0.0", 63 | "@types/express": "^4.17.13", 64 | "@types/jest": "^28.1.4", 65 | "@types/node": "^18.0.3", 66 | "@types/passport-jwt": "^3.0.6", 67 | "@types/passport-kakao": "^1.0.0", 68 | "@types/passport-local": "^1.0.34", 69 | "@types/supertest": "^2.0.12", 70 | "@typescript-eslint/eslint-plugin": "^5.30.6", 71 | "@typescript-eslint/parser": "^5.30.6", 72 | "eslint": "^8.19.0", 73 | "eslint-config-prettier": "^8.5.0", 74 | "eslint-plugin-prettier": "^4.2.1", 75 | "jest": "^28.1.2", 76 | "prettier": "^2.7.1", 77 | "supertest": "^6.2.4", 78 | "ts-jest": "^28.0.5", 79 | "ts-loader": "^9.3.1", 80 | "ts-node": "^10.8.2", 81 | "tsconfig-paths": "^4.0.0", 82 | "typescript": "^4.7.4" 83 | }, 84 | "types": "src/types/index.d.ts", 85 | "jest": { 86 | "moduleFileExtensions": [ 87 | "js", 88 | "json", 89 | "ts" 90 | ], 91 | "rootDir": "src", 92 | "moduleNameMapper": { 93 | "^@root/(.*)$": "/$1" 94 | }, 95 | "testRegex": ".*\\.spec\\.ts$", 96 | "transform": { 97 | "^.+\\.(t|j)s$": "ts-jest" 98 | }, 99 | "collectCoverageFrom": [ 100 | "**/*.(t|j)s" 101 | ], 102 | "coverageDirectory": "../coverage", 103 | "testEnvironment": "node" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; 3 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 4 | import { HttpExceptionFilter } from './common/filters/http-exception.fiter'; 5 | import { TimeoutInterceptor } from './common/interceptors/timout.interceptor'; 6 | import { ConfigModule } from '@nestjs/config'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | import { TypeOrmModuleOptions } from './config/typeorm'; 9 | import { AuthModule } from './auth/auth.module'; 10 | import { LoggingInterceptor } from './interceptors/logging.interceptor'; 11 | import { TransformInterceptor } from './interceptors/transform.interceptor'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forRootAsync(TypeOrmModuleOptions), 16 | ConfigModule.forRoot({ isGlobal: true }), 17 | ThrottlerModule.forRoot({ ttl: 60, limit: 10 }), 18 | AuthModule, 19 | ], 20 | controllers: [], 21 | providers: [ 22 | { 23 | provide: APP_GUARD, 24 | useClass: ThrottlerGuard, 25 | }, 26 | { 27 | provide: APP_INTERCEPTOR, 28 | useClass: TimeoutInterceptor, 29 | }, 30 | { 31 | provide: APP_FILTER, 32 | useClass: HttpExceptionFilter, 33 | }, 34 | { 35 | provide: APP_INTERCEPTOR, 36 | useClass: LoggingInterceptor, 37 | }, 38 | { 39 | provide: APP_INTERCEPTOR, 40 | useClass: TransformInterceptor, 41 | }, 42 | ], 43 | }) 44 | export class AppModule {} 45 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Request, Post, UseGuards } from '@nestjs/common'; 2 | import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { LoginUserDto } from '@root/models/dtos/login-user.dto'; 4 | import { LocalGuard } from './guards/local.guard'; 5 | 6 | @ApiTags('권한 / Auth') 7 | @Controller('api/auth') 8 | export class AuthController { 9 | @ApiOperation({ summary: '이메일과 패스워드를 이용한 로그인' }) 10 | @ApiBody({ type: LoginUserDto }) 11 | @UseGuards(LocalGuard) 12 | @Post('login') 13 | async login(@Request() req) { 14 | return req.user; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from '../models/tables/user'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { JwtModule } from '@nestjs/jwt'; 7 | import { AuthService } from './auth.service'; 8 | import { JwtStrategy } from './strategies/jwt.strategy'; 9 | import { CategoriesModule } from '@root/modules/categories.module'; 10 | import { ProductsModule } from '@root/modules/products.module'; 11 | import { SellersModule } from '@root/modules/sellers.module'; 12 | import { UsersModule } from '@root/modules/users.module'; 13 | import { AuthController } from './auth.controller'; 14 | import { LocalStrategy } from './strategies/local.strategy'; 15 | 16 | @Module({ 17 | imports: [ 18 | PassportModule.register({ session: false }), 19 | JwtModule.registerAsync({ 20 | imports: [ConfigModule], 21 | inject: [ConfigService], 22 | useFactory: (configService: ConfigService) => { 23 | return { 24 | secret: configService.get('ACCESS_KEY'), 25 | signOptions: { algorithm: 'HS256', expiresIn: '1y' }, 26 | }; 27 | }, 28 | }), 29 | TypeOrmModule.forFeature([User]), 30 | UsersModule, 31 | CategoriesModule, 32 | SellersModule, 33 | ProductsModule, 34 | ], 35 | controllers: [AuthController], 36 | providers: [AuthService, JwtStrategy, LocalStrategy], 37 | exports: [AuthService], 38 | }) 39 | export class AuthModule {} 40 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { User } from '../models/tables/user'; 4 | import * as bcrypt from 'bcrypt'; 5 | import { UsersService } from '@root/providers/users.service'; 6 | 7 | @Injectable() 8 | export class AuthService { 9 | constructor( 10 | private readonly jwtService: JwtService, 11 | private readonly usersService: UsersService, 12 | ) {} 13 | 14 | async validateUser(email: string, password: string): Promise { 15 | const user = await this.usersService.findOneByEmail(email); 16 | if (user) { 17 | const isRightPassword = await bcrypt.compare(password, user.password); 18 | if (isRightPassword) { 19 | delete user.password; 20 | return user; 21 | } 22 | } 23 | return null; 24 | } 25 | 26 | userLogin(user: User) { 27 | const token = this.jwtService.sign({ ...user }); 28 | return { token }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/kakao.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class KaKaoGuard extends AuthGuard('kakao') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/local.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/throttler-behind-proxy.guard.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerGuard } from '@nestjs/throttler'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class ThrottlerBehindProxyGuard extends ThrottlerGuard { 6 | protected getTracker(req: Record): string { 7 | return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | @Injectable() 7 | export class JwtStrategy extends PassportStrategy(Strategy) { 8 | constructor(private readonly configService: ConfigService) { 9 | super({ 10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 11 | ignoreExpiration: true, 12 | secretOrKey: configService.get('ACCESS_KEY'), 13 | }); 14 | } 15 | 16 | async validate(payload: any) { 17 | const { iat, exp, ...user } = payload; 18 | return user; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/strategies/kakao.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy, Profile } from 'passport-kakao'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { config } from 'dotenv'; 5 | 6 | config(); 7 | 8 | @Injectable() 9 | export class KakaoStrategy extends PassportStrategy(Strategy) { 10 | constructor() { 11 | super({ 12 | clientID: process.env.KAKAO_REST_API_KEY, 13 | clientSecret: '', 14 | callbackURL: 'http://127.0.0.1:3000/api/users/kakao/callback', 15 | }); 16 | } 17 | 18 | async validate( 19 | accessToken: string, 20 | refreshToken: string, 21 | profile: Profile, 22 | done, 23 | ) { 24 | done(null, profile); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from '../auth.service'; 5 | import { JwtService } from '@nestjs/jwt'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor( 10 | private authService: AuthService, 11 | private readonly jwtService: JwtService, 12 | ) { 13 | super({ 14 | usernameField: 'email', 15 | passwordField: 'password', 16 | }); 17 | } 18 | 19 | async validate(email: string, password: string): Promise { 20 | const user = await this.authService.validateUser(email, password); 21 | if (!user) { 22 | throw new UnauthorizedException(); 23 | } 24 | 25 | return this.jwtService.sign({ ...user }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/decorators/user-id.decorator.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user.decorator'; 2 | 3 | export const UserId = () => User('id'); 4 | -------------------------------------------------------------------------------- /src/common/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const User = createParamDecorator( 4 | (data: string, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | const user = request.user; 7 | 8 | return data ? user?.[data] : user; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /src/common/filters/http-exception.fiter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | } from '@nestjs/common'; 7 | import { Request, Response } from 'express'; 8 | 9 | @Catch(HttpException) 10 | export class HttpExceptionFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp(); 13 | const response = ctx.getResponse(); 14 | const request = ctx.getRequest(); 15 | const status = exception.getStatus(); 16 | 17 | response.status(status).json({ 18 | statusCode: status, 19 | timestamp: new Date().toISOString(), 20 | path: request.url, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/common/interceptors/timout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | RequestTimeoutException, 7 | } from '@nestjs/common'; 8 | import { Observable, throwError, TimeoutError } from 'rxjs'; 9 | import { catchError, timeout } from 'rxjs/operators'; 10 | 11 | @Injectable() 12 | export class TimeoutInterceptor implements NestInterceptor { 13 | intercept(context: ExecutionContext, next: CallHandler): Observable { 14 | return next.handle().pipe( 15 | timeout(5000), 16 | catchError((err) => { 17 | if (err instanceof TimeoutError) { 18 | return throwError(new RequestTimeoutException()); 19 | } 20 | return throwError(err); 21 | }), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/config/constant/index.ts: -------------------------------------------------------------------------------- 1 | export const NON_PAGINATION = 'NON_PAGENATION'; 2 | -------------------------------------------------------------------------------- /src/config/swagger/index.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export const SwaggerSetting = (app: INestApplication) => { 5 | const config = new DocumentBuilder() 6 | .setTitle('nestjs-e-commerce-example') 7 | .setDescription('The API description') 8 | .setVersion('1.0') 9 | .addTag('kakasoo') 10 | .addBearerAuth({ type: 'http', scheme: 'bearer', in: 'header' }, 'Bearer') 11 | .build(); 12 | 13 | const document = SwaggerModule.createDocument(app, config); 14 | SwaggerModule.setup('api', app, document); 15 | }; 16 | -------------------------------------------------------------------------------- /src/config/typeorm/index.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule, ConfigService } from '@nestjs/config'; 2 | import * as path from 'path'; 3 | 4 | export const TypeOrmModuleOptions = { 5 | imports: [ConfigModule], 6 | inject: [ConfigService], 7 | useFactory: async (configService: ConfigService) => { 8 | const NODE_ENV = configService.get('NODE_ENV'); 9 | 10 | const option = { 11 | type: configService.get('DB_TYPE'), 12 | host: configService.get(`${NODE_ENV}_DB_HOST`), 13 | port: Number(configService.get(`${NODE_ENV}_DB_PORT`)), 14 | username: configService.get(`${NODE_ENV}_DB_USERNAME`), 15 | database: configService.get(`${NODE_ENV}_DB_DATABASE`), 16 | password: configService.get(`${NODE_ENV}_DB_PASSWORD`), 17 | entities: [ 18 | path.join(__dirname, '../../models/tables/*.ts'), 19 | path.join(__dirname, '../../models/tables/*.js'), 20 | ], 21 | synchronize: true, 22 | socketPath: '/tmp/mysql.sock', 23 | ...(NODE_ENV === 'DEVELOPMENT' 24 | ? { retryAttempts: 10, logging: true } 25 | : { logging: false }), 26 | }; 27 | 28 | console.log(option); 29 | return option; 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/controllers/categories.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | DefaultValuePipe, 4 | Get, 5 | Param, 6 | ParseIntPipe, 7 | Query, 8 | } from '@nestjs/common'; 9 | import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; 10 | import { CategoriesService } from '../providers/categories.service'; 11 | import { Category } from '../models/tables/category'; 12 | 13 | @ApiTags('카테고리 / Categories') 14 | @Controller('api/categories') 15 | export class CategoriesController { 16 | constructor(private readonly categoriesService: CategoriesService) {} 17 | 18 | @ApiOperation({ summary: 'MVP : 카테고리 별 상품 조회' }) 19 | @ApiParam({ name: 'id', description: 'categoryId' }) 20 | @Get(':id/products') 21 | async getProductsBy( 22 | @Param('id', ParseIntPipe) categoryId: number, 23 | @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, 24 | ) { 25 | return await this.categoriesService.getProductsBy(categoryId, page); 26 | } 27 | 28 | @ApiOperation({ summary: "MVP : 생성된 '모든' 카테고리 조회" }) 29 | @Get() 30 | async getAll(): Promise { 31 | return this.categoriesService.getAll(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/controllers/products.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; 2 | import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; 3 | import { ProductsService } from '../providers/products.service'; 4 | 5 | @ApiTags('상품 / Products') 6 | @Controller('api/products') 7 | export class ProductsController { 8 | constructor(private readonly productsService: ProductsService) {} 9 | 10 | @ApiOperation({ summary: 'MVP : 상품의 상세 내역을 조회' }) 11 | @ApiParam({ name: 'id', description: 'productId' }) 12 | @Get('id') 13 | async getDetail(@Param('id', ParseIntPipe) productId: number) { 14 | return await this.productsService.getDetail(productId); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/controllers/sellers.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { SellersService } from '../providers/sellers.service'; 4 | 5 | @ApiTags('판매자 / Sellers') 6 | @Controller('api/sellers') 7 | export class SellersController { 8 | constructor(private readonly sellersService: SellersService) {} 9 | 10 | @Get('id') 11 | async getSellerInfo(@Param('id', ParseIntPipe) id: number) {} 12 | } 13 | -------------------------------------------------------------------------------- /src/controllers/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; 2 | import { User as UserEntity } from '../models/tables/user'; 3 | import { CreateUserDto } from '../models/dtos/create-user.dto'; 4 | import { UsersService } from '../providers/users.service'; 5 | import { User } from '../common/decorators/user.decorator'; 6 | import { JwtGuard } from '../auth/guards/jwt.guard'; 7 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 8 | 9 | @ApiTags('유저 / User') 10 | @Controller('api/users') 11 | export class UsersController { 12 | constructor(private readonly usersService: UsersService) {} 13 | 14 | @ApiOperation({ summary: 'MVP : Local 로그인을 위한 User 생성' }) 15 | @Post('sign-up') 16 | async signUp(@Body() createUserDto: CreateUserDto) { 17 | return await this.usersService.create(createUserDto); 18 | } 19 | 20 | @ApiOperation({ summary: 'MVP : 유저 프로필 조회 & 토큰에 담긴 값 Parsing.' }) 21 | @ApiBearerAuth('Bearer') 22 | @UseGuards(JwtGuard) 23 | @Get('profile') 24 | async getProfile(@User() user: UserEntity) { 25 | return user; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/decorators/is-not-empty-boolean.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { IsBoolean, IsNotEmpty } from 'class-validator'; 3 | import { ParseOptionalBoolean } from './is-optional-boolean.decorator'; 4 | 5 | export function IsNotEmptyBoolean() { 6 | return applyDecorators(IsNotEmpty(), ParseOptionalBoolean(), IsBoolean()); 7 | } 8 | -------------------------------------------------------------------------------- /src/decorators/is-not-empty-number.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsNotEmpty } from 'class-validator'; 4 | 5 | export function IsNotEmptyNumber() { 6 | return applyDecorators( 7 | IsInt(), 8 | IsNotEmpty(), 9 | Type(() => Number), 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/decorators/is-not-empty-string.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { IsNotEmpty, IsString, Length } from 'class-validator'; 3 | 4 | export function IsNotEmptyString(min: number, max: number) { 5 | return applyDecorators(IsNotEmpty(), IsString(), Length(min, max)); 6 | } 7 | -------------------------------------------------------------------------------- /src/decorators/is-optional-boolean.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsBoolean, IsOptional } from 'class-validator'; 4 | 5 | const optionalBooleanMapper = new Map([ 6 | ['undefined', undefined], 7 | ['true', true], 8 | ['false', false], 9 | ]); 10 | 11 | export const ParseOptionalBoolean = () => 12 | Transform((params) => { 13 | return optionalBooleanMapper.get(String(params.value)); 14 | }); 15 | 16 | export function IsOptionalBoolean() { 17 | return applyDecorators(IsOptional(), ParseOptionalBoolean(), IsBoolean()); 18 | } 19 | -------------------------------------------------------------------------------- /src/decorators/is-optional-number.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsOptional } from 'class-validator'; 4 | 5 | export function IsOptionalNumber() { 6 | return applyDecorators( 7 | IsOptional(), 8 | IsInt(), 9 | Type(() => Number), 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/decorators/is-optional-string.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { IsOptional, IsString, Length } from 'class-validator'; 3 | 4 | export function IsOptionalString(min: number, max: number) { 5 | return applyDecorators(IsOptional(), IsString(), Length(min, max)); 6 | } 7 | -------------------------------------------------------------------------------- /src/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { tap } from 'rxjs/operators'; 9 | 10 | @Injectable() 11 | export class LoggingInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | const request = context.switchToHttp().getRequest(); 14 | const { path, user, body, query } = request; 15 | const now = Date.now(); 16 | 17 | return next 18 | .handle() 19 | .pipe( 20 | tap((response) => 21 | console.log( 22 | `logging\n${request.method} ${path} time : ${ 23 | Date.now() - request.now 24 | }ms\nuser : ${JSON.stringify(user)}\nbody : ${JSON.stringify( 25 | body, 26 | )}\nquery : ${JSON.stringify(query)}`, 27 | ), 28 | ), 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/interceptors/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { NON_PAGINATION } from '@root/config/constant'; 8 | import { Request } from 'express'; 9 | import { Observable } from 'rxjs'; 10 | import { map } from 'rxjs/operators'; 11 | 12 | export const calcListTotalCount = ( 13 | totalCount = 0, 14 | limit = 0, 15 | ): { totalResult: number; totalPage: number } => { 16 | const totalResult = totalCount; 17 | const totalPage = 18 | totalResult % limit === 0 19 | ? totalResult / limit 20 | : Math.floor(totalResult / limit) + 1; 21 | return { totalResult, totalPage }; 22 | }; 23 | 24 | @Injectable() 25 | export class TransformInterceptor 26 | implements NestInterceptor> 27 | { 28 | intercept( 29 | context: ExecutionContext, 30 | next: CallHandler, 31 | ): Observable> { 32 | const request: Request = context.switchToHttp().getRequest(); 33 | 34 | return next.handle().pipe( 35 | map((value) => { 36 | if (value instanceof Object && 'count' in value && 'list' in value) { 37 | const { list, count, ...restData } = value; 38 | 39 | const limit = request.query['limit'] 40 | ? request.query['limit'] 41 | : NON_PAGINATION; 42 | const page = request.query['page']; 43 | const search = request.query['search']; 44 | 45 | return { 46 | result: true, 47 | code: 1000, 48 | data: { 49 | ...restData, 50 | list, 51 | ...(limit === NON_PAGINATION 52 | ? { totalResult: count, totalPage: 1 } 53 | : calcListTotalCount(count, Number(limit))), 54 | ...(search ? { search } : { search: null }), 55 | ...(page && { page }), 56 | } as ListOutputValue, 57 | }; 58 | } 59 | 60 | return { result: true, code: 1000, data: value }; 61 | }), 62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { SwaggerSetting } from './config/swagger'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | 8 | app.enableCors(); 9 | 10 | SwaggerSetting(app); 11 | await app.listen(3000, () => { 12 | if (process.env.NODE_ENV === 'production') { 13 | process.send('ready'); 14 | } 15 | }); 16 | } 17 | bootstrap(); 18 | -------------------------------------------------------------------------------- /src/models/common/time-columns.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | UpdateDateColumn, 4 | DeleteDateColumn, 5 | BaseEntity, 6 | } from 'typeorm'; 7 | 8 | export abstract class TimeColumns extends BaseEntity { 9 | @CreateDateColumn() 10 | public readonly createdAt!: Date; 11 | 12 | @UpdateDateColumn() 13 | public readonly updatedAt!: Date; 14 | 15 | @DeleteDateColumn() 16 | public readonly deletedAt!: Date; 17 | } 18 | -------------------------------------------------------------------------------- /src/models/dtos/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | import { User } from '../tables/user'; 3 | 4 | export class CreateUserDto extends PickType(User, [ 5 | 'name', 6 | 'nickname', 7 | 'email', 8 | 'password', 9 | 'phoneNumber', 10 | 'birth', 11 | 'gender', 12 | 'smsAdsConsent', 13 | 'emailAdsConsent', 14 | ] as const) {} 15 | -------------------------------------------------------------------------------- /src/models/dtos/login-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | import { User } from '../tables/user'; 3 | 4 | export class LoginUserDto extends PickType(User, [ 5 | 'email', 6 | 'password', 7 | ] as const) {} 8 | -------------------------------------------------------------------------------- /src/models/repositories/products.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Product } from '../tables/product'; 3 | 4 | @EntityRepository(Product) 5 | export class ProductsRepository extends Repository { 6 | async getProduct(productId: number) { 7 | return await this.createQueryBuilder('product') 8 | .withDeleted() 9 | .leftJoinAndMapMany('prouct.headers', 'product.headers', 'header') 10 | .leftJoinAndMapMany('product.bodies', 'product.bodies', 'body') 11 | .leftJoinAndMapMany( 12 | 'product.categories', 13 | 'product.categories', 14 | 'category', 15 | 'category.deletedAt IS NULL', 16 | ) 17 | .leftJoinAndMapMany( 18 | 'product.options', 19 | 'product.options', 20 | 'option', 21 | 'option.deletedAt IS NULL AND option.isSale = :isSale', 22 | { isSale: true }, 23 | ) 24 | .where('product.id = :productId', { productId }) 25 | .andWhere('product.isSale = :isSale', { isSale: true }) 26 | .getOne(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/models/tables/bodyImage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | JoinColumn, 7 | Index, 8 | } from 'typeorm'; 9 | import { Product } from './product'; 10 | 11 | @Index('FK__BodyImage__Product', ['productId'], {}) 12 | @Entity() 13 | export class BodyImage { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column('int', { nullable: false, select: false }) 18 | productId: number; 19 | 20 | @Column('varchar', { nullable: false }) 21 | url: string; 22 | 23 | @Column('decimal', { name: 'position', precision: 6, scale: 5, default: 0 }) 24 | position: number; 25 | 26 | @ManyToOne(() => Product, (product) => product.bodies) 27 | @JoinColumn({ name: 'productId', referencedColumnName: 'id' }) 28 | product: Product; 29 | } 30 | -------------------------------------------------------------------------------- /src/models/tables/category.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, ManyToMany } from 'typeorm'; 2 | import { TimeColumns } from '../common/time-columns'; 3 | import { Product } from './product'; 4 | 5 | @Entity() 6 | export class Category extends TimeColumns { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column('varchar', { nullable: false, unique: true }) 11 | name: string; 12 | 13 | @ManyToMany(() => Product, (product) => product.categories) 14 | products: Product[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/models/tables/headerImage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | JoinColumn, 7 | Index, 8 | } from 'typeorm'; 9 | import { Product } from './product'; 10 | 11 | @Index('FK__HeaderImage__Product', ['productId'], {}) 12 | @Entity() 13 | export class HeaderImage { 14 | @PrimaryGeneratedColumn() 15 | id: number; 16 | 17 | @Column('int', { nullable: false, select: false }) 18 | productId: number; 19 | 20 | @Column('varchar', { nullable: false }) 21 | url: string; 22 | 23 | @Column('decimal', { name: 'position', precision: 6, scale: 5, default: 0 }) 24 | position: number; 25 | 26 | @ManyToOne(() => Product, (product) => product.headers) 27 | @JoinColumn({ name: 'productId', referencedColumnName: 'id' }) 28 | product: Product; 29 | } 30 | -------------------------------------------------------------------------------- /src/models/tables/option.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | Index, 7 | JoinColumn, 8 | } from 'typeorm'; 9 | import { TimeColumns } from '../common/time-columns'; 10 | import { OptionGroup } from './optionGroup'; 11 | 12 | @Index('FK__Option__OptionGroup', ['groupId'], {}) 13 | @Entity() 14 | export class Option extends TimeColumns { 15 | @PrimaryGeneratedColumn() 16 | public id!: number; 17 | 18 | @Column('int', { nullable: false, select: false }) 19 | public groupId!: number; 20 | 21 | @Column('varchar', { nullable: false, comment: '옵션 이름' }) 22 | public title!: string; 23 | 24 | @Column('tinyint', { width: 1, nullable: false, default: false }) 25 | public isSale!: boolean; 26 | 27 | @ManyToOne(() => OptionGroup, (group) => group.options) 28 | @JoinColumn({ name: 'groupId', referencedColumnName: 'id' }) 29 | group: OptionGroup; 30 | } 31 | -------------------------------------------------------------------------------- /src/models/tables/optionGroup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | JoinColumn, 7 | OneToMany, 8 | Index, 9 | } from 'typeorm'; 10 | import { TimeColumns } from '../common/time-columns'; 11 | import { Option } from './option'; 12 | import { Product } from './product'; 13 | 14 | @Index('FK__OptionGroup__Product', ['productId'], {}) 15 | @Entity() 16 | export class OptionGroup extends TimeColumns { 17 | @PrimaryGeneratedColumn() 18 | id: number; 19 | 20 | @Column('int', { nullable: false, select: false }) 21 | public productId!: number; 22 | 23 | @Column('tinyint', { 24 | width: 1, 25 | nullable: false, 26 | default: false, 27 | comment: '선택 옵션 유무', 28 | }) 29 | public isOptional!: boolean; 30 | 31 | @Column('varchar', { nullable: false, comment: '옵션 그룹 이름' }) 32 | public title!: string; 33 | 34 | @ManyToOne(() => Product, (product) => product.optionGroups) 35 | @JoinColumn({ name: 'productId', referencedColumnName: 'id' }) 36 | product: Product; 37 | 38 | @OneToMany(() => Option, (option) => option.group) 39 | options: Option[]; 40 | } 41 | -------------------------------------------------------------------------------- /src/models/tables/product.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | Index, 7 | OneToMany, 8 | ManyToMany, 9 | JoinTable, 10 | } from 'typeorm'; 11 | import { TimeColumns } from '../common/time-columns'; 12 | import { BodyImage } from './bodyImage'; 13 | import { Category } from './category'; 14 | import { HeaderImage } from './headerImage'; 15 | import { OptionGroup } from './optionGroup'; 16 | import { Seller } from './seller'; 17 | import { User } from './user'; 18 | 19 | @Index('FK__Product__Seller', ['sellerId'], {}) 20 | @Entity() 21 | export class Product extends TimeColumns { 22 | @PrimaryGeneratedColumn() 23 | public readonly id: number; 24 | 25 | @Column('int', { nullable: false, comment: '판매자 ID' }) 26 | public sellerId: number; 27 | 28 | @Column('varchar', { nullable: false, comment: '상품 이름' }) 29 | public title: string; 30 | 31 | @Column('varchar', { nullable: true, comment: '상품 설명' }) 32 | public description: string; 33 | 34 | @Column('varchar', { nullable: true, comment: '안내 문구' }) 35 | public guide: string; 36 | 37 | @Column('int', { nullable: false, comment: '원가' }) 38 | public originalPrice: number; 39 | 40 | @Column('int', { nullable: false, comment: '판매가' }) 41 | public salesPrice: number; 42 | 43 | @Column('int', { nullable: false, comment: '출고 소요 일자' }) 44 | public releaseCount: number; 45 | 46 | @Column('text', { nullable: false, select: false, comment: '정보 제공 고시' }) 47 | public announcement: string; 48 | 49 | @Column('text', { nullable: false, select: false, comment: '정책 안내' }) 50 | public policy: string; 51 | 52 | @ManyToOne((Type) => Seller, (seller) => seller.products) 53 | seller: Seller; 54 | 55 | @OneToMany(() => HeaderImage, (image) => image.product) 56 | headers: HeaderImage[]; 57 | 58 | @OneToMany(() => BodyImage, (image) => image.product) 59 | bodies: BodyImage[]; 60 | 61 | @Column('tinyint', { width: 1, nullable: false, default: false }) 62 | public isSale!: boolean; 63 | 64 | @ManyToMany(() => Category, (category) => category.products) 65 | @JoinTable({ name: 'product_has_categories' }) 66 | categories: Category[]; 67 | 68 | @OneToMany(() => OptionGroup, (group) => group.product) 69 | optionGroups: OptionGroup[]; 70 | 71 | @ManyToMany(() => User, (user) => user.products, { nullable: false }) 72 | @JoinTable({ name: 'user_like_product' }) 73 | users: User[]; 74 | } 75 | -------------------------------------------------------------------------------- /src/models/tables/productHasCategory.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, PrimaryColumn } from 'typeorm'; 2 | import { Product } from './product'; 3 | 4 | @Entity({ name: 'product_has_categories' }) 5 | export class ProductHasCategory { 6 | @PrimaryColumn() 7 | public readonly categoryId!: number; 8 | 9 | @PrimaryColumn() 10 | public readonly productId!: number; 11 | 12 | @ManyToOne(() => Product, (product) => product.id) 13 | product: Product; 14 | } 15 | -------------------------------------------------------------------------------- /src/models/tables/seller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | OneToMany, 6 | JoinColumn, 7 | } from 'typeorm'; 8 | import { TimeColumns } from '../common/time-columns'; 9 | import { Product } from './product'; 10 | 11 | @Entity() 12 | export class Seller extends TimeColumns { 13 | @PrimaryGeneratedColumn() 14 | public readonly id!: number; 15 | 16 | @Column('varchar', { unique: true, select: false }) 17 | public address!: string; 18 | 19 | @Column('varchar', { select: false }) 20 | public password!: string; 21 | 22 | @Column('varchar') 23 | public name!: string; 24 | 25 | @Column('varchar', { nullable: true, select: false }) 26 | public nickname!: string; 27 | 28 | @Column('varchar', { nullable: true, select: false }) 29 | public profileImage!: string; 30 | 31 | @Column('varchar', { nullable: true, unique: true, select: false }) 32 | public phoneNumber!: string; 33 | 34 | @Column('varchar', { nullable: true, unique: true, select: false }) 35 | public email!: string; 36 | 37 | @Column('varchar', { comment: '사업장 명' }) 38 | public shopName!: string; 39 | 40 | @Column('int', { default: 0, comment: '기본 배송비' }) 41 | public basicFee!: number; 42 | 43 | @Column('int', { default: 0, comment: '도서 산간 지방 배송비' }) 44 | public exceptionFee: number; 45 | 46 | @Column('int', { default: 0, comment: '배송비 무료 기준 금액' }) 47 | public baseFee: number; 48 | 49 | @Column('varchar', { select: false, comment: '사업장 명' }) 50 | public companyName!: string; 51 | 52 | @Column('varchar', { select: false, comment: '영업용 메일' }) 53 | public companyEmail!: string; 54 | 55 | @Column('varchar', { select: false, comment: '영업용 번호' }) 56 | public companyPhonNumber!: string; 57 | 58 | @Column('varchar', { select: false, comment: '사업자번호' }) 59 | public businessNumber!: string; 60 | 61 | @OneToMany(() => Product, (product) => product.seller) 62 | @JoinColumn({ name: 'sellerId', referencedColumnName: 'id' }) 63 | products: Product[]; 64 | } 65 | -------------------------------------------------------------------------------- /src/models/tables/user.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmptyString } from '@root/decorators/is-not-empty-string.decorator'; 3 | import { IsOptionalBoolean } from '@root/decorators/is-optional-boolean.decorator'; 4 | import { Type } from 'class-transformer'; 5 | import { IsDate, IsEmail, IsOptional } from 'class-validator'; 6 | import { 7 | Entity, 8 | PrimaryGeneratedColumn, 9 | Column, 10 | ManyToMany, 11 | JoinTable, 12 | } from 'typeorm'; 13 | import { TimeColumns } from '../common/time-columns'; 14 | import { Product } from './product'; 15 | 16 | @Entity() 17 | export class User extends TimeColumns { 18 | @PrimaryGeneratedColumn() 19 | public readonly id!: number; 20 | 21 | @ApiProperty({ description: '이름 칼럼으로 사용자의 이름을 의미' }) 22 | @IsNotEmptyString(1, 50) 23 | @Column('varchar', { length: 50, nullable: false, select: false }) 24 | public name!: string; 25 | 26 | @ApiProperty({ description: '사용자의 별칭, 설정하지 않는 경우도 있다.' }) 27 | @IsNotEmptyString(1, 50) 28 | @Column('varchar', { length: 50 }) 29 | public nickname!: string; 30 | 31 | @ApiProperty({ description: '사용자의 프로필 이미지' }) 32 | @Column('varchar', { nullable: true, select: false }) 33 | public profileImage!: string; 34 | 35 | @ApiProperty({ description: '사용자의 전화번호로 동일한 값은 없다.' }) 36 | @IsNotEmptyString(1, 50) 37 | @Column('varchar', { nullable: true, unique: true, select: false }) 38 | public phoneNumber!: string; 39 | 40 | @ApiProperty({ description: '사용자의 이메일 주소로 로그인 시 필요' }) 41 | @IsEmail() 42 | @IsNotEmptyString(1, 50) 43 | @Column('varchar', { nullable: true, unique: true, select: false }) 44 | public email!: string; 45 | 46 | @ApiProperty({ description: '사용자의 비밀번호로 로그인 시 필요' }) 47 | @Column({ select: false }) 48 | password: string; 49 | 50 | @ApiProperty({ description: '생일 이벤트 및 고객 분석을 위해 수집' }) 51 | @IsOptional() 52 | @IsDate() 53 | @Type(() => Date) 54 | @Column('datetime', { nullable: true, select: false }) 55 | public birth!: Date; 56 | 57 | @ApiProperty({ description: '사용자의 성별로 true면 남자라고 가정한다.' }) 58 | @IsOptionalBoolean() 59 | @Column({ nullable: true, width: 1, select: false }) 60 | public gender!: boolean; 61 | 62 | // @ApiProperty({ description: '사용자가 현재 가지고 있는 마일리지' }) 63 | // @Column('int', { 64 | // nullable: false, 65 | // select: false, 66 | // comment: '마일리지 잔여금', 67 | // default: 0, 68 | // }) 69 | // public mileage!: number; 70 | 71 | @ApiProperty({ description: '회원 가입 시 받는 값으로 수신 거부 가능' }) 72 | @IsOptionalBoolean() 73 | @Column({ 74 | width: 1, 75 | nullable: false, 76 | select: false, 77 | default: false, 78 | comment: 'sms 광고 수신 동의', 79 | }) 80 | public smsAdsConsent!: boolean; 81 | 82 | @ApiProperty({ description: '회원 가입 시 받는 값으로 수신 거부 가능' }) 83 | @IsOptionalBoolean() 84 | @Column({ 85 | width: 1, 86 | nullable: false, 87 | select: false, 88 | default: false, 89 | comment: 'email 광고 수신 동의', 90 | }) 91 | public emailAdsConsent!: boolean; 92 | 93 | @ManyToMany(() => Product, (product) => product.users, { nullable: false }) 94 | @JoinTable({ name: 'user_like_product' }) 95 | products: Product[]; 96 | } 97 | -------------------------------------------------------------------------------- /src/models/tables/userLikeProduct.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryColumn, CreateDateColumn } from 'typeorm'; 2 | 3 | @Entity({ name: 'user_like_product' }) 4 | export class UserLikeProduct { 5 | @PrimaryColumn() 6 | public readonly userId!: number; 7 | 8 | @PrimaryColumn() 9 | public readonly productId!: number; 10 | 11 | @CreateDateColumn() 12 | public readonly createdAt: Date; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/categories.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CategoriesService } from '../providers/categories.service'; 3 | import { CategoriesController } from '../controllers/categories.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Category } from '../models/tables/category'; 6 | import { ProductHasCategory } from '../models/tables/productHasCategory'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([Category, ProductHasCategory])], 10 | controllers: [CategoriesController], 11 | providers: [CategoriesService], 12 | }) 13 | export class CategoriesModule {} 14 | -------------------------------------------------------------------------------- /src/modules/products.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProductsService } from '../providers/products.service'; 3 | import { ProductsController } from '../controllers/products.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { ProductsRepository } from '@root/models/repositories/products.repository'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([ProductsRepository])], 9 | controllers: [ProductsController], 10 | providers: [ProductsService], 11 | }) 12 | export class ProductsModule {} 13 | -------------------------------------------------------------------------------- /src/modules/sellers.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SellersService } from '../providers/sellers.service'; 3 | import { SellersController } from '../controllers/sellers.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Seller } from '../models/tables/seller'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Seller])], 9 | controllers: [SellersController], 10 | providers: [SellersService], 11 | }) 12 | export class SellersModule {} 13 | -------------------------------------------------------------------------------- /src/modules/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { User } from '../models/tables/user'; 4 | import { UsersController } from '../controllers/users.controller'; 5 | import { UsersService } from '../providers/users.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([User])], 9 | controllers: [UsersController], 10 | providers: [UsersService], 11 | exports: [UsersService], 12 | }) 13 | export class UsersModule {} 14 | -------------------------------------------------------------------------------- /src/providers/categories.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Category } from '../models/tables/category'; 4 | import { Product } from '../models/tables/product'; 5 | import { Repository } from 'typeorm'; 6 | import { ProductHasCategory } from '@root/models/tables/productHasCategory'; 7 | import { getOffset } from '@root/utils/getOffset'; 8 | 9 | @Injectable() 10 | export class CategoriesService { 11 | constructor( 12 | @InjectRepository(Category) 13 | private readonly categoriesRepository: Repository, 14 | @InjectRepository(ProductHasCategory) 15 | private readonly productHasCategoriesRepository: Repository, 16 | ) {} 17 | 18 | async getAll(): Promise { 19 | return await this.categoriesRepository.find(); 20 | } 21 | 22 | async getProductsBy( 23 | categoryId: number, 24 | page: number, 25 | ): Promise<{ count: number; products: Product[] }> { 26 | const [count, relations] = await Promise.all([ 27 | this.productHasCategoriesRepository.count({ where: { categoryId } }), 28 | this.productHasCategoriesRepository.find({ 29 | relations: { product: true }, 30 | where: { categoryId }, 31 | ...getOffset(page), 32 | }), 33 | ]); 34 | 35 | return { count, products: relations.map((el) => el.product) }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/providers/products.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { ProductsRepository } from '@root/models/repositories/products.repository'; 4 | 5 | @Injectable() 6 | export class ProductsService { 7 | constructor( 8 | @InjectRepository(ProductsRepository) 9 | private readonly productsRepository: ProductsRepository, 10 | ) {} 11 | 12 | async getDetail(productId: number) { 13 | const product = await this.productsRepository.getProduct(productId); 14 | if (!product) { 15 | throw new Error('there is not product!'); 16 | } 17 | return product; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/providers/sellers.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Seller } from '../models/tables/seller'; 4 | import { Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class SellersService { 8 | constructor( 9 | @InjectRepository(Seller) 10 | private readonly sellersRepository: Repository, 11 | ) {} 12 | } 13 | -------------------------------------------------------------------------------- /src/providers/users.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from '../models/tables/user'; 4 | import { Repository } from 'typeorm'; 5 | import { CreateUserDto } from '../models/dtos/create-user.dto'; 6 | import * as bcrypt from 'bcrypt'; 7 | 8 | @Injectable() 9 | export class UsersService { 10 | constructor( 11 | @InjectRepository(User) private readonly usersRepository: Repository, 12 | ) {} 13 | 14 | async create(createUserDto: CreateUserDto) { 15 | const users = await this.usersRepository.find({ 16 | where: [ 17 | { email: createUserDto.email }, 18 | { phoneNumber: createUserDto.phoneNumber }, 19 | ], 20 | }); 21 | 22 | if (users.length) { 23 | // throw new BadRequestException( 24 | return '이미 해당 전화번호나 이메일로 만들어진 유저가 있습니다.'; 25 | // ); 26 | } 27 | 28 | const hashedPassword = await bcrypt.hash(createUserDto.password, 10); 29 | 30 | return await this.usersRepository.save({ 31 | ...createUserDto, 32 | password: hashedPassword, 33 | }); 34 | } 35 | 36 | async findOneByEmail(email: string) { 37 | return await this.usersRepository.findOne({ 38 | select: { 39 | id: true, 40 | name: true, 41 | nickname: true, 42 | phoneNumber: true, 43 | email: true, 44 | password: true, 45 | birth: true, 46 | gender: true, 47 | }, 48 | where: { email }, 49 | }); 50 | } 51 | 52 | async findOne(userId: number): Promise { 53 | return await this.usersRepository.findOne({ where: { id: userId } }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test } from '@nestjs/testing'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from '../models/tables/user'; 5 | import { TypeOrmModuleOptions } from '../config/typeorm'; 6 | import { AuthModule } from '../auth/auth.module'; 7 | import { AuthController } from '@root/auth/auth.controller'; 8 | 9 | describe('Auth Entity', () => { 10 | let controller: AuthController; 11 | 12 | beforeAll(async () => { 13 | const module = await Test.createTestingModule({ 14 | imports: [ 15 | TypeOrmModule.forRootAsync(TypeOrmModuleOptions), 16 | TypeOrmModule.forFeature([User]), 17 | ConfigModule.forRoot({ isGlobal: true }), 18 | AuthModule, 19 | ], 20 | controllers: [], 21 | providers: [], 22 | }).compile(); 23 | 24 | controller = module.get(AuthController); 25 | }); 26 | 27 | describe('0. 테스트 환경을 확인합니다.', () => { 28 | it.only('0-1. controller 가 정의되어야 합니다.', async () => { 29 | expect(controller).toBeDefined(); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/test/categories.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test } from '@nestjs/testing'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Category } from '../models/tables/category'; 5 | import { TypeOrmModuleOptions } from '../config/typeorm'; 6 | import { CategoriesController } from '../controllers/categories.controller'; 7 | import { CategoriesService } from '../providers/categories.service'; 8 | import { CategoriesModule } from '../modules/categories.module'; 9 | 10 | describe('Category Entity', () => { 11 | let controller: CategoriesController; 12 | let service: CategoriesService; 13 | let category; 14 | 15 | beforeAll(async () => { 16 | const module = await Test.createTestingModule({ 17 | imports: [ 18 | TypeOrmModule.forRootAsync(TypeOrmModuleOptions), 19 | TypeOrmModule.forFeature([Category]), 20 | ConfigModule.forRoot({ isGlobal: true }), 21 | CategoriesModule, 22 | ], 23 | controllers: [], 24 | providers: [], 25 | }).compile(); 26 | 27 | service = module.get(CategoriesService); 28 | controller = module.get(CategoriesController); 29 | }); 30 | 31 | describe('0. 테스트 환경을 확인합니다.', () => { 32 | it.only('0-1. Service와 Controller 가 정의되어야 합니다.', async () => { 33 | expect(controller).toBeDefined(); 34 | expect(service).toBeDefined(); 35 | }); 36 | }); 37 | 38 | describe('1. 카테고리들을 조회합니다.', () => { 39 | it.only('1-1. Response는 Category의 배열입니다.', async () => { 40 | const categories = await controller.getAll(); 41 | expect(categories).toBeInstanceOf(Array); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/test/products.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test } from '@nestjs/testing'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Product } from '../models/tables/product'; 5 | import { TypeOrmModuleOptions } from '../config/typeorm'; 6 | import { ProductsController } from '../controllers/products.controller'; 7 | import { ProductsService } from '../providers/products.service'; 8 | import { ProductsModule } from '../modules/products.module'; 9 | 10 | describe('Product Entity', () => { 11 | let controller: ProductsController; 12 | let service: ProductsService; 13 | let product; 14 | 15 | beforeAll(async () => { 16 | const module = await Test.createTestingModule({ 17 | imports: [ 18 | TypeOrmModule.forRootAsync(TypeOrmModuleOptions), 19 | TypeOrmModule.forFeature([Product]), 20 | ConfigModule.forRoot({ isGlobal: true }), 21 | ProductsModule, 22 | ], 23 | controllers: [], 24 | providers: [], 25 | }).compile(); 26 | 27 | service = module.get(ProductsService); 28 | controller = module.get(ProductsController); 29 | }); 30 | 31 | describe('0. 테스트 환경을 확인합니다.', () => { 32 | it.only('0-1. Service와 Controller 가 정의되어야 합니다.', async () => { 33 | expect(controller).toBeDefined(); 34 | expect(service).toBeDefined(); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/test/users.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModule } from '@nestjs/config'; 2 | import { Test } from '@nestjs/testing'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { User } from '../models/tables/user'; 5 | import { TypeOrmModuleOptions } from '../config/typeorm'; 6 | import { UsersController } from '../controllers/users.controller'; 7 | import { UsersService } from '../providers/users.service'; 8 | import { AuthModule } from '../auth/auth.module'; 9 | import { UsersModule } from '../modules/users.module'; 10 | import { AuthService } from '../auth/auth.service'; 11 | 12 | describe('User Entity', () => { 13 | let controller: UsersController; 14 | let service: UsersService; 15 | let authService: AuthService; 16 | let user; 17 | 18 | beforeAll(async () => { 19 | const module = await Test.createTestingModule({ 20 | imports: [ 21 | TypeOrmModule.forRootAsync(TypeOrmModuleOptions), 22 | TypeOrmModule.forFeature([User]), 23 | ConfigModule.forRoot({ isGlobal: true }), 24 | UsersModule, 25 | AuthModule, 26 | ], 27 | controllers: [], 28 | providers: [], 29 | }).compile(); 30 | 31 | service = module.get(UsersService); 32 | controller = module.get(UsersController); 33 | 34 | authService = module.get(AuthService); 35 | }); 36 | 37 | describe('0. 테스트 환경을 확인합니다.', () => { 38 | it('0-1. Service와 Controller 가 정의되어야 합니다.', async () => { 39 | expect(controller).toBeDefined(); 40 | expect(service).toBeDefined(); 41 | }); 42 | }); 43 | 44 | describe('1. 유저의 생성 로직을 검증합니다.', () => { 45 | let user: User; 46 | 47 | afterEach(async () => { 48 | const searched = await User.findOne({ where: { id: user.id } }); 49 | if (searched) { 50 | await User.remove(searched); 51 | } 52 | }); 53 | 54 | it('1-1. 유저를 생성하거나 조회합니다.', async () => { 55 | user = await controller.signUp({ 56 | name: 'test', 57 | nickname: 'test', 58 | email: 'test', 59 | password: 'test', 60 | phoneNumber: 'test', 61 | birth: new Date(1997, 10, 6), 62 | gender: true, 63 | smsAdsConsent: true, 64 | emailAdsConsent: true, 65 | }); 66 | console.log(user); 67 | 68 | expect(user).toBeDefined(); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | type ListType = { 2 | data: any[]; 3 | count: number; 4 | }; 5 | 6 | type ListOutputValue = { 7 | totalPage: number; 8 | totalResult: number; 9 | list: any[]; 10 | }; 11 | 12 | type ExtendedResponse = { 13 | result: boolean; 14 | code: number; 15 | // data: T; 16 | data: T extends ListType ? ListOutputValue : T; 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/getOffset.ts: -------------------------------------------------------------------------------- 1 | export const NUM_OF_ENTITIES = 10; 2 | 3 | export const getOffset = (page: number) => { 4 | const skip = page > 1 ? NUM_OF_ENTITIES * (page - 1) : 0; 5 | const take = NUM_OF_ENTITIES; 6 | return { skip, take }; 7 | }; 8 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.api.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "DOM", 11 | "ES2015" 12 | ] /* Specify library files to be included in the compilation. */, 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | "declaration": true /* Generates corresponding '.d.ts' file. */, 17 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 18 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./packages/api/lib" /* Redirect output structure to the directory. */, 21 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 22 | // "composite": true, /* Enable project compilation */ 23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true /* Enable all strict type-checking options. */, 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 39 | 40 | /* Additional Checks */ 41 | "noUnusedLocals": true /* Report errors on unused locals. */, 42 | "noUnusedParameters": true /* Report errors on unused parameters. */, 43 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 44 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 45 | 46 | /* Module Resolution Options */ 47 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 66 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 67 | "stripInternal": true, 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true /* Skip type checking of declaration files. */, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | }, 73 | "include": ["src/api"] 74 | } 75 | -------------------------------------------------------------------------------- /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": "es2020", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "paths": { 15 | "@root/*": ["./src/*"] 16 | } 17 | }, 18 | "include": ["./src", "types"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.models.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "DOM", 11 | "ES2015" 12 | ] /* Specify library files to be included in the compilation. */, 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | "declaration": true /* Generates corresponding '.d.ts' file. */, 17 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 18 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./packages/models/lib" /* Redirect output structure to the directory. */, 21 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 22 | // "composite": true, /* Enable project compilation */ 23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true /* Enable all strict type-checking options. */, 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 39 | 40 | /* Additional Checks */ 41 | "noUnusedLocals": true /* Report errors on unused locals. */, 42 | "noUnusedParameters": true /* Report errors on unused parameters. */, 43 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 44 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 45 | 46 | /* Module Resolution Options */ 47 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 66 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 67 | "stripInternal": true, 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true /* Skip type checking of declaration files. */, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | }, 73 | "include": ["src/models"] 74 | } 75 | --------------------------------------------------------------------------------