├── .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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------