├── .dockerignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── fix-.md │ └── issue.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── develop.yml ├── .gitignore ├── .prettierrc ├── Dockerfile.dev ├── README.md ├── docker-compose.test.yml ├── docker-compose.yml ├── nest-cli.json ├── package.json ├── prisma ├── migrations │ ├── 20221022144813_connection_test │ │ └── migration.sql │ ├── 20230105060951_editwithia │ │ └── migration.sql │ ├── 20230105075140_edit_with_ia │ │ └── migration.sql │ ├── 20230109145733_init │ │ └── migration.sql │ ├── 20230130113036_update_user_social_login_id │ │ └── migration.sql │ ├── 20230722065946_add_relation_to_userplant_and_plant │ │ └── migration.sql │ ├── 20230722081307_rename_alias_water │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── dto │ │ ├── create-signin.dto.ts │ │ ├── response-signin.dto.ts │ │ └── response-token.dto.ts │ ├── guards │ │ ├── access-token.guard.ts │ │ ├── index.ts │ │ └── refresh-token.guard.ts │ └── strategies │ │ ├── access-token.strategy.ts │ │ ├── index.ts │ │ └── refresh-token.strategy.ts ├── common │ ├── dto │ │ ├── common-params.dto.ts │ │ └── response-success.dto.ts │ ├── interfaces │ │ ├── index.ts │ │ ├── jwt-payload.interface.ts │ │ └── jwt-token.interface.ts │ └── objects │ │ ├── index.ts │ │ └── response-message.object.ts ├── config │ ├── configuration.ts │ └── swagger.ts ├── constants │ └── swagger │ │ ├── auth.ts │ │ ├── index.ts │ │ └── plants.ts ├── exceptions │ ├── custom.exception.ts │ ├── global.exception.ts │ └── index.ts ├── interceptors │ └── webhook.interceptor.ts ├── main.ts ├── plants │ ├── constants │ │ └── plant-status.ts │ ├── dto │ │ ├── response-plant-detail.dto.ts │ │ ├── response-plant-information.dto.ts │ │ ├── response-plant-water-log.dto.ts │ │ ├── response-plants.dto.ts │ │ └── update-plant-detail.dto.ts │ ├── plants.controller.spec.ts │ ├── plants.controller.ts │ ├── plants.module.ts │ ├── plants.service.spec.ts │ ├── plants.service.ts │ └── utils │ │ └── plants.ts ├── prisma.service.ts ├── users │ ├── users.controller.spec.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.service.spec.ts │ └── users.service.ts └── utils │ ├── day.ts │ ├── error.ts │ ├── object.ts │ ├── success.ts │ └── validation.ts ├── test ├── app.e2e-spec.ts ├── jest-e2e.json ├── mock │ └── plants.mock.ts └── plants.e2e-spec.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | 4 | node_modules 5 | docker-compose.yml 6 | README.md 7 | dist -------------------------------------------------------------------------------- /.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 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/fix-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Fix:' 3 | about: fix 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | --- 11 | name: Fix: 12 | about: fix 13 | title: 'Fix: ~' 14 | labels: '' 15 | assignees: '' 16 | 17 | --- 18 | 19 | ## 🚅 Issue 한 줄 요약 20 | 21 | 22 | 23 | ## 🤷 Issue 세부 내용 24 | 25 | 26 | 27 | - [ ] todo! 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Feat:' 3 | about: feature 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | --- 11 | name: ISSUE_TEMPLATE 12 | about: Issue Template 13 | title: 'Feat: ~' 14 | labels: '' 15 | assignees: '' 16 | 17 | --- 18 | 19 | ## 🚅 Issue 한 줄 요약 20 | 21 | 22 | 23 | ## 🤷 Issue 세부 내용 24 | 25 | 26 | 27 | - [ ] todo! 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📚 PR 요약 / Linked Issue 2 | 3 | 해당 PR에서 작업한 내용을 한 줄로 요약해주세요. 4 | close #{no} 5 | 6 | ## 💡 변경 사항 7 | 8 | 디테일한 작업 내역을 적어주세요. 9 | 주의할 사항이 있다면 적어주세요. 10 | 변경사항 (모듈 설치 등)이 있다면 적어주세요. 11 | 12 | ## 📖 Swagger 13 | 14 | API Swagger 를 추가했다면 캡쳐해주세요. 15 | 16 | ## ✅ PR check list 17 | 18 | - [ ] 테스트 코드를 작성했나요? (unit/e2e) 19 | - [ ] 커밋 컨벤션, 제목 등을 확인했나요? 20 | - [ ] 알맞은 라벨을 달았나요? 21 | - [ ] 셀프 코드리뷰를 작성했나요? 22 | -------------------------------------------------------------------------------- /.github/workflows/develop.yml: -------------------------------------------------------------------------------- 1 | name: cherish-server-dev 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | 7 | jobs: 8 | build: 9 | env: 10 | PORT: ${{ secrets.PORT }} 11 | DATABASE_URL: ${{ secrets.DATABASE_URL_DEV }} 12 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEV }} 13 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }} 14 | REGION: ${{ secrets.REGION }} 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [16.x] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - name: Create .env file 31 | run: | 32 | touch .env 33 | echo PORT=${{ secrets.PORT }} >> .env 34 | echo DATABASE_URL=${{ secrets.DATABASE_URL_DEV }} >> .env 35 | echo AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID_DEV }} >> .env 36 | echo AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }} >> .env 37 | echo REGION=${{ secrets.REGION }} >> .env 38 | echo DOCKERFILE=Dockerfile.dev >> .env 39 | echo SENTRY_DSN=${{ secrets.SENTRY_DSN }} >> .env 40 | echo ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }} >> .env 41 | echo REFRESH_TOKEN_SECRET=${{ secrets.REFRESH_TOKEN_SECRET }} >> .env 42 | cat .env 43 | 44 | - name: Install dependencies 45 | run: yarn 46 | 47 | - name: Run build 48 | run: yarn build 49 | 50 | - name: Build the Docker image 51 | run: docker build -t cherish-dev/cherish-dev -f Dockerfile.dev . 52 | 53 | - name: Generate Deployment Package 54 | run: zip -r deploy.zip . -x '*.git*' './node_modules/*' 55 | 56 | - name: Add .env to deploy.zip 57 | run: zip deploy.zip .env 58 | 59 | - name: Get timestamp 60 | uses: gerred/actions/current-time@master 61 | id: current-time 62 | 63 | - name: Run string replace 64 | uses: frabert/replace-string-action@master 65 | id: format-time 66 | with: 67 | pattern: '[:\.]+' 68 | string: '${{ steps.current-time.outputs.time }}' 69 | replace-with: '-' 70 | flags: 'g' 71 | 72 | - name: Deploy to EB 73 | uses: einaregilsson/beanstalk-deploy@v14 74 | with: 75 | aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID_DEV }} 76 | aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }} 77 | application_name: cherish-dev 78 | environment_name: Cherishdev-env 79 | version_label: 'cherish-dev${{ steps.format-time.outputs.replaced }}' 80 | region: ${{ secrets.REGION }} 81 | deployment_package: deploy.zip 82 | 83 | - name: action-slack 84 | uses: 8398a7/action-slack@v3 85 | with: 86 | status: ${{ job.status }} 87 | author_name: Github Action Push Server 88 | fields: repo,message,commit,author,action,eventName,ref,workflow,job,took 89 | env: 90 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_DEV }} # required 91 | if: always() # Pick up events even if the job fails or is canceled. 92 | -------------------------------------------------------------------------------- /.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 | # env 15 | .env 16 | .env.test 17 | 18 | # OS 19 | .DS_Store 20 | 21 | # Tests 22 | /coverage 23 | /.nyc_output 24 | 25 | # IDEs and editors 26 | /.idea 27 | .project 28 | .classpath 29 | .c9/ 30 | *.launch 31 | .settings/ 32 | *.sublime-workspace 33 | 34 | # IDE - VSCode 35 | .vscode/* 36 | .vscode/settings.json 37 | !.vscode/tasks.json 38 | !.vscode/launch.json 39 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "all", 6 | "arrowParens": "always", 7 | "printWidth": 80, 8 | "tabWidth": 2 9 | } -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine AS base 2 | 3 | # node prune 설정 4 | RUN apk add curl bash && curl -sfL https://gobinaries.com/tj/node-prune | bash -s -- -b /usr/local/bin 5 | 6 | WORKDIR /usr/src/app 7 | 8 | COPY package.json ./ 9 | 10 | RUN ls -a && yarn 11 | 12 | FROM base AS dev 13 | 14 | COPY . . 15 | 16 | RUN ls -a && yarn build 17 | 18 | # run node prune - 사용하지 않는 모듈 제거 19 | RUN /usr/local/bin/node-prune 20 | 21 | FROM node:16-alpine 22 | 23 | COPY --from=base /usr/src/app/package.json ./ 24 | COPY --from=dev /usr/src/app/dist/ ./dist/ 25 | COPY --from=dev /usr/src/app/node_modules/ ./node_modules/ 26 | 27 | # port 설정 28 | EXPOSE 8081 29 | 30 | # 환경 변수 설정 31 | ENV NODE_ENV=development 32 | 33 | # start 34 | CMD ["node", "dist/src/main.js"] 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | # Set the version of docker compose to use 2 | version: '3.9' 3 | 4 | # The containers that compose the project 5 | services: 6 | db: 7 | image: mysql:8.0 8 | restart: always 9 | container_name: e2e-test-prisma 10 | ports: 11 | - '3306:3306' 12 | environment: 13 | MYSQL_ROOT_PASSWORD: root 14 | MYSQL_DATABASE: test 15 | volumes: 16 | - /var/lib/mysql 17 | command: 18 | - --character-set-server=utf8mb4 19 | - --collation-server=utf8mb4_unicode_ci 20 | - --skip-character-set-client-handshake 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | deploy: 4 | env_file: 5 | - .env 6 | build: 7 | context: . 8 | dockerfile: ${DOCKERFILE} 9 | ports: 10 | - '80:8081' 11 | environment: 12 | - TZ=Asia/Seoul 13 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cherish-Server", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "npx prisma generate && nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "yarn docker:up && sleep 6.5 && yarn migrate:test && yarn seed:test && dotenv -e .env.test -- jest --config ./test/jest-e2e.json && yarn docker:down", 22 | "migrate:test": "dotenv -e .env.test -- npx prisma migrate deploy", 23 | "seed:test": "dotenv -e .env.test -- npx prisma db seed", 24 | "docker:up": "docker-compose -f docker-compose.test.yml up -d", 25 | "docker:down": "docker-compose -f docker-compose.test.yml down -v" 26 | }, 27 | "prisma": { 28 | "seed": "cross-env NODE_ENV=test ts-node prisma/seed.ts" 29 | }, 30 | "dependencies": { 31 | "@nestjs/axios": "^2.0.0", 32 | "@nestjs/common": "^9.0.0", 33 | "@nestjs/config": "^2.2.0", 34 | "@nestjs/core": "^9.0.0", 35 | "@nestjs/jwt": "^10.0.1", 36 | "@nestjs/passport": "^9.0.0", 37 | "@nestjs/platform-express": "^9.0.0", 38 | "@nestjs/swagger": "^6.1.4", 39 | "@prisma/client": "4.9.0", 40 | "@sentry/minimal": "^6.19.7", 41 | "@sentry/node": "^7.30.0", 42 | "@slack/client": "^5.0.2", 43 | "argon2": "^0.30.3", 44 | "axios": "^1.3.3", 45 | "class-transformer": "^0.5.1", 46 | "class-validator": "^0.14.0", 47 | "dayjs": "^1.11.9", 48 | "dotenv": "^16.0.3", 49 | "dotenv-cli": "^7.2.1", 50 | "jest-mock-extended": "^3.0.4", 51 | "nanoid": "3.3.4", 52 | "passport": "^0.6.0", 53 | "passport-jwt": "^4.0.1", 54 | "reflect-metadata": "^0.1.13", 55 | "rimraf": "^3.0.2", 56 | "rxjs": "^7.2.0", 57 | "swagger-ui-express": "^4.6.0" 58 | }, 59 | "devDependencies": { 60 | "@nestjs/cli": "^9.0.0", 61 | "@nestjs/schematics": "^9.0.0", 62 | "@nestjs/testing": "^9.0.0", 63 | "@types/express": "^4.17.13", 64 | "@types/jest": "28.1.8", 65 | "@types/node": "^16.0.0", 66 | "@types/supertest": "^2.0.11", 67 | "@typescript-eslint/eslint-plugin": "^5.0.0", 68 | "@typescript-eslint/parser": "^5.0.0", 69 | "cross-env": "^7.0.3", 70 | "eslint": "^8.0.1", 71 | "eslint-config-prettier": "^8.3.0", 72 | "eslint-plugin-prettier": "^4.0.0", 73 | "jest": "28.1.3", 74 | "prettier": "^2.3.2", 75 | "prisma": "^4.9.0", 76 | "source-map-support": "^0.5.20", 77 | "supertest": "^6.1.3", 78 | "ts-jest": "28.0.8", 79 | "ts-loader": "^9.2.3", 80 | "ts-node": "^10.0.0", 81 | "tsconfig-paths": "4.1.0", 82 | "typescript": "^4.7.4" 83 | }, 84 | "jest": { 85 | "moduleFileExtensions": [ 86 | "js", 87 | "json", 88 | "ts" 89 | ], 90 | "rootDir": "src", 91 | "testRegex": ".*\\.spec\\.ts$", 92 | "transform": { 93 | "^.+\\.(t|j)s$": "ts-jest" 94 | }, 95 | "collectCoverageFrom": [ 96 | "**/*.(t|j)s" 97 | ], 98 | "coverageDirectory": "../coverage", 99 | "testEnvironment": "node", 100 | "moduleNameMapper": { 101 | "^src/(.*)$": "/../src/$1" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /prisma/migrations/20221022144813_connection_test/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `User` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `email` VARCHAR(191) NOT NULL, 5 | `name` VARCHAR(191) NULL, 6 | 7 | UNIQUE INDEX `User_email_key`(`email`), 8 | PRIMARY KEY (`id`) 9 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20230105060951_editwithia/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `name` on the `User` table. All the data in the column will be lost. 5 | - A unique constraint covering the columns `[uuid]` on the table `User` will be added. If there are existing duplicate values, this will fail. 6 | - Added the required column `fcmToken` to the `User` table without a default value. This is not possible if the table is not empty. 7 | - Added the required column `nickname` to the `User` table without a default value. This is not possible if the table is not empty. 8 | - Added the required column `profileImageURL` to the `User` table without a default value. This is not possible if the table is not empty. 9 | - Added the required column `refreshToken` to the `User` table without a default value. This is not possible if the table is not empty. 10 | - Added the required column `title` to the `User` table without a default value. This is not possible if the table is not empty. 11 | - The required column `uuid` was added to the `User` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. 12 | 13 | */ 14 | -- DropIndex 15 | DROP INDEX `User_email_key` ON `User`; 16 | 17 | -- AlterTable 18 | ALTER TABLE `User` DROP COLUMN `name`, 19 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 20 | ADD COLUMN `fcmToken` VARCHAR(191) NOT NULL, 21 | ADD COLUMN `isDeleted` BOOLEAN NOT NULL DEFAULT false, 22 | ADD COLUMN `nickname` VARCHAR(191) NOT NULL, 23 | ADD COLUMN `password` VARCHAR(191) NULL, 24 | ADD COLUMN `phone` VARCHAR(191) NULL, 25 | ADD COLUMN `profileImageURL` VARCHAR(191) NOT NULL, 26 | ADD COLUMN `refreshToken` VARCHAR(191) NOT NULL, 27 | ADD COLUMN `socialType` VARCHAR(191) NULL, 28 | ADD COLUMN `title` VARCHAR(191) NOT NULL, 29 | ADD COLUMN `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 30 | ADD COLUMN `uuid` VARCHAR(191) NOT NULL, 31 | MODIFY `email` VARCHAR(191) NULL; 32 | 33 | -- CreateTable 34 | CREATE TABLE `Plant` ( 35 | `id` INTEGER NOT NULL AUTO_INCREMENT, 36 | `cycle` INTEGER NOT NULL, 37 | `name` VARCHAR(191) NOT NULL, 38 | `introduction` VARCHAR(191) NOT NULL, 39 | `meaning` VARCHAR(191) NOT NULL, 40 | `explanation` VARCHAR(191) NOT NULL, 41 | `circleImageURL` VARCHAR(191) NOT NULL, 42 | `gifURL` VARCHAR(191) NOT NULL, 43 | `isDeleted` BOOLEAN NOT NULL DEFAULT false, 44 | 45 | PRIMARY KEY (`id`) 46 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 47 | 48 | -- CreateTable 49 | CREATE TABLE `PlantLevel` ( 50 | `id` INTEGER NOT NULL AUTO_INCREMENT, 51 | `plantId` INTEGER NOT NULL, 52 | `level` INTEGER NOT NULL, 53 | `levelName` VARCHAR(191) NOT NULL, 54 | `description` VARCHAR(191) NOT NULL, 55 | `imageURL` VARCHAR(191) NOT NULL, 56 | `isDeleted` BOOLEAN NOT NULL DEFAULT false, 57 | 58 | PRIMARY KEY (`id`) 59 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 60 | 61 | -- CreateTable 62 | CREATE TABLE `UserPlant` ( 63 | `id` INTEGER NOT NULL AUTO_INCREMENT, 64 | `userId` INTEGER NOT NULL, 65 | `plantId` INTEGER NOT NULL, 66 | `nickname` VARCHAR(191) NOT NULL, 67 | `instagram` VARCHAR(191) NULL, 68 | `phone` VARCHAR(191) NULL, 69 | `waterCycle` INTEGER NOT NULL, 70 | `waterCount` INTEGER NOT NULL DEFAULT 0, 71 | `isNotified` BOOLEAN NOT NULL DEFAULT true, 72 | `noticeTime` VARCHAR(191) NULL, 73 | `loveGauge` DOUBLE NOT NULL DEFAULT 0.0, 74 | `isWatered` BOOLEAN NOT NULL DEFAULT false, 75 | `isDeleted` BOOLEAN NOT NULL DEFAULT false, 76 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 77 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 78 | 79 | PRIMARY KEY (`id`) 80 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 81 | 82 | -- CreateTable 83 | CREATE TABLE `Water` ( 84 | `id` INTEGER NOT NULL AUTO_INCREMENT, 85 | `userPlantId` INTEGER NOT NULL, 86 | `review` VARCHAR(191) NULL, 87 | `isDeleted` BOOLEAN NOT NULL DEFAULT false, 88 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 89 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 90 | 91 | PRIMARY KEY (`id`) 92 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 93 | 94 | -- CreateTable 95 | CREATE TABLE `WaterKeyword` ( 96 | `id` INTEGER NOT NULL AUTO_INCREMENT, 97 | `waterId` INTEGER NOT NULL, 98 | `keyword` VARCHAR(191) NOT NULL, 99 | `isDeleted` BOOLEAN NOT NULL DEFAULT false, 100 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 101 | `updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 102 | 103 | PRIMARY KEY (`id`) 104 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 105 | 106 | -- CreateTable 107 | CREATE TABLE `PlantStatus` ( 108 | `id` INTEGER NOT NULL AUTO_INCREMENT, 109 | `day` INTEGER NOT NULL, 110 | `status` VARCHAR(191) NOT NULL, 111 | `message` VARCHAR(191) NOT NULL, 112 | `gauge` DOUBLE NOT NULL, 113 | `isDeleted` BOOLEAN NOT NULL DEFAULT false, 114 | 115 | PRIMARY KEY (`id`) 116 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 117 | 118 | -- CreateIndex 119 | CREATE UNIQUE INDEX `User_uuid_key` ON `User`(`uuid`); 120 | 121 | -- AddForeignKey 122 | ALTER TABLE `PlantLevel` ADD CONSTRAINT `PlantLevel_plantId_fkey` FOREIGN KEY (`plantId`) REFERENCES `Plant`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 123 | 124 | -- AddForeignKey 125 | ALTER TABLE `UserPlant` ADD CONSTRAINT `UserPlant_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE `UserPlant` ADD CONSTRAINT `UserPlant_plantId_fkey` FOREIGN KEY (`plantId`) REFERENCES `Plant`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 129 | 130 | -- AddForeignKey 131 | ALTER TABLE `Water` ADD CONSTRAINT `Water_userPlantId_fkey` FOREIGN KEY (`userPlantId`) REFERENCES `UserPlant`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 132 | 133 | -- AddForeignKey 134 | ALTER TABLE `WaterKeyword` ADD CONSTRAINT `WaterKeyword_waterId_fkey` FOREIGN KEY (`waterId`) REFERENCES `Water`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 135 | -------------------------------------------------------------------------------- /prisma/migrations/20230105075140_edit_with_ia/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `isWatered` on the `UserPlant` table. All the data in the column will be lost. 5 | - You are about to drop the column `noticeTime` on the `UserPlant` table. All the data in the column will be lost. 6 | - You are about to drop the column `plantId` on the `UserPlant` table. All the data in the column will be lost. 7 | - You are about to drop the `PlantStatus` table. If the table is not empty, all the data it contains will be lost. 8 | - Added the required column `plantLevelId` to the `UserPlant` table without a default value. This is not possible if the table is not empty. 9 | 10 | */ 11 | -- DropForeignKey 12 | ALTER TABLE `UserPlant` DROP FOREIGN KEY `UserPlant_plantId_fkey`; 13 | 14 | -- AlterTable 15 | ALTER TABLE `UserPlant` DROP COLUMN `isWatered`, 16 | DROP COLUMN `noticeTime`, 17 | DROP COLUMN `plantId`, 18 | ADD COLUMN `plantLevelId` INTEGER NOT NULL, 19 | ADD COLUMN `waterTime` VARCHAR(191) NULL; 20 | 21 | -- DropTable 22 | DROP TABLE `PlantStatus`; 23 | 24 | -- AddForeignKey 25 | ALTER TABLE `UserPlant` ADD CONSTRAINT `UserPlant_plantLevelId_fkey` FOREIGN KEY (`plantLevelId`) REFERENCES `PlantLevel`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 26 | -------------------------------------------------------------------------------- /prisma/migrations/20230109145733_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `User` MODIFY `profileImageURL` VARCHAR(191) NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20230130113036_update_user_social_login_id/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `User` ADD COLUMN `appleId` VARCHAR(191) NULL, 3 | ADD COLUMN `appleRefreshToken` VARCHAR(191) NULL, 4 | ADD COLUMN `kakaoId` BIGINT NULL; 5 | -------------------------------------------------------------------------------- /prisma/migrations/20230722065946_add_relation_to_userplant_and_plant/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `plantLevelId` on the `UserPlant` table. All the data in the column will be lost. 5 | - Added the required column `plantId` to the `UserPlant` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE `UserPlant` DROP FOREIGN KEY `UserPlant_plantLevelId_fkey`; 10 | 11 | -- AlterTable 12 | ALTER TABLE `UserPlant` DROP COLUMN `plantLevelId`, 13 | ADD COLUMN `plantId` INTEGER NOT NULL; 14 | 15 | -- AddForeignKey 16 | ALTER TABLE `UserPlant` ADD CONSTRAINT `UserPlant_plantId_fkey` FOREIGN KEY (`plantId`) REFERENCES `Plant`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230722081307_rename_alias_water/migration.sql: -------------------------------------------------------------------------------- 1 | -- This is an empty migration. -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "mysql" 6 | url = env("DATABASE_URL") 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | previewFeatures = ["extendedWhereUnique"] 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | uuid String @unique @default(uuid()) 17 | 18 | email String? 19 | password String? 20 | phone String? 21 | nickname String 22 | profileImageURL String? 23 | socialType String? 24 | refreshToken String 25 | fcmToken String 26 | title String 27 | kakaoId BigInt? 28 | appleId String? 29 | appleRefreshToken String? 30 | 31 | isDeleted Boolean @default(false) 32 | createdAt DateTime @default(now()) 33 | updatedAt DateTime @default(now()) 34 | 35 | UserPlant UserPlant[] 36 | } 37 | 38 | model Plant { 39 | id Int @id @default(autoincrement()) 40 | 41 | cycle Int 42 | name String 43 | introduction String 44 | meaning String 45 | explanation String 46 | circleImageURL String 47 | gifURL String 48 | 49 | isDeleted Boolean @default(false) 50 | 51 | PlantLevel PlantLevel[] 52 | UserPlant UserPlant[] 53 | } 54 | 55 | model PlantLevel { 56 | id Int @id @default(autoincrement()) 57 | 58 | plant Plant @relation(fields: [plantId], references: [id]) 59 | plantId Int 60 | 61 | level Int 62 | levelName String 63 | description String 64 | imageURL String 65 | 66 | isDeleted Boolean @default(false) 67 | } 68 | 69 | model UserPlant { 70 | id Int @id @default(autoincrement()) 71 | 72 | user User @relation(fields: [userId], references: [id]) 73 | userId Int 74 | plant Plant @relation(fields: [plantId], references: [id]) 75 | plantId Int 76 | 77 | nickname String 78 | instagram String? 79 | phone String? 80 | waterCycle Int 81 | waterCount Int @default(0) 82 | isNotified Boolean @default(true) 83 | waterTime String? 84 | loveGauge Float @default(0.0) 85 | 86 | isDeleted Boolean @default(false) 87 | createdAt DateTime @default(now()) 88 | updatedAt DateTime @default(now()) 89 | 90 | Water Water[] 91 | } 92 | 93 | model Water { 94 | id Int @id @default(autoincrement()) 95 | 96 | userPlant UserPlant @relation(fields: [userPlantId], references: [id]) 97 | userPlantId Int 98 | 99 | review String? 100 | 101 | isDeleted Boolean @default(false) 102 | wateringDate DateTime @default(now()) @map("createdAt") 103 | updatedAt DateTime @default(now()) 104 | 105 | WaterKeyword WaterKeyword[] 106 | } 107 | 108 | model WaterKeyword { 109 | id Int @id @default(autoincrement()) 110 | 111 | water Water @relation(fields: [waterId], references: [id]) 112 | waterId Int 113 | 114 | keyword String 115 | 116 | isDeleted Boolean @default(false) 117 | createdAt DateTime @default(now()) 118 | updatedAt DateTime @default(now()) 119 | } 120 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import * as dayjs from 'dayjs'; 3 | import * as utc from 'dayjs/plugin/utc'; 4 | import * as timezone from 'dayjs/plugin/timezone'; 5 | 6 | dayjs.extend(utc); 7 | dayjs.extend(timezone); 8 | 9 | const prisma = new PrismaClient(); 10 | 11 | async function main() { 12 | const user = await prisma.user.create({ 13 | data: { 14 | id: 1, 15 | email: 'ddd', 16 | createdAt: dayjs('2023-07-22 08:15:37.225Z').toDate(), 17 | fcmToken: 'dd', 18 | nickname: 'ddd', 19 | password: 'dd', 20 | phone: 'ddd', 21 | profileImageURL: 'dd', 22 | refreshToken: 'dd', 23 | socialType: 'kakao', 24 | title: 'dd', 25 | updatedAt: dayjs('2023-07-22 08:15:37.225Z').toDate(), 26 | uuid: 'dd', 27 | appleId: null, 28 | appleRefreshToken: null, 29 | kakaoId: 1, 30 | }, 31 | }); 32 | 33 | const plants = await prisma.plant.createMany({ 34 | data: [ 35 | { 36 | id: 1, 37 | name: '오렌지 자스민', 38 | cycle: 2, 39 | introduction: '붙임성이 좋은\n앙증맞은 오렌지 자스민', 40 | meaning: '당신을 향해', 41 | explanation: 42 | '1~2일에 한 번 물을 주는 것을 추천해요\n물을 좋아하는 자스민이 곧 귀여운 열매를 선물할거에요!', 43 | circleImageURL: 44 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/mindlere-circle.png', 45 | gifURL: 'aaa', 46 | }, 47 | { 48 | id: 2, 49 | name: '로즈마리', 50 | cycle: 4, 51 | introduction: '당신의 하루를 치유하는\n향기로운 로즈마리', 52 | meaning: '기억해 주세요', 53 | explanation: 54 | '3~4일에 한 번 물을 주는 것을 추천해요\n자주 연락하고 많은 시간을 함께하며 추억을 쌓아가요!', 55 | circleImageURL: 56 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/rosemari-circle.png', 57 | gifURL: 'aaa', 58 | }, 59 | { 60 | id: 3, 61 | name: '아메리칸 블루', 62 | cycle: 6, 63 | introduction: '매일매일 꽃이 피는\n푸른 빛의 아메리칸 블루', 64 | meaning: '두 사람의 인연', 65 | explanation: 66 | '일주일에 한 번 물을 주는 것을 추천해요\n종종 안부를 물으면서 오손도손 이야기를 나누어요!', 67 | circleImageURL: 68 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/americanblue-circle.png', 69 | gifURL: 'aaa', 70 | }, 71 | { 72 | id: 4, 73 | name: '민들레', 74 | cycle: 13, 75 | introduction: '감사하는 마음을 가진\n따뜻한 민들레', 76 | meaning: '인연에서의 행복', 77 | explanation: 78 | '보름에 한 번 물을 주는 것을 추천해요\n당신의 연락이 홀씨가 되어 날아가 행복으로 피어날거에요!', 79 | circleImageURL: 80 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/mindlere-circle.png', 81 | gifURL: 'ㅁㅁ', 82 | }, 83 | { 84 | id: 5, 85 | name: '스투키', 86 | cycle: 29, 87 | introduction: '언제나 당신을 지켜주는\n든든한 스투키', 88 | meaning: '너그러움', 89 | explanation: 90 | '한달에 한 번 물을 주는 것을 추천해요\n가끔씩 연락하더라도 오래 만날 수 있길 바라요!', 91 | circleImageURL: 92 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/stuki-circle.png', 93 | gifURL: 'ㅁㅁ', 94 | }, 95 | { 96 | id: 6, 97 | name: '단모환', 98 | cycle: 90, 99 | introduction: '당신의 밤을 지켜주는\n씩씩한 단모환', 100 | meaning: '사랑과 열정', 101 | explanation: 102 | '세 달에 한 번 물을 주는 것을 추천해요\n자주 보지 못해도 분명 당신의 연락을 기다릴거에요!', 103 | circleImageURL: 104 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/danmohwan-circle.png', 105 | gifURL: 'ㅁㅁㅁ', 106 | }, 107 | ], 108 | skipDuplicates: true, 109 | }); 110 | 111 | const plantLevels = await prisma.plantLevel.createMany({ 112 | data: [ 113 | { 114 | id: 1, 115 | plantId: 1, 116 | level: 0, 117 | levelName: '어린 나무', 118 | description: '무럭무럭 자랄 준비를 하고 있어요!', 119 | imageURL: 120 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-1.png', 121 | }, 122 | { 123 | id: 2, 124 | plantId: 1, 125 | level: 1, 126 | levelName: '개화', 127 | description: '하얀 꽃이 활짝 피어났어요!', 128 | imageURL: 129 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-2.png', 130 | }, 131 | { 132 | id: 3, 133 | plantId: 1, 134 | level: 2, 135 | levelName: '열매', 136 | description: '꽃이 머물다간 자리에 앙증맞은 열매가 열렸네요!', 137 | imageURL: 138 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-complete.png', 139 | }, 140 | { 141 | id: 4, 142 | plantId: 2, 143 | level: 0, 144 | levelName: '새싹', 145 | description: '새싹이 쏘옥 얼굴을 내밀었어요!', 146 | imageURL: 147 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/rosemari-1.png', 148 | }, 149 | { 150 | id: 5, 151 | plantId: 2, 152 | level: 1, 153 | levelName: '꽃망울', 154 | description: '꽃망울이 방울방울 맺혔어요!', 155 | imageURL: 156 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/rosemari-2.png', 157 | }, 158 | { 159 | id: 6, 160 | plantId: 2, 161 | level: 2, 162 | levelName: '개화', 163 | description: '예쁜 꽃이 활짝 피어났어요!', 164 | imageURL: 165 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/rosemari-complete.png', 166 | }, 167 | { 168 | id: 7, 169 | plantId: 3, 170 | level: 0, 171 | levelName: '새싹', 172 | description: '새싹이 쏘옥 얼굴을 내밀었어요!', 173 | imageURL: 174 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/americanblue-1.png', 175 | }, 176 | { 177 | id: 8, 178 | plantId: 3, 179 | level: 1, 180 | levelName: '꽃망울', 181 | description: '꽃망울이 방울방울 맺혔어요!', 182 | imageURL: 183 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/americanblue-2.png', 184 | }, 185 | { 186 | id: 9, 187 | plantId: 3, 188 | level: 2, 189 | levelName: '개화', 190 | description: '예쁜 꽃이 활짝 피어났어요!', 191 | imageURL: 192 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/americanblue-complete.png', 193 | }, 194 | { 195 | id: 10, 196 | plantId: 4, 197 | level: 0, 198 | levelName: '새싹', 199 | description: '새싹이 쏘옥 얼굴을 내밀었어요!', 200 | imageURL: 201 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-1.png', 202 | }, 203 | ], 204 | skipDuplicates: true, 205 | }); 206 | 207 | const userPlant = await prisma.userPlant.create({ 208 | data: { 209 | id: 1, 210 | userId: 1, 211 | nickname: 'test', 212 | instagram: null, 213 | phone: null, 214 | waterCycle: 14, 215 | waterCount: 0, 216 | isNotified: true, 217 | loveGauge: 0, 218 | createdAt: dayjs('2023-07-22 08:16:52.538Z').toDate(), 219 | updatedAt: dayjs('2023-07-22 08:16:52.538Z').toDate(), 220 | waterTime: null, 221 | plantId: 4, 222 | }, 223 | }); 224 | 225 | const water = await prisma.water.createMany({ 226 | data: [ 227 | { 228 | id: 1, 229 | userPlantId: 1, 230 | review: '리뷰1', 231 | wateringDate: dayjs('2023-07-22 08:55:31.799Z').toDate(), 232 | updatedAt: dayjs('2023-07-22 08:55:31.799Z').toDate(), 233 | }, 234 | { 235 | id: 3, 236 | userPlantId: 1, 237 | review: '리뷰2', 238 | wateringDate: dayjs('2023-07-22 18:16:53Z').toDate(), 239 | updatedAt: dayjs('2023-07-22 09:16:54.635Z').toDate(), 240 | }, 241 | ], 242 | skipDuplicates: true, 243 | }); 244 | 245 | const waterKeword = await prisma.waterKeyword.createMany({ 246 | data: [ 247 | { 248 | id: 1, 249 | waterId: 4, 250 | keyword: 'keyword1', 251 | createdAt: dayjs('2023-07-22 12:20:11.257Z').toDate(), 252 | updatedAt: dayjs('2023-07-22 12:20:11.257Z').toDate(), 253 | }, 254 | { 255 | id: 2, 256 | waterId: 4, 257 | keyword: 'keyword2', 258 | createdAt: dayjs('2023-07-22 12:20:11.257Z').toDate(), 259 | updatedAt: dayjs('2023-07-22 12:20:11.257Z').toDate(), 260 | }, 261 | ], 262 | skipDuplicates: true, 263 | }); 264 | } 265 | 266 | main() 267 | .then(async () => { 268 | await prisma.$disconnect(); 269 | }) 270 | .catch(async (e) => { 271 | console.error(e); 272 | await prisma.$disconnect(); 273 | process.exit(1); 274 | }); 275 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { HttpModule } from '@nestjs/axios'; 6 | import configuration from './config/configuration'; 7 | import { GlobalExceptionFilter } from './exceptions'; 8 | import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; 9 | import { WebhookInterceptor } from './interceptors/webhook.interceptor'; 10 | import { AuthModule } from './auth/auth.module'; 11 | import { UsersModule } from './users/users.module'; 12 | import { PlantsModule } from './plants/plants.module'; 13 | import { PrismaService } from 'src/prisma.service'; 14 | 15 | @Module({ 16 | imports: [ 17 | ConfigModule.forRoot({ 18 | isGlobal: true, 19 | load: [configuration], 20 | envFilePath: process.env.NODE_ENV === 'test' ? '.env.test' : '.env', 21 | }), 22 | AuthModule, 23 | HttpModule, 24 | UsersModule, 25 | PlantsModule, 26 | ], 27 | controllers: [AppController], 28 | providers: [ 29 | AppService, 30 | PrismaService, 31 | { 32 | provide: APP_FILTER, 33 | useClass: GlobalExceptionFilter, 34 | }, 35 | { 36 | provide: APP_INTERCEPTOR, 37 | useClass: WebhookInterceptor, 38 | }, 39 | ], 40 | }) 41 | export class AppModule {} 42 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | 4 | describe('AuthController', () => { 5 | let controller: AuthController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpStatus, Post } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiCreatedResponse, 5 | ApiInternalServerErrorResponse, 6 | ApiNotFoundResponse, 7 | ApiOperation, 8 | ApiTags, 9 | ApiUnauthorizedResponse, 10 | } from '@nestjs/swagger'; 11 | 12 | import { AuthService } from './auth.service'; 13 | import { 14 | ResponseSigninData, 15 | ResponseSigninDto, 16 | } from './dto/response-signin.dto'; 17 | import { CreateSigninDto } from './dto/create-signin.dto'; 18 | import { Validation } from 'src/utils/validation'; 19 | import { wrapSuccess } from 'src/utils/success'; 20 | import { RESPONSE_MESSAGE } from 'src/common/objects'; 21 | import { ERROR_DESCRIPTION, SIGNIN_DESCRIPTION } from 'src/constants/swagger'; 22 | 23 | @Controller('auth') 24 | @ApiTags('Auth') 25 | @ApiInternalServerErrorResponse({ 26 | description: ERROR_DESCRIPTION.INTERNAL_SERVER_ERROR, 27 | }) 28 | export class AuthController { 29 | constructor( 30 | private authService: AuthService, 31 | private validation: Validation, 32 | ) {} 33 | 34 | @Post('signin/social') 35 | @ApiOperation({ 36 | summary: SIGNIN_DESCRIPTION.API_OPERATION.SUMMARY, 37 | description: SIGNIN_DESCRIPTION.API_OPERATION.DESCRIPTION, 38 | }) 39 | @ApiCreatedResponse({ type: ResponseSigninDto }) 40 | @ApiUnauthorizedResponse({ 41 | description: SIGNIN_DESCRIPTION.ERROR_DESCRIPTION.UNAUTHORIZED, 42 | }) 43 | @ApiBadRequestResponse({ 44 | description: SIGNIN_DESCRIPTION.ERROR_DESCRIPTION.BAD_REQUEST, 45 | }) 46 | @ApiNotFoundResponse({ 47 | description: SIGNIN_DESCRIPTION.ERROR_DESCRIPTION.NOT_FOUND, 48 | }) 49 | async signin( 50 | @Body() createSigninDto: CreateSigninDto, 51 | ): Promise { 52 | await this.validation.validationSignin(createSigninDto); 53 | 54 | const { socialType } = createSigninDto; 55 | 56 | let data: ResponseSigninData; 57 | 58 | switch (socialType) { 59 | case 'kakao': 60 | data = await this.authService.createKakaoUser(createSigninDto); 61 | break; 62 | case 'apple': 63 | } 64 | 65 | return wrapSuccess( 66 | HttpStatus.CREATED, 67 | RESPONSE_MESSAGE.SIGNIN_USER_SUCCESS, 68 | data, 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { HttpModule } from '@nestjs/axios'; 4 | import { JwtModule } from '@nestjs/jwt'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { AuthService } from './auth.service'; 7 | import { AccessTokenStrategy, RefreshTokenStrategy } from './strategies'; 8 | import { PrismaService } from 'src/prisma.service'; 9 | import { AuthController } from './auth.controller'; 10 | import { UsersModule } from 'src/users/users.module'; 11 | import { Validation } from 'src/utils/validation'; 12 | 13 | @Module({ 14 | imports: [ 15 | JwtModule.register({}), 16 | PassportModule.register({ 17 | defaultStrategy: 'jwt', 18 | session: false, 19 | }), 20 | ConfigModule, 21 | HttpModule, 22 | UsersModule, 23 | ], 24 | providers: [ 25 | AuthService, 26 | AccessTokenStrategy, 27 | RefreshTokenStrategy, 28 | PrismaService, 29 | ConfigService, 30 | Validation, 31 | ], 32 | controllers: [AuthController], 33 | }) 34 | export class AuthModule {} 35 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { HttpService } from '@nestjs/axios'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { JwtService } from '@nestjs/jwt'; 5 | import { firstValueFrom } from 'rxjs'; 6 | import { User } from '@prisma/client'; 7 | import * as argon2 from 'argon2'; 8 | import { nanoid } from 'nanoid'; 9 | 10 | import { UsersService } from 'src/users/users.service'; 11 | import { ResponseSigninData } from './dto/response-signin.dto'; 12 | import { CreateSigninDto } from './dto/create-signin.dto'; 13 | import { ResponseTokenData } from './dto/response-token.dto'; 14 | import { JwtPayload, JwtToken } from 'src/common/interfaces'; 15 | import { forbidden, notFound } from 'src/utils/error'; 16 | 17 | @Injectable() 18 | export class AuthService { 19 | constructor( 20 | private readonly jwtService: JwtService, 21 | private readonly config: ConfigService, 22 | private readonly http: HttpService, 23 | private readonly usersService: UsersService, 24 | ) {} 25 | 26 | private readonly logger = new Logger(AuthService.name); 27 | 28 | async getJwtToken(user: User): Promise { 29 | const uniqueId: string = 30 | user.socialType === 'kakao' ? user.kakaoId.toString() : user.appleId; 31 | 32 | const payload: JwtPayload = { 33 | id: user.id, 34 | socialType: user.socialType, 35 | uniqueId, 36 | }; 37 | 38 | const [accessToken, refreshToken] = await Promise.all([ 39 | this.jwtService.signAsync(payload, { 40 | secret: this.config.get('accessTokenSecret'), 41 | expiresIn: '10d', 42 | }), 43 | this.jwtService.signAsync(payload, { 44 | secret: this.config.get('refreshTokenSecret'), 45 | expiresIn: '30d', 46 | }), 47 | ]); 48 | 49 | return { accessToken, refreshToken }; 50 | } 51 | 52 | async getHashedRefreshToken(refreshToken: string): Promise { 53 | return await argon2.hash(refreshToken); 54 | } 55 | 56 | async updateToken( 57 | id: number, 58 | hashedRefreshToken: string, 59 | ): Promise { 60 | const user = await this.usersService.getUserById(id); 61 | if (!user || !user.refreshToken) { 62 | throw notFound(); 63 | } 64 | 65 | const isRefreshTokenMatch: boolean = await argon2.verify( 66 | user.refreshToken, 67 | hashedRefreshToken, 68 | ); 69 | if (!isRefreshTokenMatch) { 70 | throw forbidden(); 71 | } 72 | 73 | const newTokens = await this.getJwtToken(user); 74 | const newHashedRefreshToken = await this.getHashedRefreshToken( 75 | newTokens.refreshToken, 76 | ); 77 | 78 | await this.usersService.updateRefreshTokenByUserId( 79 | user.id, 80 | newHashedRefreshToken, 81 | ); 82 | 83 | return newTokens; 84 | } 85 | 86 | async createKakaoUser( 87 | createSigninDto: CreateSigninDto, 88 | ): Promise { 89 | const { kakaoAccessToken, socialType, nickname, fcmToken } = 90 | createSigninDto; 91 | 92 | const requestHeader = { 93 | Authorization: `Bearer ${kakaoAccessToken}`, 94 | 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', 95 | }; 96 | 97 | const kakaoRequestUserUrl = `https://kapi.kakao.com/v2/user/me`; 98 | 99 | const userResponse = await firstValueFrom( 100 | this.http.get(kakaoRequestUserUrl, { 101 | headers: requestHeader, 102 | }), 103 | ); 104 | if (!userResponse) { 105 | throw notFound(); 106 | } 107 | 108 | this.logger.debug('get kakao user success', userResponse.data); 109 | 110 | const { id } = userResponse.data; 111 | const { email } = userResponse.data?.kakao_account; 112 | 113 | let user = await this.usersService.getUserByKaKaoId(id); 114 | 115 | if (!user) { 116 | const newUser = { 117 | uuid: nanoid(4), 118 | kakaoId: id, 119 | email: email ?? undefined, 120 | refreshToken: '', 121 | title: '', 122 | fcmToken, 123 | nickname, 124 | socialType, 125 | }; 126 | 127 | user = await this.usersService.createUser(newUser); 128 | } 129 | 130 | const tokens: JwtToken = await this.getJwtToken(user); 131 | const hashedRefreshToken: string = await this.getHashedRefreshToken( 132 | tokens.refreshToken, 133 | ); 134 | 135 | await this.usersService.updateRefreshTokenByUserId( 136 | user.id, 137 | hashedRefreshToken, 138 | ); 139 | 140 | return { 141 | ...tokens, 142 | id: user.id, 143 | }; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/auth/dto/create-signin.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 3 | 4 | import { SIGNIN_DESCRIPTION } from 'src/constants/swagger'; 5 | 6 | const DTO_CREATE_DESCRIPTION = SIGNIN_DESCRIPTION.DTO_DESCRIPTION.CREATE; 7 | 8 | export class CreateSigninDto { 9 | @ApiProperty({ 10 | required: true, 11 | description: DTO_CREATE_DESCRIPTION.SOCIAL_TYPE, 12 | }) 13 | @IsString() 14 | @IsNotEmpty() 15 | socialType: 'kakao' | 'apple'; 16 | 17 | @ApiProperty({ required: true, description: DTO_CREATE_DESCRIPTION.NICKNAME }) 18 | @IsString() 19 | @IsNotEmpty() 20 | nickname: string; 21 | 22 | @ApiProperty({ 23 | required: true, 24 | description: DTO_CREATE_DESCRIPTION.FCM_TOKEN, 25 | }) 26 | @IsString() 27 | @IsNotEmpty() 28 | fcmToken: string; 29 | 30 | @ApiPropertyOptional({ 31 | description: DTO_CREATE_DESCRIPTION.KAKAO_ACCESS_TOKEN, 32 | }) 33 | @IsOptional() 34 | @IsString() 35 | kakaoAccessToken?: string; 36 | 37 | @ApiPropertyOptional({ description: DTO_CREATE_DESCRIPTION.ID_TOKEN }) 38 | @IsOptional() 39 | @IsString() 40 | idToken?: string; 41 | 42 | @ApiPropertyOptional({ description: DTO_CREATE_DESCRIPTION.CODE }) 43 | @IsOptional() 44 | @IsString() 45 | code?: string; 46 | } 47 | -------------------------------------------------------------------------------- /src/auth/dto/response-signin.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 3 | import { ResponseTokenData } from './response-token.dto'; 4 | 5 | import { SIGNIN_DESCRIPTION } from 'src/constants/swagger'; 6 | 7 | const DTO_RESPONSE_DESCRIPTION = SIGNIN_DESCRIPTION.DTO_DESCRIPTION.RESPONSE; 8 | 9 | export class ResponseSigninData extends ResponseTokenData { 10 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.ID }) 11 | id: number; 12 | } 13 | 14 | export class ResponseSigninDto extends ResponseSuccessDto { 15 | @ApiProperty() 16 | data: ResponseSigninData; 17 | } 18 | -------------------------------------------------------------------------------- /src/auth/dto/response-token.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 4 | import { SIGNIN_DESCRIPTION } from 'src/constants/swagger'; 5 | 6 | const DTO_RESPONSE_DESCRIPTION = SIGNIN_DESCRIPTION.DTO_DESCRIPTION.RESPONSE; 7 | 8 | export class ResponseTokenData { 9 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.TOKEN.ACCESS_TOKEN }) 10 | accessToken: string; 11 | 12 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.TOKEN.REFRESH_TOKEN }) 13 | refreshToken: string; 14 | } 15 | 16 | export class ResponseTokenDto extends ResponseSuccessDto { 17 | @ApiProperty() 18 | data: ResponseTokenData; 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/guards/access-token.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class AccessTokenGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/index.ts: -------------------------------------------------------------------------------- 1 | import { AccessTokenGuard } from './access-token.guard'; 2 | import { RefreshTokenGuard } from './refresh-token.guard'; 3 | 4 | export { AccessTokenGuard, RefreshTokenGuard }; 5 | -------------------------------------------------------------------------------- /src/auth/guards/refresh-token.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {} 6 | -------------------------------------------------------------------------------- /src/auth/strategies/access-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { JwtPayload } from 'src/common/interfaces'; 6 | import { RESPONSE_MESSAGE } from 'src/common/objects'; 7 | import CustomException from 'src/exceptions/custom.exception'; 8 | import { PrismaService } from 'src/prisma.service'; 9 | 10 | @Injectable() 11 | export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { 12 | constructor(config: ConfigService, private prisma: PrismaService) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 15 | ignoreExpiration: false, 16 | secretOrKey: config.get('accessTokenSecret'), 17 | }); 18 | } 19 | 20 | async validate(payload: JwtPayload): Promise { 21 | const user = await this.prisma.user.findUnique({ 22 | where: { 23 | id: payload.id, 24 | isDeleted: false, 25 | }, 26 | }); 27 | 28 | if (!user) { 29 | throw new CustomException( 30 | HttpStatus.NOT_FOUND, 31 | RESPONSE_MESSAGE.NOT_FOUND, 32 | ); 33 | } 34 | 35 | return { 36 | id: payload.id, 37 | uniqueId: payload.uniqueId, 38 | socialType: payload.socialType, 39 | }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/auth/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import { AccessTokenStrategy } from './access-token.strategy'; 2 | import { RefreshTokenStrategy } from './refresh-token.strategy'; 3 | 4 | export { AccessTokenStrategy, RefreshTokenStrategy }; 5 | -------------------------------------------------------------------------------- /src/auth/strategies/refresh-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { PassportStrategy } from '@nestjs/passport'; 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | import { JwtPayload } from 'src/common/interfaces/jwt-payload.interface'; 7 | 8 | @Injectable() 9 | export class RefreshTokenStrategy extends PassportStrategy( 10 | Strategy, 11 | 'jwt-refresh', 12 | ) { 13 | constructor(config: ConfigService) { 14 | super({ 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ignoreExpiration: false, 17 | secretOrKey: config.get('refreshTokenSecret'), 18 | passReqToCallback: true, 19 | }); 20 | } 21 | 22 | async validate(req: Request, payload: JwtPayload) { 23 | const refreshToken = req.get('Authorization').replace('Bearer', '').trim(); 24 | return { ...payload, refreshToken }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/common/dto/common-params.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class CommonParamsDto { 5 | @IsNumber() 6 | @Type(() => Number) 7 | readonly id: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/dto/response-success.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ResponseSuccessDto { 4 | @ApiProperty() 5 | statusCode: number; 6 | 7 | @ApiProperty() 8 | success: boolean; 9 | 10 | @ApiProperty() 11 | message: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import { JwtPayload } from './jwt-payload.interface'; 2 | import { JwtToken } from './jwt-token.interface'; 3 | 4 | export { JwtPayload, JwtToken }; 5 | -------------------------------------------------------------------------------- /src/common/interfaces/jwt-payload.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | id: number; 3 | uniqueId: string; 4 | socialType: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/interfaces/jwt-token.interface.ts: -------------------------------------------------------------------------------- 1 | export interface JwtToken { 2 | accessToken: string; 3 | refreshToken: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/common/objects/index.ts: -------------------------------------------------------------------------------- 1 | import { RESPONSE_MESSAGE } from './response-message.object'; 2 | 3 | export { RESPONSE_MESSAGE }; 4 | -------------------------------------------------------------------------------- /src/common/objects/response-message.object.ts: -------------------------------------------------------------------------------- 1 | export const RESPONSE_MESSAGE: { 2 | [key: string]: string; 3 | } = { 4 | // common 5 | NULL_VALUE: '필요한 값이 없습니다.', 6 | FORBIDDEN: 'Access Denied', 7 | DUPLICATED: 'Duplicated', 8 | NOT_FOUND: 'Not Found', 9 | BAD_REQUEST: 'Bad Request', 10 | UNAUTHORIZED: 'Unauthorized', 11 | INTERNAL_SERVER_ERROR: 'Internal Server Error', 12 | NULL_VALUE_TOKEN: '토큰이 없습니다.', 13 | INVALID_TOKEN: '만료된 토큰입니다.', 14 | INVALID_PASSWORD: '비밀번호 오류', 15 | 16 | // auth 17 | SIGNIN_USER_SUCCESS: '로그인/회원가입 성공', 18 | ISSUED_TOKEN_SUCCESS: '토큰 발급 성공', 19 | REISSUED_TOKEN_SUCCESS: '토큰 재발급 성공', 20 | 21 | // plants 22 | READ_PLANT_DETAIL_SUCCESS: '식물 상세 조회 성공', 23 | UPDATE_PLANT_DETAIL_SUCCESS: '식물 카드 업데이트 성공', 24 | READ_PLANT_INFORMATION_SUCCESS: '식물 단계 조회 성공', 25 | READ_PLANT_WATER_LOG_SUCCESS: '식물 물주기 기록 조회 성공', 26 | READ_PLANTS_SUCCESS: '메인 식물 리스트 조회 성공', 27 | }; 28 | -------------------------------------------------------------------------------- /src/config/configuration.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | export default () => ({ 4 | port: parseInt(process.env.PORT, 10) || 3000, 5 | sentryWebhookUrl: process.env.SLACK_WEBHOOK_URL, 6 | sentryDsn: process.env.SENTRY_DSN, 7 | accessTokenSecret: process.env.ACCESS_TOKEN_SECRET, 8 | refreshTokenSecret: process.env.REFRESH_TOKEN_SECRET, 9 | }); 10 | -------------------------------------------------------------------------------- /src/config/swagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | 4 | export const setUpSwagger = (app: INestApplication): void => { 5 | const options = new DocumentBuilder() 6 | .setTitle('Cherish API Docs') 7 | .setDescription('Cherish API 문서입니다.') 8 | .setVersion('1.0.0') 9 | .addBearerAuth( 10 | { 11 | type: 'http', 12 | scheme: 'bearer', 13 | name: 'JWT', 14 | in: 'header', 15 | }, 16 | 'accessToken', 17 | ) 18 | .addBearerAuth( 19 | { 20 | type: 'http', 21 | scheme: 'bearer', 22 | name: 'JWT', 23 | in: 'header', 24 | }, 25 | 'refreshToken', 26 | ) 27 | .build(); 28 | 29 | const document = SwaggerModule.createDocument(app, options); 30 | SwaggerModule.setup('api-docs', app, document); 31 | }; 32 | -------------------------------------------------------------------------------- /src/constants/swagger/auth.ts: -------------------------------------------------------------------------------- 1 | export const SIGNIN_DESCRIPTION = { 2 | API_OPERATION: { 3 | SUMMARY: '소셜 로그인 API', 4 | DESCRIPTION: 5 | '카카오/애플 로그인을 진행하고, access/refresh token을 발급합니다.', 6 | }, 7 | DTO_DESCRIPTION: { 8 | CREATE: { 9 | SOCIAL_TYPE: 'kakao/apple', 10 | NICKNAME: 'nickname', 11 | FCM_TOKEN: 'push 알람 용 fcm token', 12 | KAKAO_ACCESS_TOKEN: 'kakao access toekn', 13 | ID_TOKEN: 'apple id token', 14 | CODE: 'apple authorization code', 15 | }, 16 | RESPONSE: { 17 | ID: 'id', 18 | TOKEN: { 19 | ACCESS_TOKEN: 'cherish access token', 20 | REFRESH_TOKEN: 'cherish refresh token', 21 | }, 22 | }, 23 | }, 24 | ERROR_DESCRIPTION: { 25 | BAD_REQUEST: 26 | 'Bad Request - 소셜 로그인 토큰을 보내지 않거나 kakao, apple 둘 다 보낸 경우', 27 | UNAUTHORIZED: 'Unauthorized - 소셜 로그인 토큰이 없거나 유효하지 않은 경우', 28 | NOT_FOUND: 'Not Found - 소셜 로그인 토큰에 해당하는 유저 정보가 없는 경우', 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/constants/swagger/index.ts: -------------------------------------------------------------------------------- 1 | import { SIGNIN_DESCRIPTION } from './auth'; 2 | import { 3 | READ_PLANT_DETAIL, 4 | UPDATE_PLANT_DETAIL, 5 | READ_PLANT_INFORMATION, 6 | READ_PLANT_WATER_LOG, 7 | READ_PLANTS, 8 | } from './plants'; 9 | 10 | export const ERROR_DESCRIPTION = { 11 | INTERNAL_SERVER_ERROR: 'Internal Server Error', 12 | }; 13 | 14 | export { 15 | SIGNIN_DESCRIPTION, 16 | READ_PLANT_DETAIL, 17 | UPDATE_PLANT_DETAIL, 18 | READ_PLANT_INFORMATION, 19 | READ_PLANT_WATER_LOG, 20 | READ_PLANTS, 21 | }; 22 | -------------------------------------------------------------------------------- /src/constants/swagger/plants.ts: -------------------------------------------------------------------------------- 1 | export const READ_PLANT_DETAIL = { 2 | API_OPERATION: { 3 | summary: '식물 상세 조회 API', 4 | description: '식물 카드 상세 정보를 조회합니다.', 5 | }, 6 | API_PARAM: { 7 | type: Number, 8 | name: 'id', 9 | required: true, 10 | description: 'userPlant id', 11 | }, 12 | DTO_DESCRIPTION: { 13 | RESPONSE: { 14 | ID: 'userPlant id', 15 | NICKNAME: '식물 닉네임', 16 | DURATION: '함께한 날', 17 | INSTAGRAM: '인스타그램 id', 18 | D_DAY: '물 주기 D-Day', 19 | PLANT_ID: 'plant id', 20 | PLANT_IMAGE: '식물 이미지 url', 21 | PLANT_NAME: '식물 이름', 22 | LEVEL_NAME: '식물 레벨 이름', 23 | STATUS_MESSAGE: '식물 상태 메시지', 24 | STATUS_GAUGE: '식물 상태 게이지', 25 | }, 26 | }, 27 | ERROR_DESCRIPTION: { 28 | BAD_REQUEST: 'Bad Request - 요청 id 가 없거나, number가 아닌 경우 등', 29 | NOT_FOUND: 'Not Found - 요청한 id에 해당하는 식물 자원이 없는 경우', 30 | }, 31 | }; 32 | 33 | export const UPDATE_PLANT_DETAIL = { 34 | API_OPERATION: { 35 | summary: '식물 카드 수정 API', 36 | description: '식물 카드를 수정합니다.', 37 | }, 38 | API_PARAM: { 39 | type: Number, 40 | name: 'id', 41 | required: true, 42 | description: 'userPlant id', 43 | }, 44 | DTO_DESCRIPTION: { 45 | BODY: { 46 | PHONE: '전화번호', 47 | NICKNAME: '식물 닉네임', 48 | WATER_CYCLE: '주기', 49 | WATER_TIME: '물 주기 시간 (00:00 ~ 24:00)', 50 | INSTAGRAM: 51 | '인스타그램 id (보내지 않을 경우 기존 저장된 값을 null로 변경)', 52 | }, 53 | }, 54 | ERROR_DESCRIPTION: { 55 | BAD_REQUEST: 'Bad Request - request body 를 잘못 보낸 경우', 56 | NOT_FOUND: 'Not Found - 요청한 id에 해당하는 식물 자원이 없는 경우', 57 | }, 58 | }; 59 | 60 | export const READ_PLANT_INFORMATION = { 61 | API_OPERATION: { 62 | summary: '식물 정보 조회 API', 63 | description: '식물 단계별 정보를 조회합니다.', 64 | }, 65 | API_PARAM: { 66 | type: Number, 67 | name: 'id', 68 | required: true, 69 | description: 'plant id', 70 | }, 71 | DTO_DESCRIPTION: { 72 | RESPONSE: { 73 | INTRODUCTION: '이름 앞에 붙는 형용사', 74 | NAME: '식물 이름', 75 | MEANING: '식물 꽃말', 76 | EXPLANATION: '설명', 77 | PLANT_LEVEL: { 78 | LEVEL_NAME: '레벨 이름', 79 | DESCRIPTION: '레벨 설명', 80 | IMAGE_URL: '레벨 별 이미지', 81 | }, 82 | CIRCLE_IMAGE_URL: '전체 식물 이미지', 83 | }, 84 | }, 85 | ERROR_DESCRIPTION: { 86 | BAD_REQUEST: 'Bad Request - 요청 id 가 없거나, number가 아닌 경우 등', 87 | NOT_FOUND: 'Not Found - 요청한 id에 해당하는 식물 자원이 없는 경우', 88 | }, 89 | }; 90 | 91 | export const READ_PLANT_WATER_LOG = { 92 | API_OPERATION: { 93 | summary: '식물 물주기 조회 API', 94 | description: '식물별 물주기 기록을 조회합니다.', 95 | }, 96 | API_PARAM: { 97 | type: Number, 98 | name: 'id', 99 | required: true, 100 | description: 'user_plant_id', 101 | }, 102 | DTO_DESCRIPTION: { 103 | RESPONSE: { 104 | REVIEWS: { 105 | ID: '물주기 id', 106 | CREATEDAT: '물주기 날짜', 107 | REVIEW: '물주기 리뷰', 108 | }, 109 | }, 110 | }, 111 | ERROR_DESCRIPTION: { 112 | BAD_REQUEST: 'Bad Request - 요청 id 가 없거나, number가 아닌 경우 등', 113 | }, 114 | }; 115 | 116 | export const READ_PLANTS = { 117 | API_OPERATION: { 118 | summary: '메인 식물 리스트 조회 API', 119 | description: '유저의 전체 식물 리스트를 조회합니다.', 120 | }, 121 | DTO_DESCRIPTION: { 122 | RESPONSE: { 123 | PLANTS: { 124 | ID: 'userPlant id', 125 | PLANT_TYPE: '식물 종류 - rose, sun, stuki, min, blue, ojya', 126 | D_DAY: '물주기까지 남은 일수', 127 | NICKNAME: '식물 닉네임', 128 | DESCRIPTION: '식물 묘사 - ex) 바짝바짝 목이 마른', 129 | LEVEL_NAME: '식물 레벨 이름', 130 | CIRCLE_IMAGE: '식물 원형 이미지 url', 131 | MAIN_IMAGE: '식물 레벨별 메인 이미지 url', 132 | LOVE_GAUGE: '식물 애정도', 133 | // IS_WATERED: '오늘 식물에게 물을 준 적 있는지 여부' 134 | }, 135 | PLANT_COUNT: '식물 총 갯수', 136 | }, 137 | }, 138 | ERROR_DESCRIPTION: {}, 139 | }; 140 | -------------------------------------------------------------------------------- /src/exceptions/custom.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | 3 | export default class CustomException extends HttpException { 4 | public statusCode: number = 0; 5 | public message: string = ''; 6 | public success: boolean = false; 7 | 8 | constructor(status: number, message: string, success?: boolean) { 9 | super(message, status); 10 | this.statusCode = status; 11 | this.message = message; 12 | this.success = success; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/exceptions/global.exception.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | Logger, 8 | } from '@nestjs/common'; 9 | import { Prisma } from '@prisma/client'; 10 | import { Response } from 'express'; 11 | import { getStatusByPrismaExceptionCode } from 'src/utils/error'; 12 | 13 | @Catch() 14 | export class GlobalExceptionFilter implements ExceptionFilter { 15 | private readonly logger = new Logger(GlobalExceptionFilter.name); 16 | 17 | catch(exception: unknown, host: ArgumentsHost) { 18 | const ctx = host.switchToHttp(); 19 | const response = ctx.getResponse(); 20 | 21 | let status = HttpStatus.INTERNAL_SERVER_ERROR; 22 | let message = (exception as any).message; 23 | 24 | if (exception instanceof HttpException) { 25 | status = (exception as HttpException).getStatus(); 26 | } else if (exception instanceof Prisma.PrismaClientKnownRequestError) { 27 | status = getStatusByPrismaExceptionCode(exception.code); 28 | message = exception.meta.cause; 29 | } 30 | this.logger.error({ error: exception }); 31 | 32 | response.status(status).json({ 33 | statusCode: status, 34 | success: false, 35 | message, 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | import CustomException from './custom.exception'; 2 | import { GlobalExceptionFilter } from './global.exception'; 3 | 4 | export { CustomException, GlobalExceptionFilter }; 5 | -------------------------------------------------------------------------------- /src/interceptors/webhook.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { catchError, Observable, throwError } from 'rxjs'; 8 | import { IncomingWebhook } from '@slack/client'; 9 | import * as Sentry from '@sentry/minimal'; 10 | import configuration from 'src/config/configuration'; 11 | 12 | @Injectable() 13 | export class WebhookInterceptor implements NestInterceptor { 14 | intercept(_: ExecutionContext, next: CallHandler): Observable { 15 | return next.handle().pipe( 16 | catchError((error) => { 17 | Sentry.captureException(error); 18 | const webhook = new IncomingWebhook(configuration().sentryWebhookUrl); 19 | process.env.NODE_ENV !== 'test' && 20 | webhook.send({ 21 | attachments: [ 22 | { 23 | color: 'danger', 24 | text: '🚨 Cherish Dev - 에러 발생 🚨', 25 | fields: [ 26 | { 27 | title: `Request Message: ${error.message}`, 28 | value: error.stack, 29 | short: false, 30 | }, 31 | ], 32 | ts: Math.floor(new Date().getTime() / 1000).toString(), 33 | }, 34 | ], 35 | }); 36 | return throwError(() => error); 37 | }), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as Sentry from '@sentry/node'; 4 | import { setUpSwagger } from './config/swagger'; 5 | import configuration from './config/configuration'; 6 | import { GlobalExceptionFilter } from './exceptions/global.exception'; 7 | import { ValidationPipe } from '@nestjs/common'; 8 | 9 | require('dotenv').config(); 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule); 13 | Sentry.init({ 14 | dsn: configuration().sentryDsn, 15 | }); 16 | app.useGlobalPipes( 17 | new ValidationPipe({ disableErrorMessages: false, transform: true }), 18 | ); 19 | app.useGlobalFilters(new GlobalExceptionFilter()); 20 | setUpSwagger(app); 21 | await app.listen(configuration().port || 3000); 22 | } 23 | bootstrap(); 24 | -------------------------------------------------------------------------------- /src/plants/constants/plant-status.ts: -------------------------------------------------------------------------------- 1 | export const PLANT_STATUS: Record< 2 | string, 3 | { statusMessage: string; statusGauge: number } 4 | > = { 5 | healthy: { 6 | statusMessage: '힘이 솟아요', 7 | statusGauge: 1, 8 | }, 9 | waterDay: { 10 | statusMessage: '물 주는 날이에요!', 11 | statusGauge: 1, 12 | }, 13 | happy: { 14 | statusMessage: '기분이 좋아요', 15 | statusGauge: 0.75, 16 | }, 17 | thirsty: { 18 | statusMessage: '갈증나요', 19 | statusGauge: 0.5, 20 | }, 21 | veryThirsty: { 22 | statusMessage: '물 주세요', 23 | statusGauge: 0.25, 24 | }, 25 | }; 26 | 27 | export const PLANT_D_DAY: Record = { 28 | 1: [0, 3, 7], 29 | 2: [0, 4, 13], 30 | 3: [0, 6, 13], 31 | 4: [0, 3, 7], 32 | 5: [0, 3, 7], 33 | 6: [0, 3, 7], 34 | }; 35 | -------------------------------------------------------------------------------- /src/plants/dto/response-plant-detail.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 3 | import { READ_PLANT_DETAIL } from 'src/constants/swagger'; 4 | 5 | const DTO_RESPONSE_DESCRIPTION = READ_PLANT_DETAIL.DTO_DESCRIPTION.RESPONSE; 6 | 7 | export class ResponsePlantDetailData { 8 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.ID }) 9 | id: number; 10 | 11 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.NICKNAME }) 12 | nickname: string; 13 | 14 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.DURATION }) 15 | duration: number; 16 | 17 | @ApiProperty({ 18 | required: false, 19 | description: DTO_RESPONSE_DESCRIPTION.INSTAGRAM, 20 | }) 21 | instagram?: string; 22 | 23 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.D_DAY }) 24 | dDay: number; 25 | 26 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANT_ID }) 27 | plantId: number; 28 | 29 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANT_IMAGE }) 30 | plantImage: string; 31 | 32 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANT_NAME }) 33 | plantName: string; 34 | 35 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.LEVEL_NAME }) 36 | levelName: string; 37 | 38 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.STATUS_MESSAGE }) 39 | statusMessage: string; 40 | 41 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.STATUS_GAUGE }) 42 | statusGauge: number; 43 | } 44 | 45 | export class ResponsePlantDetailDto extends ResponseSuccessDto { 46 | @ApiProperty() 47 | data: ResponsePlantDetailData; 48 | } 49 | -------------------------------------------------------------------------------- /src/plants/dto/response-plant-information.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 3 | import { READ_PLANT_INFORMATION } from 'src/constants/swagger'; 4 | 5 | const DTO_RESPONSE_DESCRIPTION = 6 | READ_PLANT_INFORMATION.DTO_DESCRIPTION.RESPONSE; 7 | 8 | export class PlantLevelData { 9 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANT_LEVEL.LEVEL_NAME }) 10 | levelName: string; 11 | 12 | @ApiProperty({ 13 | description: DTO_RESPONSE_DESCRIPTION.PLANT_LEVEL.DESCRIPTION, 14 | }) 15 | description: string; 16 | 17 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANT_LEVEL.IMAGE_URL }) 18 | imageURL: string; 19 | } 20 | 21 | export class ResponsePlantInformationData { 22 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.INTRODUCTION }) 23 | introduction: string; 24 | 25 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.NAME }) 26 | name: string; 27 | 28 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.MEANING }) 29 | meaning: string; 30 | 31 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.EXPLANATION }) 32 | explanation: string; 33 | 34 | @ApiProperty({ 35 | isArray: true, 36 | type: PlantLevelData, 37 | }) 38 | plantLevel: PlantLevelData[]; 39 | 40 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.CIRCLE_IMAGE_URL }) 41 | circleImageURL: string; 42 | } 43 | 44 | export class ResponsePlantInformationDto extends ResponseSuccessDto { 45 | @ApiProperty() 46 | data: ResponsePlantInformationData; 47 | } 48 | -------------------------------------------------------------------------------- /src/plants/dto/response-plant-water-log.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 3 | import { READ_PLANT_WATER_LOG } from 'src/constants/swagger'; 4 | 5 | const DTO_RESPONSE_DESCRIPTION = READ_PLANT_WATER_LOG.DTO_DESCRIPTION.RESPONSE; 6 | 7 | export class PlantWaterReviewData { 8 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.REVIEWS.ID }) 9 | id: number; 10 | 11 | @ApiProperty({ 12 | description: DTO_RESPONSE_DESCRIPTION.REVIEWS.CREATEDAT, 13 | }) 14 | wateringDate: string; 15 | 16 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.REVIEWS.REVIEW }) 17 | review: string; 18 | } 19 | 20 | export class ResponsePlantWaterLogData { 21 | @ApiProperty() 22 | reviews: PlantWaterReviewData[]; 23 | } 24 | 25 | export class ResponsePlantWaterLogDto extends ResponseSuccessDto { 26 | @ApiProperty() 27 | data: ResponsePlantWaterLogData; 28 | } 29 | -------------------------------------------------------------------------------- /src/plants/dto/response-plants.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 3 | import { READ_PLANTS } from 'src/constants/swagger'; 4 | 5 | const DTO_RESPONSE_DESCRIPTION = READ_PLANTS.DTO_DESCRIPTION.RESPONSE; 6 | 7 | export class UserPlantData { 8 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.ID }) 9 | id: number; 10 | 11 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.PLANT_TYPE }) 12 | plantType: string; 13 | 14 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.D_DAY }) 15 | dDay: number; 16 | 17 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.NICKNAME }) 18 | nickname: string; 19 | 20 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.DESCRIPTION }) 21 | description: string; 22 | 23 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.LEVEL_NAME }) 24 | levelName: string; 25 | 26 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.CIRCLE_IMAGE }) 27 | circleImage: string; 28 | 29 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.MAIN_IMAGE }) 30 | mainImage: string; 31 | 32 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANTS.LOVE_GAUGE }) 33 | loveGauge: number; 34 | 35 | // @ApiProperty({description: DTO_RESPONSE_DESCRIPTION.PLANTS.IS_WATERED}) 36 | // isWatered: boolean; 37 | } 38 | 39 | export class ResponseUserPlantsData { 40 | @ApiProperty({ isArray: true, type: UserPlantData }) 41 | userPlants: UserPlantData[]; 42 | 43 | @ApiProperty({ description: DTO_RESPONSE_DESCRIPTION.PLANT_COUNT }) 44 | userPlantsCount: number; 45 | } 46 | 47 | export class ResponseUserPlantsDto extends ResponseSuccessDto { 48 | @ApiProperty() 49 | data: ResponseUserPlantsData; 50 | } 51 | -------------------------------------------------------------------------------- /src/plants/dto/update-plant-detail.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; 3 | import { UPDATE_PLANT_DETAIL } from 'src/constants/swagger'; 4 | 5 | const DTO_BODY_DESCRIPTION = UPDATE_PLANT_DETAIL.DTO_DESCRIPTION.BODY; 6 | 7 | export class UpdatePlantDetailDto { 8 | @ApiProperty({ description: DTO_BODY_DESCRIPTION.PHONE, required: true }) 9 | @IsNotEmpty() 10 | @IsString() 11 | phone: string; 12 | 13 | @ApiProperty({ description: DTO_BODY_DESCRIPTION.NICKNAME, required: true }) 14 | @IsNotEmpty() 15 | @IsString() 16 | nickname: string; 17 | 18 | @ApiProperty({ 19 | description: DTO_BODY_DESCRIPTION.WATER_CYCLE, 20 | required: true, 21 | }) 22 | @IsNotEmpty() 23 | @IsNumber() 24 | waterCycle: number; 25 | 26 | @ApiProperty({ description: DTO_BODY_DESCRIPTION.WATER_TIME, required: true }) 27 | @IsNotEmpty() 28 | @IsString() 29 | waterTime: string; 30 | 31 | @ApiProperty({ description: DTO_BODY_DESCRIPTION.INSTAGRAM, required: false }) 32 | @IsOptional() 33 | @IsString() 34 | instagram?: string; 35 | } 36 | -------------------------------------------------------------------------------- /src/plants/plants.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { PlantsController } from './plants.controller'; 4 | import { PlantsService } from './plants.service'; 5 | 6 | import { 7 | mockPlant, 8 | mockUserPlantDetailData, 9 | mockUserPlantDetailSuccessResponse, 10 | } from '../../test/mock/plants.mock'; 11 | 12 | describe('PlantsController', () => { 13 | let controller: PlantsController; 14 | let service: PlantsService; 15 | 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | controllers: [PlantsController], 19 | providers: [ 20 | { 21 | provide: PlantsService, 22 | useValue: { 23 | getUserPlantDetail: jest.fn(), 24 | getPlantInformation: jest.fn(), 25 | getPlantWaterLog: jest.fn(), 26 | }, 27 | }, 28 | ], 29 | }).compile(); 30 | 31 | controller = module.get(PlantsController); 32 | service = module.get(PlantsService); 33 | }); 34 | 35 | it('should be defined', () => { 36 | expect(controller).toBeDefined(); 37 | }); 38 | 39 | describe('get plant detail by userPlantId', () => { 40 | const mockUserPlantId: number = 1; 41 | 42 | it('존재하는 식물 id가 주어지면 성공 response 반환', async () => { 43 | jest 44 | .spyOn(service, 'getUserPlantDetail') 45 | .mockResolvedValueOnce(mockUserPlantDetailData); 46 | 47 | const result = await controller.getPlantDetail({ id: mockUserPlantId }); 48 | 49 | expect(result).toEqual(mockUserPlantDetailSuccessResponse); 50 | }); 51 | }); 52 | 53 | describe('get plant information by plantId', () => { 54 | const mockPlantId: number = 1; 55 | const mockResult = { 56 | ...mockPlant, 57 | plantLevel: [ 58 | { 59 | levelName: '새싹', 60 | description: '새싹이 쏘옥 얼굴을 내밀었어요!', 61 | imageURL: '', 62 | }, 63 | ], 64 | }; 65 | const mockSuccessResponse = { 66 | statusCode: 200, 67 | success: true, 68 | message: '식물 단계 조회 성공', 69 | data: mockResult, 70 | }; 71 | 72 | it('존재하는 식물 id가 주어지면, 성공 response 반환', async () => { 73 | jest.spyOn(service, 'getPlantInformation').mockResolvedValue(mockResult); 74 | 75 | const result = await controller.getPlantInformation({ id: mockPlantId }); 76 | 77 | expect(result).toEqual(mockSuccessResponse); 78 | }); 79 | }); 80 | 81 | describe('get plant water log information by userPlantId', () => { 82 | const mockUserPlantId: number = 1; 83 | const mockResult = { 84 | reviews: [ 85 | { 86 | id: 1, 87 | review: '리뷰1', 88 | wateringDate: '07/22', 89 | }, 90 | ], 91 | }; 92 | const mockSuccessResponse = { 93 | statusCode: 200, 94 | success: true, 95 | message: '식물 물주기 기록 조회 성공', 96 | data: mockResult, 97 | }; 98 | 99 | it('존재하는 userPlant id가 주어지면, 성공 response 반환', async () => { 100 | jest.spyOn(service, 'getPlantWaterLog').mockResolvedValue(mockResult); 101 | 102 | const result = await controller.getPlantWaterLog({ 103 | id: mockUserPlantId, 104 | }); 105 | 106 | expect(result).toEqual(mockSuccessResponse); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/plants/plants.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, HttpStatus, Param, Put } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiInternalServerErrorResponse, 5 | ApiNotFoundResponse, 6 | ApiOkResponse, 7 | ApiOperation, 8 | ApiParam, 9 | ApiTags, 10 | } from '@nestjs/swagger'; 11 | 12 | import { PlantsService } from './plants.service'; 13 | import { ResponsePlantInformationDto } from './dto/response-plant-information.dto'; 14 | import { CommonParamsDto } from 'src/common/dto/common-params.dto'; 15 | import { 16 | ERROR_DESCRIPTION, 17 | READ_PLANT_DETAIL, 18 | UPDATE_PLANT_DETAIL, 19 | READ_PLANT_INFORMATION, 20 | READ_PLANT_WATER_LOG, 21 | READ_PLANTS, 22 | } from 'src/constants/swagger'; 23 | import { wrapSuccess } from 'src/utils/success'; 24 | import { RESPONSE_MESSAGE } from 'src/common/objects'; 25 | import { ResponsePlantWaterLogDto } from './dto/response-plant-water-log.dto'; 26 | import { ResponsePlantDetailDto } from './dto/response-plant-detail.dto'; 27 | import { ResponseUserPlantsDto } from './dto/response-plants.dto'; 28 | import { UpdatePlantDetailDto } from './dto/update-plant-detail.dto'; 29 | import { ResponseSuccessDto } from 'src/common/dto/response-success.dto'; 30 | 31 | @Controller('plants') 32 | @ApiTags('Plants') 33 | @ApiInternalServerErrorResponse({ 34 | description: ERROR_DESCRIPTION.INTERNAL_SERVER_ERROR, 35 | }) 36 | export class PlantsController { 37 | constructor(private readonly plantsService: PlantsService) {} 38 | 39 | @Get(':id') 40 | @ApiOperation(READ_PLANT_DETAIL.API_OPERATION) 41 | @ApiParam(READ_PLANT_DETAIL.API_PARAM) 42 | @ApiOkResponse({ type: ResponsePlantDetailDto }) 43 | @ApiBadRequestResponse({ 44 | description: READ_PLANT_DETAIL.ERROR_DESCRIPTION.BAD_REQUEST, 45 | }) 46 | @ApiNotFoundResponse({ 47 | description: READ_PLANT_DETAIL.ERROR_DESCRIPTION.NOT_FOUND, 48 | }) 49 | async getPlantDetail( 50 | @Param() { id }: CommonParamsDto, 51 | ): Promise { 52 | const data = await this.plantsService.getUserPlantDetail(id); 53 | 54 | return wrapSuccess( 55 | HttpStatus.OK, 56 | RESPONSE_MESSAGE.READ_PLANT_DETAIL_SUCCESS, 57 | data, 58 | ); 59 | } 60 | 61 | @Put(':id') 62 | @ApiOperation(UPDATE_PLANT_DETAIL.API_OPERATION) 63 | @ApiParam(UPDATE_PLANT_DETAIL.API_PARAM) 64 | @ApiOkResponse({ type: ResponseSuccessDto }) 65 | @ApiBadRequestResponse({ 66 | description: UPDATE_PLANT_DETAIL.ERROR_DESCRIPTION.BAD_REQUEST, 67 | }) 68 | @ApiNotFoundResponse({ 69 | description: UPDATE_PLANT_DETAIL.ERROR_DESCRIPTION.NOT_FOUND, 70 | }) 71 | async updatePlantDetail( 72 | @Param() { id }: CommonParamsDto, 73 | @Body() updatePlantDetailDto: UpdatePlantDetailDto, 74 | ): Promise { 75 | await this.plantsService.updateUserPlantDetail(id, updatePlantDetailDto); 76 | 77 | return wrapSuccess( 78 | HttpStatus.OK, 79 | RESPONSE_MESSAGE.UPDATE_PLANT_DETAIL_SUCCESS, 80 | ); 81 | } 82 | 83 | @Get(':id/information') 84 | @ApiOperation(READ_PLANT_INFORMATION.API_OPERATION) 85 | @ApiParam(READ_PLANT_INFORMATION.API_PARAM) 86 | @ApiOkResponse({ type: ResponsePlantInformationDto }) 87 | @ApiBadRequestResponse({ 88 | description: READ_PLANT_INFORMATION.ERROR_DESCRIPTION.BAD_REQUEST, 89 | }) 90 | @ApiNotFoundResponse({ 91 | description: READ_PLANT_INFORMATION.ERROR_DESCRIPTION.NOT_FOUND, 92 | }) 93 | async getPlantInformation( 94 | @Param() { id }: CommonParamsDto, 95 | ): Promise { 96 | const data = await this.plantsService.getPlantInformation(id); 97 | 98 | return wrapSuccess( 99 | HttpStatus.OK, 100 | RESPONSE_MESSAGE.READ_PLANT_INFORMATION_SUCCESS, 101 | data, 102 | ); 103 | } 104 | 105 | @Get(':id/water') 106 | @ApiOperation(READ_PLANT_WATER_LOG.API_OPERATION) 107 | @ApiParam(READ_PLANT_WATER_LOG.API_PARAM) 108 | @ApiOkResponse({ type: ResponsePlantWaterLogDto }) 109 | @ApiBadRequestResponse({ 110 | description: READ_PLANT_WATER_LOG.ERROR_DESCRIPTION.BAD_REQUEST, 111 | }) 112 | async getPlantWaterLog( 113 | @Param() { id }: CommonParamsDto, 114 | ): Promise { 115 | const data = await this.plantsService.getPlantWaterLog(id); 116 | 117 | return wrapSuccess( 118 | HttpStatus.OK, 119 | RESPONSE_MESSAGE.READ_PLANT_WATER_LOG_SUCCESS, 120 | data, 121 | ); 122 | } 123 | 124 | @Get() 125 | @ApiOperation(READ_PLANTS.API_OPERATION) 126 | @ApiOkResponse({ type: ResponseUserPlantsDto }) 127 | async getUserPlants(): Promise { 128 | const data = await this.plantsService.getUserPlants(1); 129 | 130 | return wrapSuccess( 131 | HttpStatus.OK, 132 | RESPONSE_MESSAGE.READ_PLANTS_SUCCESS, 133 | data, 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/plants/plants.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PlantsService } from './plants.service'; 3 | import { PrismaService } from 'src/prisma.service'; 4 | import { ConfigModule, ConfigService } from '@nestjs/config'; 5 | import { Validation } from 'src/utils/validation'; 6 | 7 | import { PlantsController } from './plants.controller'; 8 | 9 | @Module({ 10 | imports: [ConfigModule], 11 | controllers: [PlantsController], 12 | providers: [PlantsService, PrismaService, ConfigService, Validation], 13 | }) 14 | export class PlantsModule {} 15 | -------------------------------------------------------------------------------- /src/plants/plants.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PrismaClient } from '@prisma/client'; 3 | import { mock, MockProxy } from 'jest-mock-extended'; 4 | 5 | import { PrismaService } from 'src/prisma.service'; 6 | import { PlantsService } from './plants.service'; 7 | 8 | import { notFound } from 'src/utils/error'; 9 | import * as objectUtils from 'src/utils/object'; 10 | 11 | import { 12 | mockPlant, 13 | mockUserPlantDetailData, 14 | mockUserPlantResponse, 15 | } from '../../test/mock/plants.mock'; 16 | 17 | describe('PlantsService', () => { 18 | let service: PlantsService; 19 | const plantPrisma: MockProxy> = 20 | mock(); 21 | const waterPrisma: MockProxy> = 22 | mock(); 23 | const userPlantPrisma: MockProxy< 24 | Pick 25 | > = mock(); 26 | 27 | const mockPrismaClient = { 28 | plant: plantPrisma, 29 | water: waterPrisma, 30 | userPlant: userPlantPrisma, 31 | }; 32 | 33 | beforeEach(async () => { 34 | const module: TestingModule = await Test.createTestingModule({ 35 | providers: [PlantsService, PrismaService], 36 | }) 37 | .overrideProvider(PrismaService) 38 | .useValue(mockPrismaClient) 39 | .compile(); 40 | 41 | service = module.get(PlantsService); 42 | }); 43 | 44 | it('should be defined', () => { 45 | expect(service).toBeDefined(); 46 | }); 47 | 48 | describe('get plant detail by userPlantId', () => { 49 | beforeAll(() => { 50 | jest.useFakeTimers(); 51 | jest.setSystemTime(new Date('2023-08-13 18:00')); 52 | }); 53 | 54 | afterAll(() => { 55 | jest.useRealTimers(); 56 | }); 57 | 58 | const mockUserPlantId: number = 1; 59 | 60 | it('존재하는 userPlantId 가 주어지면 식물 상세 정보를 반환한다.', async () => { 61 | const mockFindUnique = userPlantPrisma.findUnique.mockResolvedValueOnce( 62 | mockUserPlantResponse as any, 63 | ); 64 | jest 65 | .spyOn(service, 'getPlantLevelNameByLoveGauge') 66 | .mockResolvedValueOnce({ levelName: '새싹' }); 67 | 68 | const result = await service.getUserPlantDetail(mockUserPlantId); 69 | 70 | expect(result).toEqual(mockUserPlantDetailData); 71 | }); 72 | 73 | it('존재하지 않는 userPlantId 가 주어지면 Not Found 에러를 반환한다.', async () => { 74 | const mockFindUnique = 75 | userPlantPrisma.findUnique.mockResolvedValueOnce(null); 76 | jest.spyOn(service, 'getPlantLevelNameByLoveGauge').mockImplementation(); 77 | 78 | await expect( 79 | service.getUserPlantDetail(mockUserPlantId), 80 | ).rejects.toThrowError(notFound()); 81 | expect(service.getPlantLevelNameByLoveGauge).not.toHaveBeenCalled(); 82 | }); 83 | }); 84 | 85 | describe('update plant detail by userPlantId', () => { 86 | const mockUserPlantId = 1; 87 | const mockUpdatePlantDetailDto = { 88 | phone: '111', 89 | nickname: 'test', 90 | waterCycle: 22, 91 | waterTime: '22:00', 92 | }; 93 | 94 | it('instagram 필드가 주어지지 않을 경우 null 로 업데이트', async () => { 95 | const mockUpdate = userPlantPrisma.update.mockResolvedValueOnce( 96 | mockUpdatePlantDetailDto as any, 97 | ); 98 | 99 | await service.updateUserPlantDetail( 100 | mockUserPlantId, 101 | mockUpdatePlantDetailDto, 102 | ); 103 | 104 | expect(mockUpdate).toHaveBeenCalledWith({ 105 | where: { id: mockUserPlantId, isDeleted: false }, 106 | data: { ...mockUpdatePlantDetailDto, instagram: null }, 107 | }); 108 | }); 109 | }); 110 | 111 | describe('get plant information by plantId', () => { 112 | const mockPlantId = 1; 113 | const mockResult = { 114 | ...mockPlant, 115 | plantLevel: [ 116 | { 117 | levelName: '새싹', 118 | description: '새싹이 쏘옥 얼굴을 내밀었어요!', 119 | imageURL: '', 120 | }, 121 | ], 122 | }; 123 | 124 | it('존재하는 식물 id 가 주어지면, 식물 단계 정보를 반환한다.', async () => { 125 | const mockFindUnique = plantPrisma.findUnique.mockResolvedValueOnce( 126 | mockResult as any, 127 | ); 128 | 129 | const result = await service.getPlantInformation(mockPlantId); 130 | 131 | expect(result).toEqual(mockResult); 132 | expect(mockFindUnique).toHaveBeenCalledWith({ 133 | where: { id: mockPlantId, isDeleted: false }, 134 | select: { 135 | introduction: true, 136 | name: true, 137 | meaning: true, 138 | explanation: true, 139 | circleImageURL: true, 140 | PlantLevel: { 141 | select: { 142 | levelName: true, 143 | description: true, 144 | imageURL: true, 145 | }, 146 | }, 147 | }, 148 | }); 149 | }); 150 | 151 | it('존재하지 않는 식물 id 가 주어지면, Not Found 에러를 반환한다.', async () => { 152 | const mockFindUnique = plantPrisma.findUnique.mockResolvedValueOnce(null); 153 | jest.spyOn(objectUtils, 'renameObjectKey').mockImplementation(() => {}); 154 | 155 | await expect( 156 | service.getPlantInformation(mockPlantId), 157 | ).rejects.toThrowError(notFound()); 158 | expect(objectUtils.renameObjectKey).not.toHaveBeenCalled(); 159 | }); 160 | }); 161 | 162 | describe('get plant water log information by userPlantId', () => { 163 | const mockUserPlantId = 1; 164 | const mockResult = [ 165 | { 166 | id: 1, 167 | review: '리뷰1', 168 | wateringDate: '07/22', 169 | }, 170 | { 171 | id: 3, 172 | review: '리뷰2', 173 | wateringDate: '07/22', 174 | }, 175 | ]; 176 | 177 | it('존재하는 userPlant id 가 주어지면, 식물 단계 정보를 반환한다.', async () => { 178 | const mockFindMany = waterPrisma.findMany.mockResolvedValueOnce( 179 | mockResult as any, 180 | ); 181 | 182 | const result = await service.getPlantWaterLog(mockUserPlantId); 183 | expect(result).toEqual({ reviews: mockResult }); 184 | expect(mockFindMany).toHaveBeenCalledWith({ 185 | where: { userPlantId: mockUserPlantId, isDeleted: false }, 186 | select: { 187 | id: true, 188 | review: true, 189 | wateringDate: true, 190 | }, 191 | }); 192 | }); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /src/plants/plants.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PlantLevel, UserPlant } from '@prisma/client'; 3 | import * as dayjs from 'dayjs'; 4 | 5 | import { PrismaService } from 'src/prisma.service'; 6 | import { notFound } from 'src/utils/error'; 7 | import { renameObjectKey } from 'src/utils/object'; 8 | import * as utilPlants from './utils/plants'; 9 | import * as utilDay from 'src/utils/day'; 10 | import { ResponsePlantDetailData } from './dto/response-plant-detail.dto'; 11 | import { ResponsePlantInformationData } from './dto/response-plant-information.dto'; 12 | import { ResponsePlantWaterLogData } from './dto/response-plant-water-log.dto'; 13 | import { ResponseUserPlantsData } from './dto/response-plants.dto'; 14 | import { UpdatePlantDetailDto } from './dto/update-plant-detail.dto'; 15 | 16 | @Injectable() 17 | export class PlantsService { 18 | constructor(private prisma: PrismaService) {} 19 | 20 | async getUserPlantDetail(id: number): Promise { 21 | const userPlant = await this.prisma.userPlant.findUnique({ 22 | where: { id, isDeleted: false }, 23 | select: { 24 | nickname: true, 25 | instagram: true, 26 | createdAt: true, 27 | plantId: true, 28 | waterCycle: true, 29 | loveGauge: true, 30 | plant: { 31 | select: { 32 | name: true, 33 | circleImageURL: true, 34 | }, 35 | }, 36 | Water: { 37 | select: { 38 | wateringDate: true, 39 | }, 40 | orderBy: { 41 | wateringDate: 'desc', 42 | }, 43 | take: 1, 44 | }, 45 | }, 46 | }); 47 | if (!userPlant) { 48 | throw notFound(); 49 | } 50 | 51 | const { 52 | nickname, 53 | instagram, 54 | plantId, 55 | loveGauge, 56 | waterCycle, 57 | createdAt, 58 | Water, 59 | plant, 60 | } = userPlant; 61 | const { wateringDate } = Water[0]; 62 | const { name: plantName, circleImageURL: plantImage } = plant; 63 | 64 | const { levelName } = await this.getPlantLevelNameByLoveGauge( 65 | plantId, 66 | loveGauge, 67 | ); 68 | 69 | const nextWateringDate: Date = utilPlants.calculateNextWateringDate( 70 | wateringDate, 71 | waterCycle, 72 | ); 73 | 74 | const dDay: number = utilDay.calculateDday(new Date(), nextWateringDate); 75 | const duration: number = -utilDay.calculateDday(new Date(), createdAt); 76 | 77 | const { statusMessage, statusGauge } = utilPlants.calculatePlantStatus( 78 | plantId, 79 | -dDay, 80 | ); 81 | 82 | return { 83 | id, 84 | nickname, 85 | instagram, 86 | duration, 87 | dDay, 88 | plantId, 89 | plantName, 90 | plantImage, 91 | levelName, 92 | statusMessage, 93 | statusGauge, 94 | }; 95 | } 96 | 97 | async updateUserPlantDetail( 98 | id: number, 99 | updatePlantDetailDto: UpdatePlantDetailDto, 100 | ): Promise { 101 | if (!updatePlantDetailDto.instagram) { 102 | updatePlantDetailDto.instagram = null; 103 | } 104 | 105 | const updatedUserPlant = await this.prisma.userPlant.update({ 106 | where: { id, isDeleted: false }, 107 | data: updatePlantDetailDto, 108 | }); 109 | } 110 | 111 | async getPlantLevelNameByLoveGauge( 112 | plantId: number, 113 | loveGauge: number, 114 | ): Promise> { 115 | const level = utilPlants.calculatePlantLevel(loveGauge); 116 | 117 | const plantLevel = await this.prisma.plantLevel.findUnique({ 118 | where: { id: plantId, level, isDeleted: false }, 119 | select: { 120 | levelName: true, 121 | }, 122 | }); 123 | 124 | return plantLevel; 125 | } 126 | 127 | async getPlantInformation(id: number): Promise { 128 | const plant = await this.prisma.plant.findUnique({ 129 | where: { id, isDeleted: false }, 130 | select: { 131 | introduction: true, 132 | name: true, 133 | meaning: true, 134 | explanation: true, 135 | circleImageURL: true, 136 | PlantLevel: { 137 | select: { 138 | levelName: true, 139 | description: true, 140 | imageURL: true, 141 | }, 142 | }, 143 | }, 144 | }); 145 | 146 | if (!plant) { 147 | throw notFound(); 148 | } 149 | 150 | return renameObjectKey( 151 | plant, 152 | 'PlantLevel', 153 | 'plantLevel', 154 | ); 155 | } 156 | 157 | async getPlantWaterLog(id: number): Promise { 158 | const reviews = await this.prisma.water.findMany({ 159 | where: { userPlantId: id, isDeleted: false }, 160 | select: { 161 | id: true, 162 | review: true, 163 | wateringDate: true, 164 | }, 165 | }); 166 | 167 | const result = await Promise.all( 168 | reviews.map((review) => { 169 | const date = dayjs(review.wateringDate).format('MM/DD'); 170 | const data = { 171 | id: review.id, 172 | review: review.review, 173 | wateringDate: date, 174 | }; 175 | return data; 176 | }), 177 | ); 178 | 179 | return { reviews: result }; 180 | } 181 | 182 | async getUserPlants(userId: number): Promise { 183 | const userPlants = await this.prisma.userPlant.findMany({ 184 | where: { userId, isDeleted: false }, 185 | select: { 186 | id: true, 187 | plantId: true, 188 | nickname: true, 189 | loveGauge: true, 190 | waterCycle: true, 191 | waterCount: true, 192 | plant: { 193 | select: { 194 | name: true, 195 | circleImageURL: true, 196 | }, 197 | }, 198 | Water: { 199 | select: { 200 | wateringDate: true, 201 | }, 202 | orderBy: { 203 | wateringDate: 'desc', 204 | }, 205 | take: 1, 206 | }, 207 | }, 208 | }); 209 | 210 | if (!userPlants) { 211 | const data = { 212 | userPlants: [], 213 | userPlantsCount: 0, 214 | }; 215 | return data; 216 | } 217 | 218 | const processedUserPlants = await Promise.all( 219 | userPlants.map(async (userPlant) => { 220 | const nextWateringDate: Date = utilPlants.calculateNextWateringDate( 221 | userPlant.Water[0].wateringDate, 222 | userPlant.waterCycle, 223 | ); 224 | 225 | const dDay: number = utilDay.calculateDday( 226 | new Date(), 227 | nextWateringDate, 228 | ); 229 | 230 | const description = utilPlants.makeRandomDescription( 231 | dDay, 232 | userPlant.waterCount, 233 | ); 234 | 235 | const levelName = await this.getPlantLevelNameByLoveGauge( 236 | userPlant.plantId, 237 | userPlant.loveGauge, 238 | ); 239 | 240 | const mainImage = await this.prisma.plantLevel.findFirst({ 241 | where: { 242 | plantId: userPlant.plantId, 243 | level: utilPlants.calculatePlantLevel(userPlant.loveGauge), 244 | }, 245 | select: { imageURL: true }, 246 | }); 247 | 248 | const data = { 249 | id: userPlant.id, 250 | plantType: userPlant.plant.name, 251 | dDay, 252 | nickname: userPlant.nickname, 253 | description, 254 | levelName: levelName.levelName, 255 | circleImage: userPlant.plant.circleImageURL, 256 | mainImage: mainImage.imageURL, 257 | loveGauge: userPlant.loveGauge, 258 | }; 259 | return data; 260 | }), 261 | ); 262 | 263 | const data = { 264 | userPlants: processedUserPlants, 265 | userPlantsCount: processedUserPlants.length, 266 | }; 267 | 268 | return data; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/plants/utils/plants.ts: -------------------------------------------------------------------------------- 1 | import * as dayjs from 'dayjs'; 2 | import { PLANT_D_DAY, PLANT_STATUS } from '../constants/plant-status'; 3 | 4 | export const calculatePlantLevel = (loveGauge: number): number => { 5 | let level = 0; 6 | 7 | if (loveGauge > 3 && loveGauge <= 7) { 8 | level = 1; 9 | } else if (loveGauge > 7 && loveGauge <= 12) { 10 | level = 2; 11 | } 12 | 13 | return level; 14 | }; 15 | 16 | export const calculateNextWateringDate = ( 17 | wateringDate: Date, 18 | waterCycle: number, 19 | ): Date => { 20 | const lastWateringDate = dayjs(wateringDate); 21 | const nextWateringDate = lastWateringDate.add(waterCycle, 'day'); 22 | 23 | return new Date(nextWateringDate.format()); 24 | }; 25 | 26 | export const calculatePlantStatus = ( 27 | plantId: number, 28 | dDay: number, 29 | ): { statusMessage: string; statusGauge: number } => { 30 | let plantStatus: string = ''; 31 | 32 | if (dDay < PLANT_D_DAY[plantId][0]) { 33 | plantStatus = 'healthy'; 34 | } else if (dDay === PLANT_D_DAY[plantId][0]) { 35 | plantStatus = 'waterDay'; 36 | } else if ( 37 | dDay > PLANT_D_DAY[plantId][0] && 38 | dDay <= PLANT_D_DAY[plantId][1] 39 | ) { 40 | plantStatus = 'happy'; 41 | } else if ( 42 | dDay > PLANT_D_DAY[plantId][1] && 43 | dDay <= PLANT_D_DAY[plantId][2] 44 | ) { 45 | plantStatus = 'thirsty'; 46 | } else if (dDay > PLANT_D_DAY[plantId][2]) { 47 | plantStatus = 'veryThirsty'; 48 | } 49 | 50 | return PLANT_STATUS[plantStatus]; 51 | }; 52 | 53 | export const makeRandomDescription = ( 54 | dDay: number, 55 | waterCount: number, 56 | ): string => { 57 | const description = { 58 | 1: ['살금살금 돋아나는', '당신이 궁금한', '아직 수줍은', '아직 낯가리는', '아직은 조금 어색한'], 59 | 2: ['반짝반짝 빛이 나는', '뿜빠뿜빠 신이 난', '룰루랄라 즐거운', '생글생글 웃고있는', '한걸음 더 가까워진'], 60 | 3: ['바짝바짝 목이 마른', '묵묵히 당신을 기다리는', '발 동동 구르는', '말 못하고 기다리는', '힐끔힐끔 바라보는'], 61 | 4: ['휴... 기운없는', '살짝 서운한', '울먹울먹 서운한', '빙글빙글 어지러운', '꼬르륵 배가 고픈'] 62 | } 63 | 64 | let level; 65 | 66 | if (waterCount == 0) { 67 | if (dDay == 0) { 68 | level = 3 69 | } else if (dDay >= 1) { 70 | level = 1 71 | } else if (dDay < 0) { 72 | level = 4 73 | } 74 | } else { 75 | if (dDay == 0) { 76 | level = 3 77 | } else if (dDay >= 1) { 78 | level = 2 79 | } else if (dDay < 0) { 80 | level = 4 81 | } 82 | } 83 | 84 | const randomDescription = description[level][Math.floor(Math.random() * 5)]; 85 | 86 | return randomDescription 87 | } -------------------------------------------------------------------------------- /src/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient implements OnModuleInit { 6 | async onModuleInit() { 7 | await this.$connect(); 8 | } 9 | 10 | async enableShutdownHooks(app: INestApplication) { 11 | this.$on('beforeExit', async () => { 12 | await app.close(); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersController } from './users.controller'; 3 | 4 | describe('UsersController', () => { 5 | let controller: UsersController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UsersController], 10 | }).compile(); 11 | 12 | controller = module.get(UsersController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | @Controller('users') 4 | export class UsersController {} 5 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PrismaService } from 'src/prisma.service'; 3 | import { UsersController } from './users.controller'; 4 | import { UsersService } from './users.service'; 5 | 6 | @Module({ 7 | controllers: [UsersController], 8 | providers: [UsersService, PrismaService], 9 | exports: [UsersService], 10 | }) 11 | export class UsersModule {} 12 | -------------------------------------------------------------------------------- /src/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | 4 | describe('UsersService', () => { 5 | let service: UsersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UsersService], 10 | }).compile(); 11 | 12 | service = module.get(UsersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { User } from '@prisma/client'; 3 | import { PrismaService } from 'src/prisma.service'; 4 | 5 | @Injectable() 6 | export class UsersService { 7 | constructor(private prisma: PrismaService) {} 8 | 9 | async createUser(newUser): Promise { 10 | const user = await this.prisma.user.create({ data: newUser }); 11 | return user; 12 | } 13 | 14 | async getUserById(id: number): Promise { 15 | const user = await this.prisma.user.findUnique({ 16 | where: { 17 | id, 18 | isDeleted: false, 19 | }, 20 | }); 21 | return user; 22 | } 23 | 24 | async getUserByKaKaoId(kakaoId: number): Promise { 25 | const user = await this.prisma.user.findFirst({ 26 | where: { 27 | kakaoId, 28 | isDeleted: false, 29 | }, 30 | }); 31 | return user; 32 | } 33 | 34 | async updateRefreshTokenByUserId( 35 | id: number, 36 | refreshToken: string, 37 | ): Promise { 38 | const updatedUser = await this.prisma.user.update({ 39 | where: { id }, 40 | data: { 41 | refreshToken, 42 | }, 43 | }); 44 | return updatedUser; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/day.ts: -------------------------------------------------------------------------------- 1 | import * as dayjs from 'dayjs'; 2 | 3 | export const calculateDday = (today: Date, targetDay: Date) => { 4 | const _today = dayjs(today); 5 | const _targetDay = dayjs(targetDay); 6 | 7 | const result = _targetDay.diff(_today, 'day'); 8 | 9 | return result; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { CustomException } from '../exceptions'; 3 | import { RESPONSE_MESSAGE } from '../common/objects'; 4 | 5 | export const getStatusByPrismaExceptionCode = (code: string): number => { 6 | let status: number = HttpStatus.INTERNAL_SERVER_ERROR; 7 | 8 | switch (code) { 9 | case 'P2025': 10 | status = HttpStatus.NOT_FOUND; 11 | break; 12 | } 13 | 14 | return status; 15 | }; 16 | 17 | export const customError = (statusCode: number, message: string) => { 18 | return new CustomException(statusCode, message); 19 | }; 20 | 21 | export const internalServerError = () => { 22 | return new CustomException( 23 | HttpStatus.INTERNAL_SERVER_ERROR, 24 | RESPONSE_MESSAGE.INTERNAL_SERVER_ERROR, 25 | ); 26 | }; 27 | 28 | export const badRequest = () => { 29 | return new CustomException( 30 | HttpStatus.BAD_REQUEST, 31 | RESPONSE_MESSAGE.BAD_REQUEST, 32 | ); 33 | }; 34 | 35 | export const forbidden = () => { 36 | return new CustomException(HttpStatus.FORBIDDEN, RESPONSE_MESSAGE.FORBIDDEN); 37 | }; 38 | 39 | export const notFound = () => { 40 | return new CustomException(HttpStatus.NOT_FOUND, RESPONSE_MESSAGE.NOT_FOUND); 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export const renameObjectKey = ( 2 | obj: U, 3 | key: string, 4 | newKey: string, 5 | ): T => { 6 | obj[newKey] = obj[key]; 7 | delete obj[key]; 8 | 9 | return obj as unknown as T; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/success.ts: -------------------------------------------------------------------------------- 1 | export const wrapSuccess = ( 2 | statusCode: number, 3 | message?: string, 4 | data?: any, 5 | ) => { 6 | return { 7 | success: true, 8 | statusCode, 9 | message, 10 | data, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { CreateSigninDto } from 'src/auth/dto/create-signin.dto'; 3 | import { CustomException } from 'src/exceptions'; 4 | import { badRequest } from './error'; 5 | 6 | export class Validation { 7 | private readonly logger = new Logger(Validation.name); 8 | 9 | async validationSignin( 10 | createSigninDto: CreateSigninDto, 11 | ): Promise { 12 | const { socialType, kakaoAccessToken, idToken, code } = createSigninDto; 13 | 14 | if (!kakaoAccessToken && !idToken && !code) { 15 | this.logger.error('empty field', createSigninDto); 16 | return badRequest(); 17 | } 18 | 19 | if (kakaoAccessToken && idToken && code) { 20 | this.logger.error('duplicate apple, kakao', createSigninDto); 21 | return badRequest(); 22 | } 23 | 24 | if (socialType === 'kakao' && !kakaoAccessToken) { 25 | this.logger.error('empty kakao access token', createSigninDto); 26 | return badRequest(); 27 | } 28 | 29 | if (socialType === 'apple') { 30 | if ((idToken && !code) || (!idToken && code)) { 31 | this.logger.error('empty apple token', createSigninDto); 32 | return badRequest(); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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 | "moduleNameMapper": { 10 | "^src/(.*)$": "/../src/$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/mock/plants.mock.ts: -------------------------------------------------------------------------------- 1 | export const mockUserPlantResponse = { 2 | nickname: 'test', 3 | instagram: null, 4 | createdAt: new Date('2023-07-22T08:16:52.538Z'), 5 | plantId: 4, 6 | waterCycle: 14, 7 | loveGauge: 0, 8 | plant: { 9 | name: '민들레', 10 | circleImageURL: 11 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/mindlere-circle.png', 12 | }, 13 | Water: [ 14 | { 15 | wateringDate: new Date('2023-07-22T18:16:53.000Z'), 16 | }, 17 | ], 18 | }; 19 | 20 | export const mockUserPlantDetailData = { 21 | id: 1, 22 | nickname: 'test', 23 | instagram: null, 24 | duration: 22, 25 | dDay: -7, 26 | plantId: 4, 27 | plantName: '민들레', 28 | plantImage: 29 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/mindlere-circle.png', 30 | levelName: '새싹', 31 | statusMessage: '갈증나요', 32 | statusGauge: 0.5, 33 | }; 34 | 35 | export const mockUserPlantDetailSuccessResponse = { 36 | statusCode: 200, 37 | success: true, 38 | message: '식물 상세 조회 성공', 39 | data: mockUserPlantDetailData, 40 | }; 41 | 42 | export const mockUpdatePlantDetailDto = { 43 | phone: '111', 44 | nickname: 'test', 45 | waterCycle: 33, 46 | waterTime: '22:00', 47 | instagram: 'instagram-id', 48 | }; 49 | 50 | export const mockUpdatePlantDetailSuccessResponse = { 51 | statusCode: 200, 52 | success: true, 53 | message: '식물 카드 업데이트 성공', 54 | }; 55 | 56 | export const mockPlantsInformationResponse = { 57 | success: true, 58 | statusCode: 200, 59 | message: '식물 단계 조회 성공', 60 | data: { 61 | introduction: '붙임성이 좋은\n앙증맞은 오렌지 자스민', 62 | name: '오렌지 자스민', 63 | meaning: '당신을 향해', 64 | explanation: 65 | '1~2일에 한 번 물을 주는 것을 추천해요\n물을 좋아하는 자스민이 곧 귀여운 열매를 선물할거에요!', 66 | circleImageURL: 67 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/circleImages/mindlere-circle.png', 68 | plantLevel: [ 69 | { 70 | levelName: '어린 나무', 71 | description: '무럭무럭 자랄 준비를 하고 있어요!', 72 | imageURL: 73 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-1.png', 74 | }, 75 | { 76 | levelName: '개화', 77 | description: '하얀 꽃이 활짝 피어났어요!', 78 | imageURL: 79 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-2.png', 80 | }, 81 | { 82 | levelName: '열매', 83 | description: '꽃이 머물다간 자리에 앙증맞은 열매가 열렸네요!', 84 | imageURL: 85 | 'https://cherish-static-dev.s3.ap-northeast-2.amazonaws.com/plantLevel/mindlere-complete.png', 86 | }, 87 | ], 88 | }, 89 | }; 90 | 91 | export const mockBadRequestResponse = { 92 | success: false, 93 | statusCode: 400, 94 | message: 'Bad Request Exception', 95 | }; 96 | 97 | export const mockNotFoundResponse = { 98 | success: false, 99 | statusCode: 404, 100 | message: 'Not Found', 101 | }; 102 | 103 | export const mockPlant = { 104 | name: '오렌지 자스민', 105 | introduction: '붙임성이 좋은\n앙증맞은 오렌지 자스민', 106 | meaning: '당신을 향해', 107 | explanation: 108 | '1~2일에 한 번 물을 주는 것을 추천해요\n물을 좋아하는 자스민이 곧 귀여운 열매를 선물할거에요!', 109 | circleImageURL: 'aaa', 110 | }; 111 | 112 | export const mockPlantWaterLogsResponse = { 113 | success: true, 114 | statusCode: 200, 115 | message: '식물 물주기 기록 조회 성공', 116 | data: { 117 | reviews: [ 118 | { 119 | id: 1, 120 | review: '리뷰1', 121 | wateringDate: '07/22', 122 | }, 123 | { 124 | id: 3, 125 | review: '리뷰2', 126 | wateringDate: '07/23', 127 | }, 128 | ], 129 | }, 130 | }; 131 | -------------------------------------------------------------------------------- /test/plants.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import * as dayjs from 'dayjs'; 5 | import * as utc from 'dayjs/plugin/utc'; 6 | import * as timezone from 'dayjs/plugin/timezone'; 7 | 8 | dayjs.extend(utc); 9 | dayjs.extend(timezone); 10 | 11 | import { AppModule } from '../src/app.module'; 12 | import { PlantsModule } from 'src/plants/plants.module'; 13 | 14 | import { 15 | mockBadRequestResponse, 16 | mockNotFoundResponse, 17 | mockPlantWaterLogsResponse, 18 | mockPlantsInformationResponse, 19 | mockUpdatePlantDetailDto, 20 | mockUpdatePlantDetailSuccessResponse, 21 | mockUserPlantDetailSuccessResponse, 22 | } from './mock/plants.mock'; 23 | 24 | describe('Plants (e2e)', () => { 25 | let app: INestApplication; 26 | 27 | beforeEach(async () => { 28 | const moduleFixture: TestingModule = await Test.createTestingModule({ 29 | imports: [AppModule, PlantsModule], 30 | }).compile(); 31 | 32 | app = moduleFixture.createNestApplication(); 33 | app.useGlobalPipes( 34 | new ValidationPipe({ disableErrorMessages: false, transform: true }), 35 | ); 36 | await app.init(); 37 | }); 38 | 39 | afterAll(async () => { 40 | await app.close(); 41 | }); 42 | 43 | describe('[GET] /plants/:id', () => { 44 | beforeAll(() => { 45 | jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); 46 | jest.setSystemTime(new Date('2023-08-13 18:00')); 47 | }); 48 | 49 | afterAll(() => { 50 | jest.useRealTimers(); 51 | }); 52 | 53 | it('200 OK', () => { 54 | return request(app.getHttpServer()) 55 | .get(`/plants/1`) 56 | .expect(200) 57 | .expect(mockUserPlantDetailSuccessResponse); 58 | }); 59 | 60 | it('400 Bad Request', () => { 61 | return request(app.getHttpServer()) 62 | .get(`/plants/hi`) 63 | .expect(400) 64 | .expect(mockBadRequestResponse); 65 | }); 66 | 67 | it('404 Not Found', () => { 68 | return request(app.getHttpServer()) 69 | .get(`/plants/100`) 70 | .expect(404) 71 | .expect(mockNotFoundResponse); 72 | }); 73 | }); 74 | 75 | describe('[PUT] /plants/:id', () => { 76 | it('200 OK', () => { 77 | return request(app.getHttpServer()) 78 | .put(`/plants/1`) 79 | .send(mockUpdatePlantDetailDto) 80 | .expect(200) 81 | .expect(mockUpdatePlantDetailSuccessResponse); 82 | }); 83 | 84 | it('400 Bad Request', () => { 85 | return request(app.getHttpServer()) 86 | .put(`/plants/1`) 87 | .send({}) 88 | .expect(400) 89 | .expect(mockBadRequestResponse); 90 | }); 91 | 92 | it('404 Not Found', () => { 93 | return request(app.getHttpServer()) 94 | .put(`/plants/2`) 95 | .send(mockUpdatePlantDetailDto) 96 | .expect(404); 97 | }); 98 | }); 99 | 100 | describe('[GET] /plants/:id/information', () => { 101 | it('200 OK', () => { 102 | return request(app.getHttpServer()) 103 | .get(`/plants/1/information`) 104 | .expect(200) 105 | .expect(mockPlantsInformationResponse); 106 | }); 107 | 108 | it('400 Bad Request', () => { 109 | return request(app.getHttpServer()) 110 | .get(`/plants/hi/information`) 111 | .expect(400) 112 | .expect(mockBadRequestResponse); 113 | }); 114 | 115 | it('404 Not Found', () => { 116 | return request(app.getHttpServer()) 117 | .get(`/plants/100/information`) 118 | .expect(404) 119 | .expect(mockNotFoundResponse); 120 | }); 121 | }); 122 | 123 | describe('[GET] /plants/:id/water', () => { 124 | it('200 OK', () => { 125 | return request(app.getHttpServer()) 126 | .get(`/plants/1/water`) 127 | .expect(200) 128 | .expect(mockPlantWaterLogsResponse); 129 | }); 130 | 131 | it('400 Bad Request', () => { 132 | return request(app.getHttpServer()) 133 | .get(`/plants/hi/water`) 134 | .expect(400) 135 | .expect(mockBadRequestResponse); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /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 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "types": ["node"] 21 | } 22 | } 23 | --------------------------------------------------------------------------------