diff --git a/.github/workflows/be-autodeploy.yml b/.github/workflows/be-autodeploy.yml new file mode 100644 index 0000000..80a1c10 --- /dev/null +++ b/.github/workflows/be-autodeploy.yml @@ -0,0 +1,71 @@ +name: auto deploy + +on: + push: + branches: + - develop/be + + +jobs: + push_to_registry: + name: Push to ncp container registry + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to NCP Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} + username: ${{ secrets.NCP_ACCESS_KEY }} + password: ${{ secrets.NCP_SECRET_KEY }} + - name: Create config file + run: | + echo "export const awsConfig = ${OBJECT_STORAGE_CONFIG}" > ./be/objectStorage.config.ts + env: + OBJECT_STORAGE_CONFIG: ${{ secrets.OBJECT_STORAGE_CONFIG }} + + - name: Create TypeORM config + run: | + mkdir -p ./be/src/configs + echo "import { TypeOrmModuleOptions } from '@nestjs/typeorm';" > ./be/src/configs/typeorm.config.ts + echo "export const typeORMConfig: TypeOrmModuleOptions = ${TYPEORM_CONFIG}" >> ./be/src/configs/typeorm.config.ts + env: + TYPEORM_CONFIG: ${{ secrets.TYPEORM_CONFIG }} + + - name: build and push + uses: docker/build-push-action@v3 + with: + context: ./be + file: ./be/Dockerfile + push: true + tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob:latest + cache-from: type=registry,ref=${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob:latest + cache-to: type=inline + + + + + + pull_from_registry: + name: Connect server ssh and pull from container registry + needs: push_to_registry + runs-on: ubuntu-latest + steps: + - name: connect ssh to Nginx server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NGINX_HOST }} + username: ${{ secrets.NGINX_USERNAME }} + password: ${{ secrets.NGINX_PASSWORD }} + port: ${{ secrets.NGINX_PORT }} + script: | + ssh ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} <<- 'EOSSH' + docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob + docker stop $(docker ps -a -q) + docker rm $(docker ps -a -q) + docker run -d -p 8000:8000 -e API_KEY=${{ secrets.DEV_APIKEY }} -e NODE_ENV="DEV" ${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob + docker image prune -f + EOSSH diff --git a/be/Dockerfile b/be/Dockerfile new file mode 100644 index 0000000..2a17d51 --- /dev/null +++ b/be/Dockerfile @@ -0,0 +1,23 @@ +# 베이스 이미지 선택 +FROM node:20 + +# 작업 디렉토리 설정 +WORKDIR /usr/src/app + +# 종속성 파일 복사 (package.json 및 package-lock.json) +COPY package*.json ./ + +# 종속성 설치 +RUN npm install + +# 애플리케이션 소스 코드 복사 +COPY . . + +# TypeScript 컴파일 +RUN npm run build + +# 애플리케이션 실행 포트 지정 +EXPOSE 8000 + +# 애플리케이션 실행 명령어 +CMD ["node", "dist/src/main"] diff --git a/be/dockerignore b/be/dockerignore new file mode 100644 index 0000000..24cdedf --- /dev/null +++ b/be/dockerignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/be/package-lock.json b/be/package-lock.json index 26abfff..7f28bf6 100644 --- a/be/package-lock.json +++ b/be/package-lock.json @@ -14,18 +14,21 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.15", "@nestjs/typeorm": "^10.0.1", "aws-sdk": "^2.348.0", "axios": "^1.6.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "passport-naver": "^1.0.6", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "sharp": "^0.33.0", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.17", "uuid": "^9.0.1", @@ -37,6 +40,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", "@types/winston": "^2.4.4", @@ -894,6 +898,15 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz", + "integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1005,6 +1018,437 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.0.tgz", + "integrity": "sha512-070tEheekI1LJWTGPC9WlQEa5UoKTXzzlORBHMX4TbfUxMiL336YHR8vBEUNsjse0RJCX8dZ4ZXwT595aEF1ug==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.0.tgz", + "integrity": "sha512-pu/nvn152F3qbPeUkr+4e9zVvEhD3jhwzF473veQfMPkOYo9aoWXSfdZH/E6F+nYC3qvFjbxbvdDbUtEbghLqw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz", + "integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz", + "integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz", + "integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz", + "integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz", + "integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz", + "integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.0.tgz", + "integrity": "sha512-4horD3wMFd5a0ddbDY8/dXU9CaOgHjEHALAddXgafoR5oWq5s8X61PDgsSeh4Qupsdo6ycfPPSSNBrfVQnwwrg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.0.tgz", + "integrity": "sha512-dcomVSrtgF70SyOr8RCOCQ8XGVThXwe71A1d8MGA+mXEVRJ/J6/TrCbBEJh9ddcEIIsrnrkolaEvYSHqVhswQw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.0.tgz", + "integrity": "sha512-TiVJbx38J2rNVfA309ffSOB+3/7wOsZYQEOlKqOUdWD/nqkjNGrX+YQGz7nzcf5oy2lC+d37+w183iNXRZNngQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.0.tgz", + "integrity": "sha512-PaZM4Zi7/Ek71WgTdvR+KzTZpBqrQOFcPe7/8ZoPRlTYYRe43k6TWsf4GVH6XKRLMYeSp8J89RfAhBrSP4itNA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.0.tgz", + "integrity": "sha512-1QLbbN0zt+32eVrg7bb1lwtvEaZwlhEsY1OrijroMkwAqlHqFj6R33Y47s2XUv7P6Ie1PwCxK/uFnNqMnkd5kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.0.tgz", + "integrity": "sha512-CecqgB/CnkvCWFhmfN9ZhPGMLXaEBXl4o7WtA6U3Ztrlh/s7FUKX4vNxpMSYLIrWuuzjiaYdfU3+Tdqh1xaHfw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.0.tgz", + "integrity": "sha512-Hn4js32gUX9qkISlemZBUPuMs0k/xNJebUNl/L6djnU07B/HAA2KaxRVb3HvbU5fL242hLOcp0+tR+M8dvJUFw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.44.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.0.tgz", + "integrity": "sha512-5HfcsCZi3l5nPRF2q3bllMVMDXBqEWI3Q8KQONfzl0TferFE5lnsIG0A1YrntMAGqvkzdW6y1Ci1A2uTvxhfzg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.0.tgz", + "integrity": "sha512-i3DtP/2ce1yKFj4OzOnOYltOEL/+dp4dc4dJXJBv6god1AFTcmkaA99H/7SwOmkCOBQkbVvA3lCGm3/5nDtf9Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2043,6 +2487,37 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "1.4.4-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", + "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@nestjs/schedule": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz", + "integrity": "sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ==", + "dependencies": { + "cron": "3.1.3", + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.3.tgz", @@ -2446,12 +2921,26 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz", + "integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ==" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz", @@ -4438,6 +4927,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cron": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.3.tgz", + "integrity": "sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4765,7 +5263,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "dev": true, "engines": { "node": ">=8" } @@ -8281,6 +8778,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/macos-release": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", @@ -8579,9 +9084,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", @@ -10509,6 +11014,57 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.0.tgz", + "integrity": "sha512-99DZKudjm/Rmz+M0/26t4DKpXyywAOJaayGS9boEn7FvgtG0RYBi46uPE2c+obcJRtA3AZa0QwJot63gJQ1F0Q==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.0", + "@img/sharp-darwin-x64": "0.33.0", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.0", + "@img/sharp-linux-arm64": "0.33.0", + "@img/sharp-linux-s390x": "0.33.0", + "@img/sharp-linux-x64": "0.33.0", + "@img/sharp-linuxmusl-arm64": "0.33.0", + "@img/sharp-linuxmusl-x64": "0.33.0", + "@img/sharp-wasm32": "0.33.0", + "@img/sharp-win32-ia32": "0.33.0", + "@img/sharp-win32-x64": "0.33.0" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/be/package.json b/be/package.json index 02fd800..02bcb02 100644 --- a/be/package.json +++ b/be/package.json @@ -26,18 +26,21 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.15", "@nestjs/typeorm": "^10.0.1", "aws-sdk": "^2.348.0", "axios": "^1.6.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "passport-naver": "^1.0.6", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "sharp": "^0.33.0", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.17", "uuid": "^9.0.1", @@ -49,6 +52,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", "@types/winston": "^2.4.4", @@ -92,4 +96,4 @@ ], "coverageDirectory": "../coverage" } -} \ No newline at end of file +} diff --git a/be/src/auth/auth.controller.ts b/be/src/auth/auth.controller.ts index bbf98af..3a1c195 100644 --- a/be/src/auth/auth.controller.ts +++ b/be/src/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { Headers, Post, UseGuards, + UsePipes, ValidationPipe, } from "@nestjs/common"; import { AuthService } from "./auth.service"; @@ -15,11 +16,22 @@ import { ApiBody, } from "@nestjs/swagger"; import { RefreshTokenDto } from "./dto/refreshToken.dto"; +import { LoginInfoDto } from "./dto/loginInfo.dto"; @ApiTags("Authentication") @Controller("auth") export class AuthController { - constructor(private authService: AuthService) {} + constructor(private authService: AuthService) { } + + @Post("login") + @ApiOperation({ summary: "일반 로그인" }) + @ApiResponse({ status: 200, description: "성공적으로 로그인됨." }) + @ApiResponse({ status: 401, description: "인증 오류" }) + @UsePipes(new ValidationPipe) + login(@Body() loginInfoDto: LoginInfoDto) { + return this.authService.login(loginInfoDto); + } + @Post("social-login") @ApiOperation({ summary: "네이버 소셜 로그인" }) diff --git a/be/src/auth/auth.module.ts b/be/src/auth/auth.module.ts index 7606b23..03a526b 100644 --- a/be/src/auth/auth.module.ts +++ b/be/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; import { UserModule } from "../user/user.module"; import { JwtStrategy } from "./strategy/jwt.strategy"; +import { AuthRepository } from "./auth.repository"; @Module({ imports: [ @@ -18,7 +19,7 @@ import { JwtStrategy } from "./strategy/jwt.strategy"; forwardRef(() => UserModule), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [PassportModule], + providers: [AuthService, JwtStrategy, AuthRepository], + exports: [PassportModule, AuthService], }) -export class AuthModule {} +export class AuthModule { } diff --git a/be/src/auth/auth.repository.ts b/be/src/auth/auth.repository.ts new file mode 100644 index 0000000..f91b8f8 --- /dev/null +++ b/be/src/auth/auth.repository.ts @@ -0,0 +1,14 @@ +import { DataSource, IsNull, Repository, Not, In } from "typeorm"; +import { + ConflictException, + Injectable, + BadRequestException, +} from "@nestjs/common"; +import { AuthRefreshTokenEntity } from "./entity/auth.refreshtoken.entity"; + +@Injectable() +export class AuthRepository extends Repository { + constructor(private dataSource: DataSource) { + super(AuthRefreshTokenEntity, dataSource.createEntityManager()); + } +} \ No newline at end of file diff --git a/be/src/auth/auth.service.ts b/be/src/auth/auth.service.ts index 3b718b3..88f58b2 100644 --- a/be/src/auth/auth.service.ts +++ b/be/src/auth/auth.service.ts @@ -3,17 +3,39 @@ import { NotFoundException, HttpException, HttpStatus, + BadRequestException, + UnauthorizedException, + ForbiddenException, } from "@nestjs/common"; import { UserRepository } from "../user/user.repository"; import { JwtService } from "@nestjs/jwt"; import axios from "axios"; +import { LoginInfoDto } from "./dto/loginInfo.dto"; +import { comparePasswords } from "../utils/encryption.utils"; +import { AuthRepository } from "./auth.repository"; @Injectable() export class AuthService { constructor( private userRepository: UserRepository, - private jwtService: JwtService - ) {} + private jwtService: JwtService, + private authRepository: AuthRepository + ) { } + async login(loginInfoDto: LoginInfoDto) { + const data = await this.userRepository.findOne({ select: ["password"], where: { email: loginInfoDto.email, provider: "site" } }) + try { + const result = await comparePasswords(loginInfoDto.password, data["password"]); + if (result) return this.signin(loginInfoDto); + else throw new HttpException("LOGIN FAILED", HttpStatus.FORBIDDEN); + } catch (err) { + throw new UnauthorizedException(); + } + } + + async logout(id: number) { + await this.authRepository.delete({ id: id }); + } + async NaverAuth(authorization: string) { if (!authorization) { throw new HttpException( @@ -36,21 +58,24 @@ export class AuthService { } } + async createTokens(id: number) { + const payload = { id: id }; + const accessToken = this.jwtService.sign(payload); + const refreshToken = this.jwtService.sign(payload, { + secret: "nibobnebob", + expiresIn: "7d", + }); + await this.authRepository.upsert({ id: id, accessToken: accessToken, refreshToken: refreshToken }, ["id"]); + return { accessToken, refreshToken }; + } + async signin(loginRequestUser: any) { const user = await this.userRepository.findOneBy({ email: loginRequestUser.email, }); if (user) { - const payload = { id: user.id }; - const accessToken = this.jwtService.sign(payload); - - const refreshToken = this.jwtService.sign(payload, { - secret: "nibobnebob", - expiresIn: "7d", - }); - - return { accessToken, refreshToken }; + return await this.createTokens(user.id); } else { throw new NotFoundException( "사용자가 등록되어 있지 않습니다. 회원가입을 진행해주세요" @@ -63,9 +88,13 @@ export class AuthService { const decoded = this.jwtService.verify(refreshToken, { secret: "nibobnebob", }); - const payload = { id: decoded.id }; - const accessToken = this.jwtService.sign(payload); - return { accessToken }; + if (await this.authRepository.findOne({ where: { id: decoded.id, refreshToken: refreshToken } })) { + const payload = { id: decoded.id }; + const accessToken = this.jwtService.sign(payload); + await this.authRepository.update(decoded.id, { accessToken: accessToken }); + return { accessToken }; + } + throw new HttpException("Invalid refresh token", HttpStatus.UNAUTHORIZED); } catch (err) { throw new HttpException("Invalid refresh token", HttpStatus.UNAUTHORIZED); } diff --git a/be/src/auth/dto/loginInfo.dto.ts b/be/src/auth/dto/loginInfo.dto.ts new file mode 100644 index 0000000..c4f68c7 --- /dev/null +++ b/be/src/auth/dto/loginInfo.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsString, MaxLength } from "class-validator"; + +export class LoginInfoDto { + @ApiProperty({ + example: "user@example.com", + description: "The email of the user", + }) + @IsEmail() + @IsNotEmpty() + @MaxLength(50) + email: string; + + @ApiProperty({ + description: "The password of the user", + }) + @IsString() + @IsNotEmpty() + @MaxLength(50) + password: string; +} diff --git a/be/src/auth/entity/auth.refreshtoken.entity.ts b/be/src/auth/entity/auth.refreshtoken.entity.ts new file mode 100644 index 0000000..f026e5f --- /dev/null +++ b/be/src/auth/entity/auth.refreshtoken.entity.ts @@ -0,0 +1,13 @@ +import { Entity, Column, PrimaryColumn } from 'typeorm'; + +@Entity("auth_token") +export class AuthRefreshTokenEntity { + @PrimaryColumn() + id: number; + + @Column({ type: 'varchar', length: 300 }) + accessToken: string + + @Column({ type: 'varchar', length: 300 }) + refreshToken: string +} diff --git a/be/src/aws/aws.service.ts b/be/src/aws/aws.service.ts index a468283..d2745dc 100644 --- a/be/src/aws/aws.service.ts +++ b/be/src/aws/aws.service.ts @@ -1,7 +1,7 @@ import { Injectable } from "@nestjs/common"; import * as AWS from "aws-sdk"; -import { awsConfig } from "objectStorage.config"; -import { v4 } from "uuid"; +import { awsConfig } from "../../objectStorage.config"; +import * as sharp from "sharp"; @Injectable() export class AwsService { @@ -18,12 +18,21 @@ export class AwsService { }); } - async uploadToS3(path: string, data: Buffer){ - await this.s3.putObject({ - Bucket: awsConfig.bucket, - Key: path, - Body: data, - }).promise(); + async uploadToS3(path: string, data: Buffer) { + + try { + const resizedBuffer = await sharp(data) + .resize(256, 256) + .toBuffer(); + + await this.s3.putObject({ + Bucket: awsConfig.bucket, + Key: path, + Body: resizedBuffer, + }).promise(); + } catch (error) { + throw error; + } } getImageURL(path: string) { diff --git a/be/src/main.ts b/be/src/main.ts index 9cb0142..f042556 100644 --- a/be/src/main.ts +++ b/be/src/main.ts @@ -3,9 +3,12 @@ import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; import { AppModule } from "./app.module"; import { TransformInterceptor } from "./response.interceptor"; import { HttpExceptionFilter } from "./error.filter"; +import * as fs from 'fs'; async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix("api"); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); @@ -20,4 +23,4 @@ async function bootstrap() { SwaggerModule.setup("api", app, document); await app.listen(8000); } -bootstrap(); +bootstrap(); \ No newline at end of file diff --git a/be/src/restaurant/entities/restaurant.entity.ts b/be/src/restaurant/entities/restaurant.entity.ts index 9bc236d..94f58ca 100644 --- a/be/src/restaurant/entities/restaurant.entity.ts +++ b/be/src/restaurant/entities/restaurant.entity.ts @@ -8,9 +8,8 @@ import { OneToMany, } from "typeorm"; import { Point } from "geojson"; -import { ReviewInfoEntity } from "src/review/entities/review.entity"; -import { User } from "src/user/entities/user.entity"; -import { UserRestaurantListEntity } from "src/user/entities/user.restaurantlist.entity"; +import { ReviewInfoEntity } from "../../review/entities/review.entity"; +import { UserRestaurantListEntity } from "../../user/entities/user.restaurantlist.entity"; @Unique("unique_name_location", ["name", "location"]) @Entity("restaurant") diff --git a/be/src/restaurant/restaurant.controller.ts b/be/src/restaurant/restaurant.controller.ts index c3a918e..21d74e7 100644 --- a/be/src/restaurant/restaurant.controller.ts +++ b/be/src/restaurant/restaurant.controller.ts @@ -19,13 +19,13 @@ import { import { RestaurantService } from "./restaurant.service"; import { SearchInfoDto } from "./dto/seachInfo.dto"; import { FilterInfoDto } from "./dto/filterInfo.dto"; -import { GetUser, TokenInfo } from "src/user/user.decorator"; +import { GetUser, TokenInfo } from "../user/user.decorator"; import { LocationDto } from "./dto/location.dto"; @ApiTags("Home") @Controller("restaurant") export class RestaurantController { - constructor(private restaurantService: RestaurantService) {} + constructor(private restaurantService: RestaurantService) { } @Get("autocomplete/:partialRestaurantName") @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() @@ -162,6 +162,12 @@ export class RestaurantController { type: String, description: "검색 반경", }) + @ApiQuery({ + name: "limit", + required: false, + type: String, + description: "응답 개수", + }) @ApiResponse({ status: 200, description: "전체 음식점 리스트 요청 성공", @@ -174,8 +180,9 @@ export class RestaurantController { @UsePipes(new ValidationPipe()) entireRestaurantList( @GetUser() tokenInfo: TokenInfo, - @Query() locationDto: LocationDto + @Query() locationDto: LocationDto, + @Query("limit") limit: string ) { - return this.restaurantService.entireRestaurantList(locationDto, tokenInfo); + return this.restaurantService.entireRestaurantList(locationDto, tokenInfo, limit); } } diff --git a/be/src/restaurant/restaurant.module.ts b/be/src/restaurant/restaurant.module.ts index 27c2f1a..a1adc5a 100644 --- a/be/src/restaurant/restaurant.module.ts +++ b/be/src/restaurant/restaurant.module.ts @@ -1,14 +1,15 @@ import { Module } from "@nestjs/common"; import { RestaurantController } from "./restaurant.controller"; -import { AuthModule } from "src/auth/auth.module"; +import { AuthModule } from "../auth/auth.module"; import { RestaurantService } from "./restaurant.service"; import { RestaurantRepository } from "./restaurant.repository"; -import { UserModule } from "src/user/user.module"; -import { ReviewModule } from "src/review/review.module"; +import { UserModule } from "../user/user.module"; +import { ReviewModule } from "../review/review.module"; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ - imports: [AuthModule, UserModule, ReviewModule], + imports: [AuthModule, UserModule, ReviewModule, ScheduleModule.forRoot(),], controllers: [RestaurantController], providers: [RestaurantService, RestaurantRepository], }) -export class RestaurantModule {} +export class RestaurantModule { } diff --git a/be/src/restaurant/restaurant.repository.ts b/be/src/restaurant/restaurant.repository.ts index 370a144..44ef917 100644 --- a/be/src/restaurant/restaurant.repository.ts +++ b/be/src/restaurant/restaurant.repository.ts @@ -2,12 +2,12 @@ import { DataSource, Repository, Like } from "typeorm"; import { Injectable } from "@nestjs/common"; import { RestaurantInfoEntity } from "./entities/restaurant.entity"; import { SearchInfoDto } from "./dto/seachInfo.dto"; -import { TokenInfo } from "src/user/user.decorator"; -import { UserRestaurantListEntity } from "src/user/entities/user.restaurantlist.entity"; +import { TokenInfo } from "../user/user.decorator"; +import { UserRestaurantListEntity } from "../user/entities/user.restaurantlist.entity"; import { FilterInfoDto } from "./dto/filterInfo.dto"; -import { User } from "src/user/entities/user.entity"; +import { User } from "../user/entities/user.entity"; import { LocationDto } from "./dto/location.dto"; -import { UserWishRestaurantListEntity } from "src/user/entities/user.wishrestaurantlist.entity"; +import { UserWishRestaurantListEntity } from "../user/entities/user.wishrestaurantlist.entity"; @Injectable() export class RestaurantRepository extends Repository { @@ -178,7 +178,9 @@ export class RestaurantRepository extends Repository { } } - async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo) { + async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo, limit: string = "40") { + const limitNum = parseInt(limit); + return this.createQueryBuilder("restaurant") .leftJoin( UserRestaurantListEntity, @@ -208,6 +210,7 @@ export class RestaurantRepository extends Repository { location, ST_GeomFromText('POINT(${locationDto.longitude} ${locationDto.latitude})', 4326)) < ${locationDto.radius}` ) + .limit(limitNum) .getRawMany(); } diff --git a/be/src/restaurant/restaurant.service.ts b/be/src/restaurant/restaurant.service.ts index cda63ac..1026ea1 100644 --- a/be/src/restaurant/restaurant.service.ts +++ b/be/src/restaurant/restaurant.service.ts @@ -4,29 +4,28 @@ import { SearchInfoDto } from "./dto/seachInfo.dto"; import * as proj4 from "proj4"; import axios from "axios"; import { FilterInfoDto } from "./dto/filterInfo.dto"; -import { TokenInfo } from "src/user/user.decorator"; -import { UserRepository } from "src/user/user.repository"; -import { ReviewRepository } from "src/review/review.repository"; +import { TokenInfo } from "../user/user.decorator"; +import { UserRepository } from "../user/user.repository"; +import { ReviewRepository } from "../review/review.repository"; import { LocationDto } from "./dto/location.dto"; +import { AwsService } from "../aws/aws.service"; +import { Cron } from "@nestjs/schedule"; -const key = "api키 입력하세요"; +const key = process.env.API_KEY; @Injectable() -export class RestaurantService implements OnModuleInit { - onModuleInit() { - //this.updateRestaurantsFromSeoulData(); - setInterval( - () => { - this.updateRestaurantsFromSeoulData(); - }, - 1000 * 60 * 60 * 24 * 3 - ); +export class RestaurantService { + + @Cron('0 0 2 * * *') + handleCron() { + this.updateRestaurantsFromSeoulData(); } constructor( private restaurantRepository: RestaurantRepository, private userRepository: UserRepository, - private reviewRepository: ReviewRepository + private reviewRepository: ReviewRepository, + private awsService: AwsService ) { } async searchRestaurant(searchInfoDto: SearchInfoDto, tokenInfo: TokenInfo) { @@ -43,6 +42,22 @@ export class RestaurantService implements OnModuleInit { }) .getCount(); + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + + restaurant.restaurant_reviewCnt = reviewCount; } @@ -58,6 +73,7 @@ export class RestaurantService implements OnModuleInit { const reviews = await this.reviewRepository .createQueryBuilder("review") .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: tokenInfo.id }) .select([ "review.id", "review.isCarVisit", @@ -68,17 +84,30 @@ export class RestaurantService implements OnModuleInit { "review.restroomCleanliness", "review.overallExperience", "user.nickName as reviewer", - "review.createdAt" + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike" ]) + .addSelect("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "likeCount") + .addSelect("COUNT(CASE WHEN reviewLike.isLike = false THEN 1 ELSE NULL END)", "dislikeCount") + .groupBy("review.id, user.nickName, user.profileImage, review.isCarVisit, review.transportationAccessibility, review.parkingArea, review.taste, review.service, review.restroomCleanliness, review.overallExperience, review.createdAt, review.reviewImage, reviewLike.isLike") .where("review.restaurant_id = :restaurantId", { restaurantId: restaurant.restaurant_id, }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") .getRawMany(); - restaurant.restaurant_reviewCnt = reviews.length; - restaurant.reviews = reviews.slice(0, 3); - + restaurant.restaurant_reviewCnt = reviews.length; + const reviewList = reviews.slice(0, 3); + reviewList.forEach((element) => { + if (element.review_reviewImage && element.review_reviewImage != "review/images/defaultImage.png") element.review_reviewImage = this.awsService.getImageURL(element.review_reviewImage); + else { element.review_reviewImage = "" } + if (element.user_profileImage) element.user_profileImage = this.awsService.getImageURL(element.user_profileImage); + + }) + restaurant.reviews = reviewList; return restaurant; } @@ -105,16 +134,33 @@ export class RestaurantService implements OnModuleInit { }) .getCount(); + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + + restaurant.restaurant_reviewCnt = reviewCount; } return restaurants; } - async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo) { + async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo, limit: string) { const restaurants = await this.restaurantRepository.entireRestaurantList( locationDto, - tokenInfo + tokenInfo, + limit ); for (const restaurant of restaurants) { @@ -124,7 +170,20 @@ export class RestaurantService implements OnModuleInit { restaurantId: restaurant.restaurant_id, }) .getCount(); - + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } restaurant.restaurant_reviewCnt = reviewCount; } diff --git a/be/src/review/dto/reviewInfo.dto.ts b/be/src/review/dto/reviewInfo.dto.ts index ee47c7b..3af8774 100644 --- a/be/src/review/dto/reviewInfo.dto.ts +++ b/be/src/review/dto/reviewInfo.dto.ts @@ -10,12 +10,14 @@ import { Max, Min, } from "class-validator"; +import { Transform } from 'class-transformer'; export class ReviewInfoDto { @ApiProperty({ example: "true", description: "The transportation for visiting", }) + @Transform(({ value }) => value === 'true') @IsBoolean() @IsNotEmpty() isCarVisit: boolean; @@ -24,8 +26,11 @@ export class ReviewInfoDto { example: "0", description: "transportation Accessibility for visiting", }) - @IsInt() @IsOptional() + @Transform(({ value }) => { + return !value ? null : parseInt(value); + }) + @IsInt() @Min(0) @Max(4) transportationAccessibility: number | null; @@ -34,13 +39,17 @@ export class ReviewInfoDto { example: "0", description: "condition of the restaurant's parking area", }) - @IsInt() @IsOptional() + @Transform(({ value }) => { + return !value ? null : parseInt(value); + }) + @IsInt() @Min(0) @Max(4) parkingArea: number | null; @ApiProperty({ example: "0", description: "The taste of the food" }) + @Transform(({ value }) => parseInt(value)) @IsInt() @IsNotEmpty() @Min(0) @@ -48,6 +57,7 @@ export class ReviewInfoDto { taste: number; @ApiProperty({ example: "0", description: "The service of the restaurant" }) + @Transform(({ value }) => parseInt(value)) @IsInt() @IsNotEmpty() @Min(0) @@ -58,6 +68,7 @@ export class ReviewInfoDto { example: "0", description: "The condition of the restaurant's restroom", }) + @Transform(({ value }) => parseInt(value)) @IsInt() @IsNotEmpty() @Min(0) diff --git a/be/src/review/entities/review.entity.ts b/be/src/review/entities/review.entity.ts index c4b579b..9b4b507 100644 --- a/be/src/review/entities/review.entity.ts +++ b/be/src/review/entities/review.entity.ts @@ -6,10 +6,12 @@ import { ManyToOne, JoinColumn, OneToOne, + OneToMany, } from "typeorm"; -import { User } from "src/user/entities/user.entity"; -import { RestaurantInfoEntity } from "src/restaurant/entities/restaurant.entity"; -import { UserRestaurantListEntity } from "src/user/entities/user.restaurantlist.entity"; +import { User } from "../../user/entities/user.entity"; +import { RestaurantInfoEntity } from "../../restaurant/entities/restaurant.entity"; +import { UserRestaurantListEntity } from "../../user/entities/user.restaurantlist.entity"; +import { ReviewLikeEntity } from "./review.like.entity"; @Entity("review") export class ReviewInfoEntity { @@ -37,9 +39,15 @@ export class ReviewInfoEntity { @Column({ type: "text" }) overallExperience: string; + @Column({ type: "text", nullable: true, default: null }) + reviewImage: string; + @CreateDateColumn({ name: "created_at" }) createdAt: Date; + @OneToMany(() => ReviewLikeEntity, reviewLike => reviewLike.review) + reviewLikes: ReviewLikeEntity[]; + @ManyToOne(() => User) @JoinColumn({ name: "user_id" }) user: User; diff --git a/be/src/review/entities/review.like.entity.ts b/be/src/review/entities/review.like.entity.ts new file mode 100644 index 0000000..e502460 --- /dev/null +++ b/be/src/review/entities/review.like.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + DeleteDateColumn, +} from "typeorm"; +import { User } from "../../user/entities/user.entity"; +import { ReviewInfoEntity } from "./review.entity"; + +@Entity("reviewLike") +export class ReviewLikeEntity { + @PrimaryColumn({ name: "review_id" }) + reviewId: number; + + @PrimaryColumn({ name: "user_id" }) + userId: number; + + @Column({ name: "is_like" }) + isLike: boolean; + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date; + + @DeleteDateColumn({ name: "deleted_at", nullable: true, type: "timestamp" }) + deletedAt: Date | null; + + @ManyToOne(() => ReviewInfoEntity) + @JoinColumn({ name: "review_id", referencedColumnName: "id" }) + review: ReviewInfoEntity; + + @ManyToOne(() => User) + @JoinColumn({ name: "user_id", referencedColumnName: "id" }) + user: User; +} diff --git a/be/src/review/review.controller.spec.ts b/be/src/review/review.controller.spec.ts new file mode 100644 index 0000000..b1b0458 --- /dev/null +++ b/be/src/review/review.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewController } from './review.controller'; + +describe('ReviewController', () => { + let controller: ReviewController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReviewController], + }).compile(); + + controller = module.get(ReviewController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/be/src/review/review.controller.ts b/be/src/review/review.controller.ts new file mode 100644 index 0000000..2e79c27 --- /dev/null +++ b/be/src/review/review.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, Param, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ReviewService } from './review.service'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { GetUser, TokenInfo } from '../user/user.decorator'; +import { SortInfoDto } from '../utils/sortInfo.dto'; + +@ApiTags("Review") +@Controller('review') +export class ReviewController { + constructor(private reviewService: ReviewService) { } + + @Get("/:restaurantId") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiQuery({ name: 'sort', required: false, description: '정렬 기준' }) + @ApiQuery({ name: 'page', required: false, description: '페이지 번호' }) + @ApiQuery({ name: 'limit', required: false, description: '페이지 당 항목 수' }) + + @ApiOperation({ summary: "리뷰 정렬 요청" }) + @ApiResponse({ status: 200, description: "리뷰 정렬 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + @ApiResponse({ status: 400, description: "부적절한 요청" }) + @UsePipes(new ValidationPipe()) + async getSortedReviews( + @GetUser() tokenInfo: TokenInfo, + @Param('restaurantId') restaurantId: string, + @Query() getSortedReviewsDto: SortInfoDto + ) { + const restaurantNumber = parseInt(restaurantId, 10); + return await this.reviewService.getSortedReviews(tokenInfo, restaurantNumber, getSortedReviewsDto); + } + + @Post("/:reviewId/like") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "리뷰 좋아요 요청" }) + @ApiResponse({ status: 200, description: "리뷰 좋아요 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + @ApiResponse({ status: 400, description: "부적절한 요청" }) + async reviewLike( + @GetUser() tokenInfo: TokenInfo, + @Param("reviewId") reviewid: number + ) { + return await this.reviewService.reviewLike( + tokenInfo, + reviewid + ); + } + + @Post("/:reviewId/unlike") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "리뷰 싫어요 요청" }) + @ApiResponse({ status: 200, description: "리뷰 싫어요 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + @ApiResponse({ status: 400, description: "부적절한 요청" }) + async reviewUnLike( + @GetUser() tokenInfo: TokenInfo, + @Param("reviewId") reviewid: number + ) { + return await this.reviewService.reviewUnLike( + tokenInfo, + reviewid + ); + } +} diff --git a/be/src/review/review.like.repository.ts b/be/src/review/review.like.repository.ts new file mode 100644 index 0000000..d49ad05 --- /dev/null +++ b/be/src/review/review.like.repository.ts @@ -0,0 +1,10 @@ +import { DataSource, IsNull, Repository, Not } from "typeorm"; +import { ConflictException, Injectable } from "@nestjs/common"; +import { ReviewLikeEntity } from "./entities/review.like.entity"; + +@Injectable() +export class ReviewLikeRepository extends Repository { + constructor(private dataSource: DataSource) { + super(ReviewLikeEntity, dataSource.createEntityManager()); + } +} diff --git a/be/src/review/review.module.ts b/be/src/review/review.module.ts index dc11474..d1b2a33 100644 --- a/be/src/review/review.module.ts +++ b/be/src/review/review.module.ts @@ -1,8 +1,12 @@ import { Module } from "@nestjs/common"; import { ReviewRepository } from "./review.repository"; +import { ReviewController } from "./review.controller"; +import { ReviewService } from "./review.service"; +import { ReviewLikeRepository } from "./review.like.repository"; @Module({ - providers: [ReviewRepository], + controllers: [ReviewController], + providers: [ReviewRepository, ReviewLikeRepository, ReviewService], exports: [ReviewRepository], }) -export class ReviewModule {} +export class ReviewModule { } diff --git a/be/src/review/review.repository.ts b/be/src/review/review.repository.ts index f8b6464..f5fd07d 100644 --- a/be/src/review/review.repository.ts +++ b/be/src/review/review.repository.ts @@ -1,10 +1,139 @@ import { DataSource, IsNull, Repository, Not } from "typeorm"; import { ConflictException, Injectable } from "@nestjs/common"; import { ReviewInfoEntity } from "./entities/review.entity"; +import { TokenInfo } from "../user/user.decorator"; +import { SortInfoDto } from "../utils/sortInfo.dto"; @Injectable() export class ReviewRepository extends Repository { constructor(private dataSource: DataSource) { super(ReviewInfoEntity, dataSource.createEntityManager()); } + + async getReviewIdsWithLikes(sort: string) { + if (sort === "ASC") { + return await this + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select("review.id", "reviewId") + .addSelect("COUNT(reviewLike.isLike)", "likeCount") + .groupBy("review.id") + .orderBy("COUNT(CASE WHEN reviewLike.isLike = false THEN 1 ELSE NULL END)", "DESC") + .getRawMany(); + } + else { + return await this + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select("review.id", "reviewId") + .addSelect("COUNT(reviewLike.isLike)", "likeCount") + .groupBy("review.id") + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawMany(); + } + } + async getSortedReviews(getSortedReviewsDto: SortInfoDto, restaurantId: number, id: TokenInfo["id"], sortedReviewIds: number[]) { + const pageNumber = parseInt(getSortedReviewsDto.page as unknown as string) || 1; + const limitNumber = parseInt(getSortedReviewsDto.limit as unknown as string) || 10; + const skipNumber = (pageNumber - 1) * limitNumber; + if (getSortedReviewsDto && getSortedReviewsDto.sort === "TIME_DESC") { + const items = await this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: id }) + .select([ + "review.id", + "review.isCarVisit", + "review.transportationAccessibility", + "review.parkingArea", + "review.taste", + "review.service", + "review.restroomCleanliness", + "review.overallExperience", + "user.nickName as reviewer", + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike", + ]) + .where("review.restaurant_id = :restaurantId", { + restaurantId: restaurantId, + }) + .orderBy("review.createdAt", "DESC") + .offset(skipNumber) + .limit(limitNumber + 1) + .getRawMany(); + + const hasNext = items.length > limitNumber; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { hasNext, items: resultItems }; + } + else if (getSortedReviewsDto && getSortedReviewsDto.sort === "TIME_ASC") { + const items = await this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: id }) + .select([ + "review.id", + "review.isCarVisit", + "review.transportationAccessibility", + "review.parkingArea", + "review.taste", + "review.service", + "review.restroomCleanliness", + "review.overallExperience", + "user.nickName as reviewer", + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike", + ]) + .where("review.restaurant_id = :restaurantId", { + restaurantId: restaurantId, + }) + .orderBy("review.createdAt", "ASC") + .offset(skipNumber) + .limit(limitNumber + 1) + .getRawMany(); + + const hasNext = items.length > limitNumber; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { hasNext, items: resultItems }; + } + else { + if (sortedReviewIds.length) { + const items = await this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: id }) + .select([ + "review.id", + "review.isCarVisit", + "review.transportationAccessibility", + "review.parkingArea", + "review.taste", + "review.service", + "review.restroomCleanliness", + "review.overallExperience", + "user.nickName as reviewer", + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike", + ]) + .where("review.id IN (:...sortedReviewIds)", { sortedReviewIds }) + .andWhere("review.restaurant_id = :restaurantId", { restaurantId: restaurantId }) + .offset(skipNumber) + .limit(limitNumber + 1) + .getRawMany(); + const sortedItems = sortedReviewIds + .map(id => items.find(item => item.review_id === id)) + .filter(item => item !== undefined); + + const hasNext = sortedItems.length > limitNumber; + const resultItems = hasNext ? sortedItems.slice(0, -1) : sortedItems; + + return { hasNext, items: resultItems }; + } + } + } } diff --git a/be/src/review/review.service.spec.ts b/be/src/review/review.service.spec.ts new file mode 100644 index 0000000..b0fc157 --- /dev/null +++ b/be/src/review/review.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewService } from './review.service'; + +describe('ReviewService', () => { + let service: ReviewService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReviewService], + }).compile(); + + service = module.get(ReviewService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/be/src/review/review.service.ts b/be/src/review/review.service.ts new file mode 100644 index 0000000..3868291 --- /dev/null +++ b/be/src/review/review.service.ts @@ -0,0 +1,87 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ReviewRepository } from './review.repository'; +import { TokenInfo } from '../user/user.decorator'; +import { ReviewLikeRepository } from './review.like.repository'; +import { SortInfoDto } from '../utils/sortInfo.dto'; +import { AwsService } from '../aws/aws.service'; + +@Injectable() +export class ReviewService { + constructor( + private reviewRepository: ReviewRepository, + private reviewLikeRepository: ReviewLikeRepository, + private awsService: AwsService + ) { } + + async getSortedReviews(tokenInfo: TokenInfo, restaurantId: number, getSortedReviewsDto: SortInfoDto) { + let sortedReviewIds; + if (getSortedReviewsDto.sort === "REVIEW_ASC") { + const getReviewIdsWithLikes = await this.reviewRepository.getReviewIdsWithLikes("ASC"); + sortedReviewIds = getReviewIdsWithLikes.map(rl => rl.reviewId); + } + else { + const getReviewIdsWithLikes = await this.reviewRepository.getReviewIdsWithLikes("DESC"); + sortedReviewIds = getReviewIdsWithLikes.map(rl => rl.reviewId); + } + + const reviews = await this.reviewRepository.getSortedReviews(getSortedReviewsDto, restaurantId, tokenInfo.id, sortedReviewIds); + for (const review of reviews.items) { + const likeCounts = await this.reviewLikeRepository.createQueryBuilder("reviewLike") + .select("reviewLike.isLike", "status") + .addSelect("COUNT(*)", "count") + .where("reviewLike.reviewId = :reviewId", { reviewId: review.review_id }) + .groupBy("reviewLike.isLike") + .getRawMany(); + if (review.user_profileImage) review.user_profileImage = this.awsService.getImageURL(review.user_profileImage); + if (review.review_reviewImage && review.review_reviewImage != "review/images/defaultImage.png") review.review_reviewImage = this.awsService.getImageURL(review.review_reviewImage); + else { review.review_reviewImage = "" } + review.likeCount = Number(likeCounts.find(lc => lc.status === true)?.count) || 0; + review.dislikeCount = Number(likeCounts.find(lc => lc.status === false)?.count) || 0; + } + + return reviews; + } + + async reviewLike(tokenInfo: TokenInfo, reviewId: number) { + const existingLike = await this.reviewLikeRepository.findOne({ + where: { userId: tokenInfo.id, reviewId: reviewId } + }); + + if (existingLike && existingLike.isLike) { + await this.reviewLikeRepository.remove(existingLike); + } else { + const entity = this.reviewLikeRepository.create({ + userId: tokenInfo.id, + reviewId: reviewId, + isLike: true, + }); + try { + await this.reviewLikeRepository.upsert(entity, ['userId', 'reviewId']); + } catch (err) { + throw new BadRequestException(); + } + } + } + + async reviewUnLike(tokenInfo: TokenInfo, reviewId: number) { + const existingLike = await this.reviewLikeRepository.findOne({ + where: { userId: tokenInfo.id, reviewId: reviewId } + }); + + if (existingLike && !existingLike.isLike) { + await this.reviewLikeRepository.remove(existingLike); + } else { + const entity = this.reviewLikeRepository.create({ + userId: tokenInfo.id, + reviewId: reviewId, + isLike: false, + }); + try { + await this.reviewLikeRepository.upsert(entity, ['userId', 'reviewId']); + } catch (err) { + throw new BadRequestException(); + } + } + } + +} diff --git a/be/src/user/dto/userInfo.dto.ts b/be/src/user/dto/userInfo.dto.ts index f76dff3..2b63c5d 100644 --- a/be/src/user/dto/userInfo.dto.ts +++ b/be/src/user/dto/userInfo.dto.ts @@ -9,7 +9,7 @@ import { IsOptional, IsInstance, } from "class-validator"; -import { isArrayBuffer } from "util/types"; +import { Transform } from 'class-transformer'; export class UserInfoDto { @ApiProperty({ @@ -21,7 +21,7 @@ export class UserInfoDto { @MaxLength(50) email: string; - @ApiProperty({ example: "1234", description: "The password of the user" }) + @ApiProperty({ example: "1234", description: "The password of the user", required: false }) @IsString() @IsOptional() @MaxLength(50) @@ -55,16 +55,8 @@ export class UserInfoDto { example: true, description: "The gender of the user. true is male, false is female", }) + @Transform(({ value }) => value === 'true') @IsBoolean() @IsNotEmpty() isMale: boolean; - - @ApiProperty({ - example: "", - description: "The profile image of the user", - type: 'string', - format: 'binary' - }) - @IsOptional() - profileImage?: Buffer; } diff --git a/be/src/user/entities/user.entity.ts b/be/src/user/entities/user.entity.ts index 90c4b27..d7145d7 100644 --- a/be/src/user/entities/user.entity.ts +++ b/be/src/user/entities/user.entity.ts @@ -9,7 +9,7 @@ import { } from "typeorm"; import { FollowEntity } from "./user.followList.entity"; import { UserRestaurantListEntity } from "./user.restaurantlist.entity"; -import { ReviewInfoEntity } from "src/review/entities/review.entity"; +import { ReviewInfoEntity } from "../../review/entities/review.entity"; @Entity() export class User { @@ -37,7 +37,7 @@ export class User { @Column({ type: "varchar", length: 20, nullable: true }) provider: string | null; - @Column({ type: "text", default : "profile/images/defaultprofile.png"}) + @Column({ type: "text", default: "profile/images/defaultprofile.png" }) profileImage: string; @CreateDateColumn({ type: "timestamp" }) @@ -55,7 +55,7 @@ export class User { @OneToMany(() => FollowEntity, (follow) => follow.followedUserId) follower: FollowEntity[]; - @OneToMany(() => UserRestaurantListEntity, (list) => list.userId) + @OneToMany(() => UserRestaurantListEntity, (list) => list.user) restaurant: UserRestaurantListEntity[]; @OneToMany(() => ReviewInfoEntity, (review) => review.user) diff --git a/be/src/user/user.controller.ts b/be/src/user/user.controller.ts index f7a37d4..5631601 100644 --- a/be/src/user/user.controller.ts +++ b/be/src/user/user.controller.ts @@ -10,9 +10,14 @@ import { ValidationPipe, UseGuards, Query, + UseInterceptors, + UploadedFile, + BadRequestException, } from "@nestjs/common"; import { ApiBearerAuth, + ApiBody, + ApiConsumes, ApiOperation, ApiParam, ApiQuery, @@ -24,9 +29,19 @@ import { UserService } from "./user.service"; import { GetUser, TokenInfo } from "./user.decorator"; import { AuthGuard } from "@nestjs/passport"; import { SearchInfoDto } from "../restaurant/dto/seachInfo.dto"; -import { LocationDto } from "src/restaurant/dto/location.dto"; -import { ReviewInfoDto } from "src/review/dto/reviewInfo.dto"; +import { LocationDto } from "../restaurant/dto/location.dto"; +import { ReviewInfoDto } from "../review/dto/reviewInfo.dto"; import { ParseArrayPipe } from "../utils/parsearraypipe"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { memoryStorage } from 'multer'; +import { plainToClass } from "class-transformer"; +import { validate } from "class-validator"; +import { SortInfoDto } from "../utils/sortInfo.dto"; + +const multerOptions = { + storage: memoryStorage(), +}; + @Controller("user") export class UserController { @@ -155,16 +170,27 @@ export class UserController { type: String, description: "검색 반경", }) + @ApiQuery({ + name: "sort", + required: false, + type: String, + description: "선택된 필터", + }) + @ApiQuery({ name: 'page', required: false, description: '페이지 번호' }) + @ApiQuery({ name: 'limit', required: false, description: '페이지 당 항목 수' }) @ApiResponse({ status: 200, description: "내 맛집 리스트 정보 요청 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) + @UsePipes(new ValidationPipe()) async getMyRestaurantListInfo( @Query() locationDto: LocationDto, + @Query() sortInfoDto: SortInfoDto, @GetUser() tokenInfo: TokenInfo ) { const searchInfoDto = new SearchInfoDto("", locationDto); return await this.userService.getMyRestaurantListInfo( searchInfoDto, + sortInfoDto, tokenInfo ); } @@ -174,10 +200,40 @@ export class UserController { @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() @ApiOperation({ summary: "내 위시 맛집 리스트 정보 가져오기" }) + @ApiQuery({ + name: "sort", + required: false, + type: String, + description: "선택된 필터", + }) + @ApiQuery({ name: 'page', required: false, description: '페이지 번호' }) + @ApiQuery({ name: 'limit', required: false, description: '페이지 당 항목 수' }) @ApiResponse({ status: 200, description: "내 맛집 리스트 정보 요청 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) - async getMyWishRestaurantListInfo(@GetUser() tokenInfo: TokenInfo) { - return await this.userService.getMyWishRestaurantListInfo(tokenInfo); + async getMyWishRestaurantListInfo(@GetUser() tokenInfo: TokenInfo, @Query() sortInfoDto: SortInfoDto) { + return await this.userService.getMyWishRestaurantListInfo(tokenInfo, sortInfoDto); + } + + @ApiTags("Home") + @Get("/state/wish-restaurant") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "위시 맛집리스트 포함 여부 정보 가져오기" }) + @ApiResponse({ status: 200, description: "위시 맛집리스트 포함 여부 정보 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + async getStateIsWish(@GetUser() tokenInfo: TokenInfo, @Query("restaurantid") restaurantid: number) { + return await this.userService.getStateIsWish(tokenInfo, restaurantid); + } + + @ApiTags("Home") + @Get("/recommend-food") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "추천 음식 정보 가져오기" }) + @ApiResponse({ status: 200, description: "추천 음식 정보 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + async getRecommendFood(@GetUser() tokenInfo: TokenInfo) { + return await this.userService.getRecommendFood(tokenInfo); } @ApiTags("Follow/Following", "Home") @@ -189,7 +245,7 @@ export class UserController { @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) async getMyFollowListInfo(@GetUser() tokenInfo: TokenInfo) { - return await this.userService.getMyFollowListInfo(tokenInfo); + return this.userService.getMyFollowListInfo(tokenInfo); } @ApiTags("Follow/Following") @@ -212,17 +268,41 @@ export class UserController { @ApiResponse({ status: 200, description: "추천 사용자 정보 요청 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) async getRecommendUserListInfo(@GetUser() tokenInfo: TokenInfo) { - return await this.userService.getRecommendUserListInfo(tokenInfo); + return this.userService.getRecommendUserListInfo(tokenInfo); } @ApiTags("Signup") @Post() + @UseInterceptors(FileInterceptor('profileImage', multerOptions)) @ApiOperation({ summary: "유저 회원가입" }) @ApiResponse({ status: 200, description: "회원가입 성공" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) - @UsePipes(new ValidationPipe()) - async singup(@Body() userInfoDto: UserInfoDto) { - return await this.userService.signup(userInfoDto); + @ApiBody({ + schema: { + type: 'object', + description: "회원가입", + required: ['email', 'provider', 'nickName', 'region', 'birthdate', 'isMale'], + properties: { + email: { type: 'string', example: 'user@example.com', description: 'The email of the user' }, + password: { type: 'string', example: '1234', description: 'The password of the user' }, + provider: { type: 'string', example: 'naver', description: 'The provider of the user' }, + nickName: { type: 'string', example: 'test', description: 'The nickname of the user' }, + region: { type: 'string', example: '강동구', description: 'The region of the user' }, + birthdate: { type: 'string', example: '1234/56/78', description: 'The birth of the user' }, + isMale: { type: 'boolean', example: true, description: 'The gender of the user. true is male, false is female' }, + profileImage: { type: 'string', format: 'binary', description: 'The profile image of the user' }, + }, + }, + }) + @ApiConsumes('multipart/form-data') + async singup(@Body() body, @UploadedFile() file: Express.Multer.File) { + const userInfoDto = plainToClass(UserInfoDto, body); + const errors = await validate(userInfoDto); + if (errors.length > 0) { + console.log(errors); + throw new BadRequestException(errors); + } + return await this.userService.signup(file, userInfoDto); } @ApiTags("Follow/Following") @@ -249,6 +329,7 @@ export class UserController { @ApiTags("RestaurantList") @Post("/restaurant/:restaurantid") + @UseInterceptors(FileInterceptor('reviewImage', multerOptions)) @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() @ApiOperation({ summary: "내 맛집 리스트에 등록하기" }) @@ -258,19 +339,76 @@ export class UserController { description: "음식점 id", type: Number, }) + @ApiBody({ + schema: { + type: 'object', + description: "리뷰 등록하기", + required: ['isCarVisit', 'taste', 'service', 'restroomCleanliness', 'overallExperience'], + properties: { + isCarVisit: { type: 'boolean', example: true, description: 'The transportation for visiting' }, + transportationAccessibility: { + type: 'integer', + example: 0, + description: 'Transportation accessibility for visiting', + minimum: 0, + maximum: 4 + }, + parkingArea: { + type: 'integer', + example: 0, + description: "Condition of the restaurant's parking area", + minimum: 0, + maximum: 4 + }, + taste: { + type: 'integer', + example: 0, + description: 'The taste of the food', + minimum: 0, + maximum: 4 + }, + service: { + type: 'integer', + example: 0, + description: 'The service of the restaurant', + minimum: 0, + maximum: 4 + }, + restroomCleanliness: { + type: 'integer', + example: 0, + description: "The condition of the restaurant's restroom", + minimum: 0, + maximum: 4 + }, + overallExperience: { + type: 'string', + example: '20자 이상 작성하기', + description: 'The overall experience about the restaurant', + minLength: 20 + }, + reviewImage: { type: 'string', format: 'binary', description: 'The image of food' }, + }, + }, + }) @ApiResponse({ status: 200, description: "맛집리스트 등록 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) - @UsePipes(new ValidationPipe()) + @ApiConsumes('multipart/form-data') async addRestaurantToNebob( - @Body() reviewInfoDto: ReviewInfoDto, + @Body() body, @GetUser() tokenInfo: TokenInfo, - @Param("restaurantid") restaurantid: number + @Param("restaurantid") restaurantid: number, + @UploadedFile() file: Express.Multer.File ) { + const reviewInfoDto = plainToClass(ReviewInfoDto, body); + const errors = await validate(reviewInfoDto); + if (errors.length > 0) throw new BadRequestException(errors); return await this.userService.addRestaurantToNebob( reviewInfoDto, tokenInfo, - restaurantid + restaurantid, + file ); } @@ -382,17 +520,49 @@ export class UserController { @ApiTags("Mypage") @Put() + @UseInterceptors(FileInterceptor('profileImage', multerOptions)) @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() + @ApiBody({ + schema: { + type: 'object', + description: "회원정보 수정", + required: ['email', 'provider', 'nickName', 'region', 'birthdate', 'isMale'], + properties: { + email: { type: 'string', example: 'user@example.com', description: 'The email of the user' }, + password: { type: 'string', example: '1234', description: 'The password of the user' }, + provider: { type: 'string', example: 'naver', description: 'The provider of the user' }, + nickName: { type: 'string', example: 'test', description: 'The nickname of the user' }, + region: { type: 'string', example: '강동구', description: 'The region of the user' }, + birthdate: { type: 'string', example: '1234/56/78', description: 'The birth of the user' }, + isMale: { type: 'boolean', example: true, description: 'The gender of the user. true is male, false is female' }, + profileImage: { type: 'string', format: 'binary', description: 'The profile image of the user' }, + isImageChanged: { type: 'boolean', example: true, description: 'The Boolean Value of ProfileImageChanged' } + }, + }, + }) @ApiOperation({ summary: "유저 회원정보 수정" }) @ApiResponse({ status: 200, description: "회원정보 수정 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) - @UsePipes(new ValidationPipe()) + @ApiConsumes('multipart/form-data') async updateMypageUserInfo( + @UploadedFile() file: Express.Multer.File, @GetUser() tokenInfo: TokenInfo, - @Body() userInfoDto: UserInfoDto + @Body() body ) { - return await this.userService.updateMypageUserInfo(tokenInfo, userInfoDto); + const userInfoDto = plainToClass(UserInfoDto, body); + if (body.isImageChanged === "true" || body.isImageChanged === true) { + body.isImageChanged = true; + } else if (body.isImageChanged === "false" || body.isImageChanged === false) { + body.isImageChanged = false; + } else { + throw new BadRequestException(); + } + + + const errors = await validate(userInfoDto); + if (errors.length > 0) throw new BadRequestException(errors); + return await this.userService.updateMypageUserInfo(file, tokenInfo, userInfoDto, body.isImageChanged); } } diff --git a/be/src/user/user.repository.ts b/be/src/user/user.repository.ts index 47138c3..d6cc13d 100644 --- a/be/src/user/user.repository.ts +++ b/be/src/user/user.repository.ts @@ -12,15 +12,8 @@ export class UserRepository extends Repository { constructor(private dataSource: DataSource) { super(User, dataSource.createEntityManager()); } - async createUser(userentity: User): Promise { - try { - await this.save(userentity); - } catch (err) { - if (err.code === "23505") { - throw new ConflictException("Duplicated Value"); - } - } - return; + async createUser(userentity: User) { + return await this.save(userentity); } async getNickNameAvailability(nickName: UserInfoDto["nickName"]) { const user = await this.findOne({ @@ -52,7 +45,7 @@ export class UserRepository extends Repository { } async getUsersInfo(targetInfoIds: number[]) { const userInfo = await this.find({ - select: ["nickName", "region"], + select: ["nickName", "region", "profileImage"], where: { id: In(targetInfoIds) }, }); return userInfo; @@ -72,15 +65,42 @@ export class UserRepository extends Repository { }); return { userInfo: userInfo }; } - async getRecommendUserListInfo(idList: number[]) { + + async getRecommendUserListInfo(idList: number[], id: number) { + const curUser = await this.findOne({ + select: ["id", "region"], + where: { id: id }, + }); + + const myRestaurants = await this.createQueryBuilder("user") + .leftJoinAndSelect("user.restaurant", "userRestaurant") + .where("user.id = :id", { id }) + .select("userRestaurant.restaurantId") + .getRawMany(); + + const myFavRestaurants = myRestaurants.map( + (r) => r.userRestaurant_restaurant_id + ); + const userInfo = await this.createQueryBuilder("user") - .select(["user.nickName", "user.region"]) + .leftJoin("user.restaurant", "userRestaurant") + .select([ + "user.nickName", + "user.region", + "user.profileImage", + 'SUM(CASE WHEN userRestaurant.restaurantId IN (:...myFavRestaurants) THEN 1 ELSE 0 END) AS "commonRestaurant"', + ]) + .setParameter("myFavRestaurants", myFavRestaurants) .where("user.id NOT IN (:...idList)", { idList }) - .orderBy("RANDOM()") - .limit(2) - .getMany(); + .andWhere("user.region = :yourRegion", { yourRegion: curUser.region }) + .groupBy("user.id") + .orderBy("\"commonRestaurant\"", "DESC") + .limit(10) + .getRawMany(); + return userInfo; } + async logout(id: number) { return {}; } @@ -115,11 +135,11 @@ export class UserRepository extends Repository { region: userEntity["region"], provider: userEntity["provider"], password: userEntity["password"], - profileImage : userEntity["profileImage"] + profileImage: userEntity["profileImage"], }; if (!isEmailDuplicate) { - updateObject["email"] =userEntity["email"]; + updateObject["email"] = userEntity["email"]; } if (!isNickNameDuplicate) { updateObject["nickName"] = userEntity["nickName"]; diff --git a/be/src/user/user.restaurantList.repository.ts b/be/src/user/user.restaurantList.repository.ts index 9d0c134..1c5ade7 100644 --- a/be/src/user/user.restaurantList.repository.ts +++ b/be/src/user/user.restaurantList.repository.ts @@ -2,9 +2,10 @@ import { DataSource, IsNull, Repository, Not } from "typeorm"; import { ConflictException, Injectable } from "@nestjs/common"; import { UserRestaurantListEntity } from "./entities/user.restaurantlist.entity"; import { TokenInfo } from "./user.decorator"; -import { SearchInfoDto } from "src/restaurant/dto/seachInfo.dto"; -import { ReviewInfoEntity } from "src/review/entities/review.entity"; +import { SearchInfoDto } from "../restaurant/dto/seachInfo.dto"; +import { ReviewInfoEntity } from "../review/entities/review.entity"; import { UserWishRestaurantListEntity } from "./entities/user.wishrestaurantlist.entity"; +import { SortInfoDto } from "../utils/sortInfo.dto"; @Injectable() export class UserRestaurantListRepository extends Repository { @@ -74,10 +75,11 @@ export class UserRestaurantListRepository extends Repository sortInfoDto.limit; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { + hasNext, + items: resultItems, + } + + } + } + async getMyFavoriteFoodCategory(id: TokenInfo['id'], region) { + const categoryCounts = await this.createQueryBuilder("userRestaurantList") + .select("restaurant.category", "category") + .addSelect("COUNT(restaurant.category)", "count") + .innerJoin("userRestaurantList.restaurant", "restaurant") + .where("userRestaurantList.userId = :id", { id }) + .groupBy("restaurant.category") + .getRawMany(); + + + if (categoryCounts.length) { + const favoriteCategory = categoryCounts.reduce((a, b) => a.count > b.count ? a : b).category; + + const subQuery = await this.createQueryBuilder() + .select("DISTINCT(userRestaurantListSub.restaurantId)", "restaurantId") + .from(UserRestaurantListEntity, "userRestaurantListSub") + .where("userRestaurantListSub.userId = :id", { id }) .getRawMany(); + + const restaurantIds = subQuery.map(item => item.restaurantId); + + const result = await this + .createQueryBuilder("userRestaurantList") + .leftJoinAndSelect("userRestaurantList.restaurant", "restaurant") + .select(["restaurant.id", "restaurant.name", "restaurant.category"]) + .where("restaurant.category = :category", { category: favoriteCategory }) + .andWhere("restaurant.address LIKE :region", { region: `%${region.region}%` }) + .andWhere("userRestaurantList.restaurantId NOT IN (:...restaurantIds)", { restaurantIds: restaurantIds }) + .groupBy("restaurant.id") + .getRawMany(); + + if (result.length > 0) { + let recommendedRestaurants = []; + let usedIndexes = new Set(); + + for (let i = 0; i < Math.min(3, result.length); i++) { + let randomIndex; + do { + randomIndex = Math.floor(Math.random() * result.length); + } while (usedIndexes.has(randomIndex)); + + usedIndexes.add(randomIndex); + recommendedRestaurants.push(result[randomIndex]); + } + return recommendedRestaurants; + } + } + else { + const result = await this + .createQueryBuilder("userRestaurantList") + .leftJoinAndSelect("userRestaurantList.restaurant", "restaurant") + .select(["restaurant.id", "restaurant.name", "restaurant.category"]) + .andWhere("restaurant.address LIKE :region", { region: `%${region.region}%` }) + .groupBy("restaurant.id") + .getRawMany(); + + if (result.length > 0) { + let recommendedRestaurants = []; + let usedIndexes = new Set(); + + for (let i = 0; i < Math.min(3, result.length); i++) { + let randomIndex; + do { + randomIndex = Math.floor(Math.random() * result.length); + } while (usedIndexes.has(randomIndex)); + + usedIndexes.add(randomIndex); + recommendedRestaurants.push(result[randomIndex]); + } + return recommendedRestaurants; + } } + return []; } } diff --git a/be/src/user/user.service.ts b/be/src/user/user.service.ts index d823193..5ab7881 100644 --- a/be/src/user/user.service.ts +++ b/be/src/user/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, UploadedFile } from "@nestjs/common"; import { UserInfoDto } from "./dto/userInfo.dto"; import { InjectRepository } from "@nestjs/typeorm"; import { UserRepository } from "./user.repository"; @@ -8,14 +8,16 @@ import { SearchInfoDto } from "../restaurant/dto/seachInfo.dto"; import { UserRestaurantListRepository } from "./user.restaurantList.repository"; import { UserFollowListRepository } from "./user.followList.repository"; import { Equal, In, Like, Not } from "typeorm"; -import { BadRequestException } from "@nestjs/common/exceptions"; -import { ReviewInfoDto } from "src/review/dto/reviewInfo.dto"; -import { ReviewRepository } from "src/review/review.repository"; +import { BadRequestException, ConflictException } from "@nestjs/common/exceptions"; +import { ReviewInfoDto } from "../review/dto/reviewInfo.dto"; +import { ReviewRepository } from "../review/review.repository"; import { UserWishRestaurantListRepository } from "./user.wishrestaurantList.repository"; -import { AwsService } from "src/aws/aws.service"; +import { AwsService } from "../aws/aws.service"; import { v4 } from "uuid"; import { User } from "./entities/user.entity"; -import { RestaurantInfoEntity } from "src/restaurant/entities/restaurant.entity"; +import { RestaurantInfoEntity } from "../restaurant/entities/restaurant.entity"; +import { AuthService } from "../auth/auth.service"; +import { SortInfoDto } from "../utils/sortInfo.dto"; @Injectable() export class UserService { @@ -26,24 +28,39 @@ export class UserService { private userFollowListRepositoy: UserFollowListRepository, private reviewRepository: ReviewRepository, private userWishRestaurantListRepository: UserWishRestaurantListRepository, - private awsService: AwsService - ) {} - async signup(userInfoDto: UserInfoDto) { - userInfoDto.password = await hashPassword(userInfoDto.password); + private awsService: AwsService, + private authService: AuthService, + ) { } + async signup(@UploadedFile() file: Express.Multer.File, userInfoDto: UserInfoDto) { + if (userInfoDto.password) userInfoDto.password = await hashPassword(userInfoDto.password); + let profileImage; + + if (file) { + const uuid = v4(); + profileImage = `profile/images/${uuid}.png`; + } else { + profileImage = "profile/images/defaultprofile.png"; + } + const user = { ...userInfoDto, - profileImage: "profile/images/defaultprofile.png", + profileImage: profileImage }; - if (userInfoDto.profileImage) { - const uuid = v4(); - user.profileImage = `profile/images/${uuid}.png`; - } + try { + const newUser = this.usersRepository.create(user); + const result = await this.usersRepository.createUser(newUser); + if (file) { + await this.awsService.uploadToS3(profileImage, file.buffer); + } + return this.authService.createTokens(result.id); + } catch (error) { + if (error.code === "23505") { + throw new ConflictException("Duplicated Value"); + } + } + - const newUser = this.usersRepository.create(user); - await this.usersRepository.createUser(newUser); - if (userInfoDto.profileImage)this.awsService.uploadToS3(user.profileImage, userInfoDto.profileImage); - return; } async getNickNameAvailability(nickName: UserInfoDto["nickName"]) { return await this.usersRepository.getNickNameAvailability(nickName); @@ -77,7 +94,8 @@ export class UserService { targetInfo.id, tokenInfo.id ); - if ( restaurantList )result["restaurants"] = restaurantList; + if (restaurantList) result["restaurants"] = restaurantList; + else result["restaurants"] = []; result.profileImage = this.awsService.getImageURL(result.profileImage); return result; } catch (err) { @@ -91,15 +109,21 @@ export class UserService { } async getMyRestaurantListInfo( searchInfoDto: SearchInfoDto, + sortInfoDto: SortInfoDto, tokenInfo: TokenInfo ) { const results = await this.userRestaurantListRepository.getMyRestaurantListInfo( searchInfoDto, + sortInfoDto, tokenInfo.id ); - for (const restaurant of results) { + let list + if ('items' in results) list = results.items; + else list = results; + + for (const restaurant of list) { const reviewCount = await this.reviewRepository .createQueryBuilder("review") .where("review.restaurant_id = :restaurantId", { @@ -107,30 +131,52 @@ export class UserService { }) .getCount(); + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + restaurant.isMy = true; restaurant.restaurant_reviewCnt = reviewCount; } return results; } - async getMyWishRestaurantListInfo(tokenInfo: TokenInfo) { + async getMyWishRestaurantListInfo(tokenInfo: TokenInfo, sortInfoDto: SortInfoDto) { const result = await this.userWishRestaurantListRepository.getMyWishRestaurantListInfo( - tokenInfo.id + tokenInfo.id, + sortInfoDto ); return result; } + async getStateIsWish(tokenInfo: TokenInfo, restaurantId: number) { + const result = await this.userWishRestaurantListRepository.findOne({ where: { restaurantId: restaurantId, userId: tokenInfo["id"] } }); + if (result) return { isWish: true }; + else return { isWish: false }; + } async getMyFollowListInfo(tokenInfo: TokenInfo) { const userIds = await this.userFollowListRepositoy.getMyFollowListInfo( tokenInfo.id ); const userIdValues = userIds.map((user) => user.followingUserId); const result = await this.usersRepository.find({ - select: ["nickName", "region"], + select: ["nickName", "region", "profileImage"], where: { id: In(userIdValues) }, }); return result.map((user) => ({ ...user, + profileImage: this.awsService.getImageURL(user.profileImage), isFollow: true, })); } @@ -146,7 +192,7 @@ export class UserService { (user) => user.followingUserId ); const result = await this.usersRepository.find({ - select: ["id", "nickName", "region"], + select: ["id", "nickName", "region", "profileImage"], where: { id: In(followerUserIdValues) }, }); @@ -154,23 +200,68 @@ export class UserService { const { id, ...userInfo } = user; return { ...userInfo, + profileImage: this.awsService.getImageURL(userInfo.profileImage), isFollow: followUserIdValues.includes(id) ? true : false, }; }); } + + + async getRecommendUserListInfo(tokenInfo: TokenInfo) { const userIds = await this.userFollowListRepositoy.getMyFollowListInfo( tokenInfo.id ); const userIdValues = userIds.map((user) => user.followingUserId); userIdValues.push(tokenInfo.id); - const result = - await this.usersRepository.getRecommendUserListInfo(userIdValues); - return result.map((user) => ({ + const result = await this.usersRepository.getRecommendUserListInfo(userIdValues, tokenInfo.id); + + function getRandomInts(min: number, max: number, count: number): number[] { + if (max === -1) { + return []; + } else if (max === 0) { + return [0]; + } + + const ints = new Set(); + while (ints.size < count) { + const rand = Math.floor(Math.random() * (max - min + 1)) + min; + ints.add(rand); + } + return [...ints].sort((a, b) => a - b); + } + + const randomIndexes = getRandomInts(0, result.length - 1, 2); + if (randomIndexes.length === 0) return []; + + const selectedUsers = randomIndexes.map(index => result[index]); + return selectedUsers.map((user) => ({ ...user, + user_profileImage: this.awsService.getImageURL(user.user_profileImage), isFollow: false, })); } + async getRecommendFood(tokenInfo: TokenInfo) { + const region = await this.usersRepository.findOne({ select: ["region"], where: { id: tokenInfo.id } }); + const restaurants = await this.userRestaurantListRepository.getMyFavoriteFoodCategory(tokenInfo.id, region); + for (const restaurant of restaurants) { + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + } + return restaurants; + } async searchTargetUser(tokenInfo: TokenInfo, nickName: string, region: string[]) { const whereCondition: any = { nickName: Like(`%${nickName}%`), @@ -195,6 +286,7 @@ export class UserService { )) ? true : false; + result[i]["profileImage"] = this.awsService.getImageURL(result[i]["profileImage"]); } return result; } @@ -235,9 +327,19 @@ export class UserService { async addRestaurantToNebob( reviewInfoDto: ReviewInfoDto, tokenInfo: TokenInfo, - restaurantId: number + restaurantId: number, + file: Express.Multer.File ) { const reviewEntity = this.reviewRepository.create(reviewInfoDto); + let reviewImage; + if (file) { + const uuid = v4(); + reviewImage = `review/images/${uuid}.png`; + reviewEntity.reviewImage = reviewImage; + } + else { + reviewEntity.reviewImage = `review/images/defaultImage.png`; + } const userEntity = new User(); userEntity.id = tokenInfo["id"]; reviewEntity.user = userEntity; @@ -252,6 +354,7 @@ export class UserService { restaurantId, reviewEntity ); + if (file) await this.awsService.uploadToS3(reviewImage, file.buffer); } catch (err) { throw new BadRequestException(); } @@ -290,30 +393,38 @@ export class UserService { } async logout(tokenInfo: TokenInfo) { - return await this.usersRepository.logout(tokenInfo.id); + return await this.authService.logout(tokenInfo.id); } async deleteUserAccount(tokenInfo: TokenInfo) { return await this.usersRepository.deleteUserAccount(tokenInfo.id); } - async updateMypageUserInfo(tokenInfo: TokenInfo, userInfoDto: UserInfoDto) { - userInfoDto.password = await hashPassword(userInfoDto.password); - const user = { + async updateMypageUserInfo(file: Express.Multer.File, tokenInfo: TokenInfo, userInfoDto: UserInfoDto, isChanged: Boolean) { + const existedInfo = await this.usersRepository.findOne({ select: ["profileImage", "password"], where: { id: tokenInfo.id } }) + + if (userInfoDto.password) userInfoDto.password = await hashPassword(userInfoDto.password); + else userInfoDto.password = existedInfo.password; + + let profileImage = existedInfo.profileImage; + if (isChanged) { + if (file) { + const uuid = v4(); + profileImage = `profile/images/${uuid}.png`; + } else { + profileImage = "profile/images/defaultprofile.png"; + } + } + + let user = { ...userInfoDto, - profileImage: "profile/images/defaultprofile.png", + profileImage }; - if (userInfoDto.profileImage) { - const uuid = v4(); - user.profileImage = `profile/images/${uuid}.png`; - } - const newUser = this.usersRepository.create(user); - const result = await this.usersRepository.updateMypageUserInfo( - tokenInfo.id, - newUser - ); - if (userInfoDto.profileImage)this.awsService.uploadToS3(user.profileImage, userInfoDto.profileImage); - return result; + const updatedUser = await this.usersRepository.updateMypageUserInfo(tokenInfo.id, newUser); + if (file && isChanged) { + this.awsService.uploadToS3(profileImage, file.buffer); + } + return updatedUser; } } diff --git a/be/src/user/user.wishrestaurantList.repository.ts b/be/src/user/user.wishrestaurantList.repository.ts index 8736cd5..819f222 100644 --- a/be/src/user/user.wishrestaurantList.repository.ts +++ b/be/src/user/user.wishrestaurantList.repository.ts @@ -4,6 +4,7 @@ import { UserWishRestaurantListEntity } from "./entities/user.wishrestaurantlist import { TokenInfo } from "./user.decorator"; import { RestaurantInfoEntity } from "src/restaurant/entities/restaurant.entity"; import { UserRestaurantListEntity } from "./entities/user.restaurantlist.entity"; +import { SortInfoDto } from "src/utils/sortInfo.dto"; @Injectable() export class UserWishRestaurantListRepository extends Repository { constructor(private dataSource: DataSource) { @@ -32,8 +33,8 @@ export class UserWishRestaurantListRepository extends Repository sortInfoDto.limit; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { + hasNext, + items : resultItems, + } } } diff --git a/be/src/utils/sortInfo.dto.ts b/be/src/utils/sortInfo.dto.ts new file mode 100644 index 0000000..709394a --- /dev/null +++ b/be/src/utils/sortInfo.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsInt, IsOptional, Min, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SortInfoDto { + @IsString() + @IsOptional() + sort?: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @IsOptional() + page?: number; + + @IsInt() + @Min(1) + @Type(() => Number) + @IsOptional() + limit?: number; +} \ No newline at end of file diff --git a/be/test/app.e2e-spec.ts b/be/test/app.e2e-spec.ts index 05db0a7..1163677 100644 --- a/be/test/app.e2e-spec.ts +++ b/be/test/app.e2e-spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { INestApplication } from "@nestjs/common"; import * as request from "supertest"; -import { AppModule } from "./../src/app.module"; +import { AppModule } from "../src/app.module"; describe("AppController (e2e)", () => { let app: INestApplication; @@ -15,10 +15,26 @@ describe("AppController (e2e)", () => { await app.init(); }); - it("/ (GET)", () => { - return request(app.getHttpServer()) - .get("/") - .expect(200) - .expect("Hello World!"); + it('/api/user', async () => { + const userData = { + email: "test@example.com", + password: "1234", + provider: "site", + nickName: "test", + region: "강남구", + birthdate: "1234/56/78", + isMale: true + }; + + const response = await request(app.getHttpServer()) + .post('/user') + .send(userData) + .expect(201); + + expect(response.statusCode).toBe(201); + }); + + afterAll(async () => { + await app.close(); }); });