diff --git a/.env.example b/.env.example index 303ffb31..769e0fa8 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,18 @@ +# required environment variables PORT=[back의 port(e.g. 80)] DB_PATH=[mongoDB path(e.g. mongodb://localhost:27017/local)] -REDIS_PATH=[redis path(e.g. redis://127.0.0.1:6379)] -SESSION_KEY=[세션 비밀번호(임의 값 아무거나 설정하면 된다)] -SPARCSSSO_CLIENT_ID=[스팍스SSO ID] -SPARCSSSO_CLIENT_KEY=[스팍스SSO PW] -FRONT_URL=[front url(e.g. http://localhost:3000)] AWS_ACCESS_KEY_ID=[AWS Access key ID] AWS_SECRET_ACCESS_KEY=[AWS Secret access key] -AWS_S3_BUCKET_NAME=[AWS S3 Buck name] +AWS_S3_BUCKET_NAME=[AWS S3 Bucket name] AWS_S3_URL=[AWS S3 url(e.g. https://.s3..amazonaws.com)] -JWT_SECRET_KEY=[JWT SERCRET KEY] -APP_URI_SCHEME=[APP_URI_SCHEME] -GOOGLE_APPLICATION_CREDENTIALS=[GOOGLE APPLICATION CREDENTIALS JSON] + +# optional environment variables +REDIS_PATH=[redis path(e.g. redis://127.0.0.1:6379)] +SESSION_KEY=[세션 관리에 사용되는 키(임의 값 아무거나 설정하면 된다)] +JWT_SECRET_KEY=[JWT 관리에 사용되는 키(임의 값 아무거나 설정하면 된다)] +SPARCSSSO_CLIENT_ID=[스팍스SSO ID] +SPARCSSSO_CLIENT_KEY=[스팍스SSO PW] +CORS_WHITELIST=[CORS 정책에서 허용하는 도메인의 목록(e.g. ["http://localhost:3000"])] +GOOGLE_APPLICATION_CREDENTIALS=[GOOGLE_APPLICATION_CREDENTIALS JSON] +TEST_ACCOUNTS=[스팍스SSO로 로그인시 무조건 테스트로 로그인이 가능한 허용 아이디 목록] +SLACK_REPORT_WEBHOOK_URL=[Slack 웹훅 URL들이 담긴 JSON] diff --git a/.github/workflows/push_image_ecr.yml b/.github/workflows/push_image_ecr.yml index 17e172f7..6b3ce161 100644 --- a/.github/workflows/push_image_ecr.yml +++ b/.github/workflows/push_image_ecr.yml @@ -22,6 +22,17 @@ jobs: with: fetch-depth: 0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Get previous tag-version id: previous_tag uses: WyriHaximus/github-action-get-previous-tag@v1 @@ -47,15 +58,23 @@ jobs: id: login-ecr uses: aws-actions/amazon-ecr-login@v1 - - name: Build and Push to AWS ECR - id: build_image + - name: Build Image and Push to AWS ECR + id: build_image_and_push + uses: docker/build-push-action@v5 env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} IMAGE_TAG: ${{ steps.tag.outputs.tag }} ECR_REPOSITORY: taxi-back + with: + push: true + tags: | + "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}" + "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest" + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + - name: Remove old cache run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest - echo "Push iamge : $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG and latest" + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + \ No newline at end of file diff --git a/.github/workflows/push_image_ecr_dev.yml b/.github/workflows/push_image_ecr_dev.yml index ef917f5b..d1c1dae4 100644 --- a/.github/workflows/push_image_ecr_dev.yml +++ b/.github/workflows/push_image_ecr_dev.yml @@ -20,7 +20,18 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: @@ -31,13 +42,20 @@ jobs: - name: Login to AWS ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 - - - name: Build and Push to AWS ECR - id: build_image + + - name: Build Image and Push to AWS ECR + id: build_image_and_push + uses: docker/build-push-action@v5 env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: taxi-back + with: + push: true + tags: "${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:dev" + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + - name: Remove old cache run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:dev . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:dev - echo "Push iamge : $ECR_REGISTRY/$ECR_REPOSITORY:dev" + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache \ No newline at end of file diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 3c8885b2..29f7f21b 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -12,24 +12,23 @@ jobs: strategy: matrix: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - node-version: [16.x] + node-version: ['18.x'] mongodb-version: ['5.0'] steps: - name: Start MongoDB run: sudo docker run --name mongodb -d -p 27017:27017 mongo:${{ matrix.mongodb-version }} - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/checkout@v3 + - uses: actions/checkout@v3 with: submodules: true - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 16 - - - uses: pnpm/action-setup@v2 - name: Install pnpm + - name: Install pnpm + uses: pnpm/action-setup@v2 with: version: 8 + - name: Install Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' - id: submodule-local name: Save local version of submodule run: echo "ver=`cd sampleGenerator && git log --pretty="%h" -1 && cd ..`" >> $GITHUB_OUTPUT @@ -53,6 +52,5 @@ jobs: AWS_S3_BUCKET_NAME: ${{ secrets.AWS_S3_BUCKET_NAME }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} DB_PATH: ${{ secrets.DB_PATH }} - FRONT_URL: ${{ secrets.FRONT_URL }} PORT: ${{ secrets.PORT }} SESSION_KEY: ${{ secrets.SESSION_KEY }} diff --git a/.gitignore b/.gitignore index 8ffa241b..a7e86767 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ *.code-workspace *.swp /logs/*.log +.vscode # AdminJS 관련 디렉토리 .adminjs diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..be7e0b34 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Force Node.js and pnpm versions according to package.json +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..7950a445 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.17.0 diff --git a/Dockerfile b/Dockerfile index 78ab302f..0873829c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,21 @@ -FROM node:16-alpine +FROM node:18-alpine -# Copy repository WORKDIR /usr/src/app -COPY . . -# Install curl (for taxi-docker) -RUN apk update && apk add curl -RUN npm install --global pnpm@8.6.6 serve@14.1.2 +# Install curl(for taxi-watchtower) and pnpm +RUN apk update && apk add curl && npm install --global pnpm@8.8.0 -# Install requirements -RUN pnpm i --force --frozen-lockfile +# pnpm fetch does require only lockfile +COPY pnpm-lock.yaml . + +# Note: devDependencies are not fetched +RUN pnpm fetch --prod + +# Copy repository and install dependencies +ADD . ./ +RUN pnpm install --offline --prod # Run container EXPOSE 80 ENV PORT 80 CMD ["pnpm", "run", "serve"] - diff --git a/README.md b/README.md index 0af3da05..26cda418 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ Taxi는 KAIST 구성원들의 택시 동승 인원 모집을 위한 서비스입 - Notion : [Sparcs Notion Taxi page](https://www.notion.so/sparcs/Taxi-9d371e8ac5ac4f0c9b9c35869682a0eb) (Only SPARCS members can access it) - Slack : #taxi-main, #taxi-notice, #taxi-bug-report, #taxi-github-bot, #taxi-notion-bot (Only SPARCS members can access it) -## Prerequisites -- Recommended npm version : 8.5.5 (with node v.16.15.0) -- Recommended mognoDB version : 5.0.8 -- [Issue with node version](https://github.com/sparcs-kaist/taxi-front/issues/76) +## Prerequisite + +- Recommended node version : >=18.0.0 (Node v18.18.0, for example) +- Recommended pnpm version : >=8.0.0 (pmpm v8.8.0, for example) +- Recommended mongoDB version : 5.0.8 ## Project Setup @@ -24,15 +25,17 @@ $ git clone https://github.com/sparcs-kaist/taxi-back ### Install Requirements ```bash -$ npm install --save +$ pnpm install ``` ### Set Environment Configuration See [notion page](https://www.notion.so/sparcs/Environment-Variables-1b404bd385fa495bac6d5517b57d72bf). -Refer to [.env.example](.env.example) and write your own `.env.development` and `.env.test`. +Refer to [.env.example](.env.example) and write your own `.env`. ## Backend Route Information -See [Backend Route Documentation](src/routes/docs/README.md) +API specification is defined on Swagger. +Start development server and visit `/docs` to see the specification of each endpoint. +Some endpoints are not documented in Swagger yet. For those endpoints, refer to [routes/docs/README.md](./src/routes/docs/README.md). ## License This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details diff --git a/app.js b/app.js index 3e936950..3394dec0 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,9 @@ // 모듈 require const express = require("express"); const http = require("http"); -const { port: httpPort } = require("./loadenv"); +const { port: httpPort, eventConfig } = require("./loadenv"); const logger = require("./src/modules/logger"); +const { connectDatabase } = require("./src/modules/stores/mongo"); const { startSocketServer } = require("./src/modules/socket"); // Firebase Admin 초기설정 @@ -11,14 +12,18 @@ require("./src/modules/fcm").initializeApp(); // 익스프레스 서버 생성 const app = express(); +// 데이터베이스 연결 +connectDatabase(); + // [Middleware] request body 파싱 app.use(express.urlencoded({ extended: false })); app.use(express.json()); +// reverse proxy가 설정한 헤더를 신뢰합니다. +app.set("trust proxy", true); + // [Middleware] CORS 설정 -app.use( - require("cors")({ origin: true, credentials: true, exposedHeaders: ["Date"] }) -); +app.use(require("./src/middlewares/cors")); // [Middleware] 세션 및 쿠키 const session = require("./src/middlewares/session"); @@ -40,6 +45,16 @@ app.use(require("./src/middlewares/limitRate")); // [Router] Swagger (API 문서) app.use("/docs", require("./src/routes/docs")); +// 2023 추석 이벤트 전용 라우터입니다. +eventConfig && + app.use( + `/events/${eventConfig.mode}`, + require("./src/lottery").lotteryRouter + ); + +// [Middleware] 모든 API 요청에 대하여 origin 검증 +app.use(require("./src/middlewares/originValidator")); + // [Router] APIs app.use("/auth", require("./src/routes/auth")); app.use("/logininfo", require("./src/routes/logininfo")); @@ -50,6 +65,9 @@ app.use("/locations", require("./src/routes/locations")); app.use("/reports", require("./src/routes/reports")); app.use("/notifications", require("./src/routes/notifications")); +// [Middleware] 전역 에러 핸들러. 에러 핸들러는 router들보다 아래에 등록되어야 합니다. +app.use(require("./src/middlewares/errorHandler")); + // express 서버 시작 const serverHttp = http .createServer(app) @@ -57,5 +75,8 @@ const serverHttp = http logger.info(`Express 서버가 ${httpPort}번 포트에서 시작됨.`) ); -// socket.io 서버 시작 및 app 인스턴스에 저장 +// socket.io 서버 시작 app.set("io", startSocketServer(serverHttp)); + +// [Schedule] 스케줄러 시작 +require("./src/schedules")(app); diff --git a/loadenv.js b/loadenv.js index 8935a0ba..69451b7c 100644 --- a/loadenv.js +++ b/loadenv.js @@ -1,6 +1,5 @@ // 환경 변수에 따라 .env.production 또는 .env.development 파일을 읽어옴 require("dotenv").config({ path: `./.env.${process.env.NODE_ENV}` }); - module.exports = { nodeEnv: process.env.NODE_ENV, mongo: process.env.DB_PATH, // required @@ -11,7 +10,8 @@ module.exports = { key: process.env.SPARCSSSO_CLIENT_KEY || "", // optional }, port: process.env.PORT || 80, // optional (default = 80) - frontUrl: process.env.FRONT_URL || "http://localhost:3000", // optional (default = "http://localhost:3000") + corsWhiteList: (process.env.CORS_WHITELIST && + JSON.parse(process.env.CORS_WHITELIST)) || [true], // optional (default = [true]) aws: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, // required secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, // required @@ -24,18 +24,20 @@ module.exports = { secretKey: process.env.JWT_SECRET_KEY || "TAXI_JWT_KEY", option: { algorithm: "HS256", + // FIXME: remove FRONT_URL from issuer. 단, issuer를 변경하면 이전에 발급했던 모든 JWT가 무효화됩니다. + // See https://github.com/sparcs-kaist/taxi-back/issues/415 issuer: process.env.FRONT_URL || "http://localhost:3000", // optional (default = "http://localhost:3000") }, TOKEN_EXPIRED: -3, TOKEN_INVALID: -2, }, - appUriScheme: process.env.APP_URI_SCHEME, // FIXME: 사용하지 않음 googleApplicationCredentials: process.env.GOOGLE_APPLICATION_CREDENTIALS && - JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS), + JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS), // optional testAccounts: - process.env.TEST_ACCOUNTS && JSON.parse(process.env.TEST_ACCOUNTS), + (process.env.TEST_ACCOUNTS && JSON.parse(process.env.TEST_ACCOUNTS)) || [], // optional slackWebhookUrl: { report: process.env.SLACK_REPORT_WEBHOOK_URL || "", // optional }, + eventConfig: (process.env.EVENT_CONFIG && JSON.parse(process.env.EVENT_CONFIG)) }; diff --git a/package.json b/package.json index 456099a9..f4ff374e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,22 @@ "name": "taxi-back", "version": "1.0.0", "description": "KAIST Taxi Party Matching Web Service", + "author": "sparcs/taxi", + "license": "MIT", "main": "app.js", + "scripts": { + "preinstall": "npx only-allow pnpm", + "start": "cross-env TZ='Asia/Seoul' npx nodemon app.js", + "test": "npm run sample && cross-env TZ='Asia/Seoul' npm run mocha", + "mocha": "cross-env TZ='Asia/Seoul' NODE_ENV=test mocha --recursive --reporter spec --exit", + "serve": "cross-env TZ='Asia/Seoul' NODE_ENV=production node app.js", + "lint": "npx eslint --fix .", + "sample": "cd sampleGenerator && npm start && cd .." + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, "dependencies": { "@adminjs/express": "^5.1.0", "@adminjs/mongoose": "^3.0.3", @@ -25,12 +40,13 @@ "eslint-config-prettier": "^8.3.0", "express": "^4.17.1", "express-formidable": "^1.2.0", - "express-rate-limit": "^6.6.0", + "express-rate-limit": "^7.1.0", "express-session": "^1.17.3", "express-validator": "^6.14.0", "firebase-admin": "^11.4.1", - "jsonwebtoken": "^8.5.1", - "mongoose": "^6.11.3", + "jsonwebtoken": "^9.0.2", + "mongoose": "^6.12.0", + "node-cron": "3.0.2", "node-mocks-http": "^1.12.1", "querystring": "^0.2.1", "redis": "^4.2.0", @@ -42,22 +58,11 @@ "winston-daily-rotate-file": "^4.7.1" }, "devDependencies": { - "chai": "*", + "chai": "^4.3.10", "eslint": "^8.22.0", "eslint-plugin-mocha": "^10.1.0", - "mocha": "*", - "nodemon": "^2.0.14", + "mocha": "^10.2.0", + "nodemon": "^3.0.1", "supertest": "^6.2.4" - }, - "scripts": { - "preinstall": "npx only-allow pnpm", - "start": "cross-env TZ='Asia/Seoul' npx nodemon app.js", - "test": "npm run sample && cross-env TZ='Asia/Seoul' npm run mocha", - "mocha": "cross-env TZ='Asia/Seoul' NODE_ENV=test mocha --recursive --reporter spec --exit", - "serve": "cross-env TZ='Asia/Seoul' NODE_ENV=production node app.js", - "lint": "npx eslint --fix .", - "sample": "cd sampleGenerator && npm start && cd .." - }, - "author": "sparcs/taxi", - "license": "MIT" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9ca0a31..7ea60078 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ dependencies: version: 5.1.0(adminjs@6.8.7)(express-formidable@1.2.0)(express-session@1.17.3)(express@4.18.2)(tslib@2.6.1) '@adminjs/mongoose': specifier: ^3.0.3 - version: 3.0.3(adminjs@6.8.7)(mongoose@6.11.5) + version: 3.0.3(adminjs@6.8.7)(mongoose@6.12.0) '@aws-sdk/client-s3': specifier: ^3.391.0 version: 3.391.0 @@ -69,8 +69,8 @@ dependencies: specifier: ^1.2.0 version: 1.2.0 express-rate-limit: - specifier: ^6.6.0 - version: 6.8.1(express@4.18.2) + specifier: ^7.1.0 + version: 7.1.0(express@4.18.2) express-session: specifier: ^1.17.3 version: 1.17.3 @@ -81,11 +81,14 @@ dependencies: specifier: ^11.4.1 version: 11.10.1 jsonwebtoken: - specifier: ^8.5.1 - version: 8.5.1 + specifier: ^9.0.2 + version: 9.0.2 mongoose: - specifier: ^6.11.3 - version: 6.11.5 + specifier: ^6.12.0 + version: 6.12.0 + node-cron: + specifier: 3.0.2 + version: 3.0.2 node-mocks-http: specifier: ^1.12.1 version: 1.12.2 @@ -116,8 +119,8 @@ dependencies: devDependencies: chai: - specifier: '*' - version: 4.3.7 + specifier: ^4.3.10 + version: 4.3.10 eslint: specifier: ^8.22.0 version: 8.22.0 @@ -125,11 +128,11 @@ devDependencies: specifier: ^10.1.0 version: 10.1.0(eslint@8.22.0) mocha: - specifier: '*' + specifier: ^10.2.0 version: 10.2.0 nodemon: - specifier: ^2.0.14 - version: 2.0.22 + specifier: ^3.0.1 + version: 3.0.1 supertest: specifier: ^6.2.4 version: 6.3.3 @@ -204,7 +207,7 @@ packages: tslib: 2.6.1 dev: false - /@adminjs/mongoose@3.0.3(adminjs@6.8.7)(mongoose@6.11.5): + /@adminjs/mongoose@3.0.3(adminjs@6.8.7)(mongoose@6.12.0): resolution: {integrity: sha512-J/Ogz3oJ2ytOsbeqBpjgIFtiAmGk3MVVfJq2cUidXJ1phrvNHhb7AjiaKd+pcdFcT84COUHaoo6uPYvrLhZEQg==} peerDependencies: adminjs: '>=6.0.0' @@ -213,7 +216,7 @@ packages: adminjs: 6.8.7 escape-regexp: 0.0.1 lodash: 4.17.21 - mongoose: 6.11.5 + mongoose: 6.12.0 dev: false /@ampproject/remapping@2.2.1: @@ -310,26 +313,26 @@ packages: '@aws-sdk/util-endpoints': 3.382.0 '@aws-sdk/util-user-agent-browser': 3.378.0 '@aws-sdk/util-user-agent-node': 3.378.0 - '@smithy/config-resolver': 2.0.1 - '@smithy/fetch-http-handler': 2.0.1 - '@smithy/hash-node': 2.0.1 - '@smithy/invalid-dependency': 2.0.1 - '@smithy/middleware-content-length': 2.0.1 - '@smithy/middleware-endpoint': 2.0.1 - '@smithy/middleware-retry': 2.0.1 - '@smithy/middleware-serde': 2.0.1 + '@smithy/config-resolver': 2.0.3 + '@smithy/fetch-http-handler': 2.0.3 + '@smithy/hash-node': 2.0.3 + '@smithy/invalid-dependency': 2.0.3 + '@smithy/middleware-content-length': 2.0.3 + '@smithy/middleware-endpoint': 2.0.3 + '@smithy/middleware-retry': 2.0.3 + '@smithy/middleware-serde': 2.0.3 '@smithy/middleware-stack': 2.0.0 - '@smithy/node-config-provider': 2.0.1 - '@smithy/node-http-handler': 2.0.1 - '@smithy/protocol-http': 2.0.1 - '@smithy/smithy-client': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/url-parser': 2.0.1 + '@smithy/node-config-provider': 2.0.3 + '@smithy/node-http-handler': 2.0.3 + '@smithy/protocol-http': 2.0.3 + '@smithy/smithy-client': 2.0.3 + '@smithy/types': 2.2.0 + '@smithy/url-parser': 2.0.3 '@smithy/util-base64': 2.0.0 '@smithy/util-body-length-browser': 2.0.0 '@smithy/util-body-length-node': 2.0.0 - '@smithy/util-defaults-mode-browser': 2.0.1 - '@smithy/util-defaults-mode-node': 2.0.1 + '@smithy/util-defaults-mode-browser': 2.0.3 + '@smithy/util-defaults-mode-node': 2.0.3 '@smithy/util-retry': 2.0.0 '@smithy/util-utf8': 2.0.0 tslib: 2.6.1 @@ -461,26 +464,26 @@ packages: '@aws-sdk/util-endpoints': 3.382.0 '@aws-sdk/util-user-agent-browser': 3.378.0 '@aws-sdk/util-user-agent-node': 3.378.0 - '@smithy/config-resolver': 2.0.1 - '@smithy/fetch-http-handler': 2.0.1 - '@smithy/hash-node': 2.0.1 - '@smithy/invalid-dependency': 2.0.1 - '@smithy/middleware-content-length': 2.0.1 - '@smithy/middleware-endpoint': 2.0.1 - '@smithy/middleware-retry': 2.0.1 - '@smithy/middleware-serde': 2.0.1 + '@smithy/config-resolver': 2.0.3 + '@smithy/fetch-http-handler': 2.0.3 + '@smithy/hash-node': 2.0.3 + '@smithy/invalid-dependency': 2.0.3 + '@smithy/middleware-content-length': 2.0.3 + '@smithy/middleware-endpoint': 2.0.3 + '@smithy/middleware-retry': 2.0.3 + '@smithy/middleware-serde': 2.0.3 '@smithy/middleware-stack': 2.0.0 - '@smithy/node-config-provider': 2.0.1 - '@smithy/node-http-handler': 2.0.1 - '@smithy/protocol-http': 2.0.1 - '@smithy/smithy-client': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/url-parser': 2.0.1 + '@smithy/node-config-provider': 2.0.3 + '@smithy/node-http-handler': 2.0.3 + '@smithy/protocol-http': 2.0.3 + '@smithy/smithy-client': 2.0.3 + '@smithy/types': 2.2.0 + '@smithy/url-parser': 2.0.3 '@smithy/util-base64': 2.0.0 '@smithy/util-body-length-browser': 2.0.0 '@smithy/util-body-length-node': 2.0.0 - '@smithy/util-defaults-mode-browser': 2.0.1 - '@smithy/util-defaults-mode-node': 2.0.1 + '@smithy/util-defaults-mode-browser': 2.0.3 + '@smithy/util-defaults-mode-node': 2.0.3 '@smithy/util-retry': 2.0.0 '@smithy/util-utf8': 2.0.0 tslib: 2.6.1 @@ -547,26 +550,26 @@ packages: '@aws-sdk/util-endpoints': 3.382.0 '@aws-sdk/util-user-agent-browser': 3.378.0 '@aws-sdk/util-user-agent-node': 3.378.0 - '@smithy/config-resolver': 2.0.1 - '@smithy/fetch-http-handler': 2.0.1 - '@smithy/hash-node': 2.0.1 - '@smithy/invalid-dependency': 2.0.1 - '@smithy/middleware-content-length': 2.0.1 - '@smithy/middleware-endpoint': 2.0.1 - '@smithy/middleware-retry': 2.0.1 - '@smithy/middleware-serde': 2.0.1 + '@smithy/config-resolver': 2.0.3 + '@smithy/fetch-http-handler': 2.0.3 + '@smithy/hash-node': 2.0.3 + '@smithy/invalid-dependency': 2.0.3 + '@smithy/middleware-content-length': 2.0.3 + '@smithy/middleware-endpoint': 2.0.3 + '@smithy/middleware-retry': 2.0.3 + '@smithy/middleware-serde': 2.0.3 '@smithy/middleware-stack': 2.0.0 - '@smithy/node-config-provider': 2.0.1 - '@smithy/node-http-handler': 2.0.1 - '@smithy/protocol-http': 2.0.1 - '@smithy/smithy-client': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/url-parser': 2.0.1 + '@smithy/node-config-provider': 2.0.3 + '@smithy/node-http-handler': 2.0.3 + '@smithy/protocol-http': 2.0.3 + '@smithy/smithy-client': 2.0.3 + '@smithy/types': 2.2.0 + '@smithy/url-parser': 2.0.3 '@smithy/util-base64': 2.0.0 '@smithy/util-body-length-browser': 2.0.0 '@smithy/util-body-length-node': 2.0.0 - '@smithy/util-defaults-mode-browser': 2.0.1 - '@smithy/util-defaults-mode-node': 2.0.1 + '@smithy/util-defaults-mode-browser': 2.0.3 + '@smithy/util-defaults-mode-node': 2.0.3 '@smithy/util-retry': 2.0.0 '@smithy/util-utf8': 2.0.0 fast-xml-parser: 4.2.5 @@ -627,8 +630,8 @@ packages: dependencies: '@aws-sdk/client-cognito-identity': 3.385.0 '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 transitivePeerDependencies: - aws-crt @@ -640,8 +643,8 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -665,10 +668,10 @@ packages: '@aws-sdk/credential-provider-sso': 3.385.0 '@aws-sdk/credential-provider-web-identity': 3.378.0 '@aws-sdk/types': 3.378.0 - '@smithy/credential-provider-imds': 2.0.1 - '@smithy/property-provider': 2.0.1 - '@smithy/shared-ini-file-loader': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/credential-provider-imds': 2.0.3 + '@smithy/property-provider': 2.0.3 + '@smithy/shared-ini-file-loader': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 transitivePeerDependencies: - aws-crt @@ -703,10 +706,10 @@ packages: '@aws-sdk/credential-provider-sso': 3.385.0 '@aws-sdk/credential-provider-web-identity': 3.378.0 '@aws-sdk/types': 3.378.0 - '@smithy/credential-provider-imds': 2.0.1 - '@smithy/property-provider': 2.0.1 - '@smithy/shared-ini-file-loader': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/credential-provider-imds': 2.0.3 + '@smithy/property-provider': 2.0.3 + '@smithy/shared-ini-file-loader': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 transitivePeerDependencies: - aws-crt @@ -737,9 +740,9 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/shared-ini-file-loader': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.3 + '@smithy/shared-ini-file-loader': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -762,9 +765,9 @@ packages: '@aws-sdk/client-sso': 3.382.0 '@aws-sdk/token-providers': 3.385.0 '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/shared-ini-file-loader': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.3 + '@smithy/shared-ini-file-loader': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 transitivePeerDependencies: - aws-crt @@ -791,8 +794,8 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -823,9 +826,9 @@ packages: '@aws-sdk/credential-provider-sso': 3.385.0 '@aws-sdk/credential-provider-web-identity': 3.378.0 '@aws-sdk/types': 3.378.0 - '@smithy/credential-provider-imds': 2.0.1 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/credential-provider-imds': 2.0.3 + '@smithy/property-provider': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 transitivePeerDependencies: - aws-crt @@ -873,8 +876,8 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/protocol-http': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -903,7 +906,7 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/types': 2.0.2 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -922,8 +925,8 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/protocol-http': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -955,7 +958,7 @@ packages: dependencies: '@aws-sdk/middleware-signing': 3.379.1 '@aws-sdk/types': 3.378.0 - '@smithy/types': 2.0.2 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -975,10 +978,10 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/protocol-http': 2.0.1 + '@smithy/property-provider': 2.0.3 + '@smithy/protocol-http': 2.0.3 '@smithy/signature-v4': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/types': 2.2.0 '@smithy/util-middleware': 2.0.0 tslib: 2.6.1 dev: false @@ -1012,8 +1015,8 @@ packages: dependencies: '@aws-sdk/types': 3.378.0 '@aws-sdk/util-endpoints': 3.382.0 - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/protocol-http': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -1066,9 +1069,9 @@ packages: engines: {node: '>=14.0.0'} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/shared-ini-file-loader': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.3 + '@smithy/shared-ini-file-loader': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -1120,7 +1123,7 @@ packages: resolution: {integrity: sha512-qP0CvR/ItgktmN8YXpGQglzzR/6s0nrsQ4zIfx3HMwpsBTwuouYahcCtF1Vr82P4NFcoDA412EJahJ2pIqEd+w==} engines: {node: '>=14.0.0'} dependencies: - '@smithy/types': 2.0.2 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -1178,7 +1181,7 @@ packages: resolution: {integrity: sha512-FSCpagzftK1W+m7Ar6lpX7/Gr9y5P56nhFYz8U4EYQ4PkufS6czWX9YW+/FA5OYV0vlQ/SvPqMnzoHIPUNhZrQ==} dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/types': 2.0.2 + '@smithy/types': 2.2.0 bowser: 2.11.0 tslib: 2.6.1 dev: false @@ -1203,8 +1206,8 @@ packages: optional: true dependencies: '@aws-sdk/types': 3.378.0 - '@smithy/node-config-provider': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/node-config-provider': 2.0.3 + '@smithy/types': 2.2.0 tslib: 2.6.1 dev: false optional: true @@ -2988,6 +2991,14 @@ packages: dev: false optional: true + /@mongodb-js/saslprep@1.1.1: + resolution: {integrity: sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==} + requiresBuild: true + dependencies: + sparse-bitfield: 3.0.3 + dev: false + optional: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3224,15 +3235,6 @@ packages: rollup: 2.79.1 dev: false - /@smithy/abort-controller@2.0.1: - resolution: {integrity: sha512-0s7XjIbsTwZyUW9OwXQ8J6x1UiA1TNCh60Vaw56nHahL7kUZsLhmTlWiaxfLkFtO2Utkj8YewcpHTYpxaTzO+w==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/abort-controller@2.0.3: resolution: {integrity: sha512-LbQ4fdsVuQC3/18Z/uia5wnk9fk8ikfHl3laYCEGhboEMJ/6oVk3zhydqljMxBCftHGUv7yUrTnZ6EAQhOf+PA==} engines: {node: '>=14.0.0'} @@ -3254,17 +3256,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/config-resolver@2.0.1: - resolution: {integrity: sha512-l83Pm7hV+8CBQOCmBRopWDtF+CURUJol7NsuPYvimiDhkC2F8Ba9T1imSFE+pD1UIJ9jlsDPAnZfPJT5cjnuEw==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/types': 2.0.2 - '@smithy/util-config-provider': 2.0.0 - '@smithy/util-middleware': 2.0.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/config-resolver@2.0.3: resolution: {integrity: sha512-E+fsc6BOzFOc6U6y9ogRH8Pw2HF1NVW14AAYy7l3OTXYWuYxHb/fzDZaA0FvD/dXyFoMy7AV1rYZsGzD4bMKzw==} engines: {node: '>=14.0.0'} @@ -3350,17 +3341,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/fetch-http-handler@2.0.1: - resolution: {integrity: sha512-/SoU/ClazgcdOxgE4zA7RX8euiELwpsrKCSvulVQvu9zpmqJRyEJn8ZTWYFV17/eHOBdHTs9kqodhNhsNT+cUw==} - dependencies: - '@smithy/protocol-http': 2.0.1 - '@smithy/querystring-builder': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/util-base64': 2.0.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/fetch-http-handler@2.0.3: resolution: {integrity: sha512-0if2hyn+tDkyK9Tg1bXpo3IMUaezz/FKlaUTwTey3m87hF8gb7a0nKaST4NURE2eUVimViGCB7SH3/i4wFXALg==} dependencies: @@ -3380,17 +3360,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/hash-node@2.0.1: - resolution: {integrity: sha512-oTKYimQdF4psX54ZonpcIE+MXjMUWFxLCNosjPkJPFQ9whRX0K/PFX/+JZGRQh3zO9RlEOEUIbhy9NO+Wha6hw==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/types': 2.0.2 - '@smithy/util-buffer-from': 2.0.0 - '@smithy/util-utf8': 2.0.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/hash-node@2.0.3: resolution: {integrity: sha512-wtN9eiRKEiryXrPbWQ7Acu0D3Uk65+PowtTqOslViMZNcKNlYHsxOP1S9rb2klnzA3yY1WSPO1tG78pjhRlvrQ==} engines: {node: '>=14.0.0'} @@ -3410,14 +3379,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/invalid-dependency@2.0.1: - resolution: {integrity: sha512-2q/Eb0AE662zwyMV+z+TL7deBwcHCgaZZGc0RItamBE8kak3MzCi/EZCNoFWoBfxgQ4jfR12wm8KKsSXhJzJtQ==} - dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/invalid-dependency@2.0.3: resolution: {integrity: sha512-GtmVXD/s+OZlFG1o3HfUI55aBJZXX5/iznAQkgjRGf8prYoO8GvSZLDWHXJp91arybaJxYd133oJORGf4YxGAg==} dependencies: @@ -3440,16 +3401,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/middleware-content-length@2.0.1: - resolution: {integrity: sha512-IZhRSk5GkVBcrKaqPXddBS2uKhaqwBgaSgbBb1OJyGsKe7SxRFbclWS0LqOR9fKUkDl+3lL8E2ffpo6EQg0igw==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/middleware-content-length@2.0.3: resolution: {integrity: sha512-2FiZ5vu2+iMRL8XWNaREUqqNHjtBubaY9Jb2b3huZ9EbgrXsJfCszK6PPidHTLe+B4T7AISqdF4ZSp9VPXuelg==} engines: {node: '>=14.0.0'} @@ -3459,18 +3410,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/middleware-endpoint@2.0.1: - resolution: {integrity: sha512-uz/KI1MBd9WHrrkVFZO4L4Wyv24raf0oR4EsOYEeG5jPJO5U+C7MZGLcMxX8gWERDn1sycBDqmGv8fjUMLxT6w==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/middleware-serde': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/url-parser': 2.0.1 - '@smithy/util-middleware': 2.0.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/middleware-endpoint@2.0.3: resolution: {integrity: sha512-gNleUHhu5OKk/nrA6WbpLUk/Wk2hcyCvaw7sZiKMazs+zdzWb0kYzynRf675uCWolbvlw9BvkrVaSJo5TRz+Mg==} engines: {node: '>=14.0.0'} @@ -3482,20 +3421,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/middleware-retry@2.0.1: - resolution: {integrity: sha512-NKHF4i0gjSyjO6C0ZyjEpNqzGgIu7s8HOK6oT/1Jqws2Q1GynR1xV8XTUs1gKXeaNRzbzKQRewHHmfPwZjOtHA==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/protocol-http': 2.0.1 - '@smithy/service-error-classification': 2.0.0 - '@smithy/types': 2.0.2 - '@smithy/util-middleware': 2.0.0 - '@smithy/util-retry': 2.0.0 - tslib: 2.6.1 - uuid: 8.3.2 - dev: false - optional: true - /@smithy/middleware-retry@2.0.3: resolution: {integrity: sha512-BpfaUwgOh8LpWP/x6KBb5IdBmd5+tEpTKIjDt7LWi3IVOYmRX5DjQo1eCEUqlKS1nxws/T7+/IyzvgBq8gF9rw==} engines: {node: '>=14.0.0'} @@ -3509,15 +3434,6 @@ packages: uuid: 8.3.2 dev: false - /@smithy/middleware-serde@2.0.1: - resolution: {integrity: sha512-uKxPaC6ItH9ZXdpdqNtf8sda7GcU4SPMp0tomq/5lUg9oiMa/Q7+kD35MUrpKaX3IVXVrwEtkjCU9dogZ/RAUA==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/middleware-serde@2.0.3: resolution: {integrity: sha512-5BxuOKL7pXqesvtunniDlvYQXVr7UJEF5nFVoK6+5chf5wplLA8IZWAn3NUcGq/f1u01w2m2q7atCoA6ftRLKA==} engines: {node: '>=14.0.0'} @@ -3553,18 +3469,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/node-http-handler@2.0.1: - resolution: {integrity: sha512-Zv3fxk3p9tsmPT2CKMsbuwbbxnq2gzLDIulxv+yI6aE+02WPYorObbbe9gh7SW3weadMODL1vTfOoJ9yFypDzg==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/abort-controller': 2.0.1 - '@smithy/protocol-http': 2.0.1 - '@smithy/querystring-builder': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/node-http-handler@2.0.3: resolution: {integrity: sha512-wUO78aa0VVJVz54Lr1Nw6FYnkatbvh2saHgkT8fdtNWc7I/osaPMUJnRkBmTZZ5w+BIQ1rvr9dbGyYBTlRg2+Q==} engines: {node: '>=14.0.0'} @@ -3592,15 +3496,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/protocol-http@2.0.1: - resolution: {integrity: sha512-mrkMAp0wtaDEIkgRObWYxI1Kun1tm6Iu6rK+X4utb6Ah7Uc3Kk4VIWwK/rBHdYGReiLIrxFCB1rq4a2gyZnSgg==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/protocol-http@2.0.3: resolution: {integrity: sha512-yzBYloviSLOwo2RT62vBRCPtk8mc/O2RMJfynEahbX8ZnduHpKaajvx3IuGubhamIbesi7M5HBVecDehBnlb9Q==} engines: {node: '>=14.0.0'} @@ -3609,16 +3504,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/querystring-builder@2.0.1: - resolution: {integrity: sha512-bp+93WFzx1FojVEIeFPtG0A1pKsFdCUcZvVdZdRlmNooOUrz9Mm9bneRd8hDwAQ37pxiZkCOxopSXXRQN10mYw==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/types': 2.0.2 - '@smithy/util-uri-escape': 2.0.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/querystring-builder@2.0.3: resolution: {integrity: sha512-HPSviVgGj9FT4jPdprkfSGF3nhFzpQMST1hOC1Oh6eaRB2KTQCsOZmS7U4IqGErVPafe6f/yRa1DV73B5gO50w==} engines: {node: '>=14.0.0'} @@ -3679,17 +3564,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/smithy-client@2.0.1: - resolution: {integrity: sha512-LHC5m6tYpEu1iNbONfvMbwtErboyTZJfEIPoD78Ei5MVr36vZQCaCla5mvo36+q/a2NAk2//fA5Rx3I1Kf7+lQ==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/middleware-stack': 2.0.0 - '@smithy/types': 2.0.2 - '@smithy/util-stream': 2.0.1 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/smithy-client@2.0.3: resolution: {integrity: sha512-YP0HakPOJgvX2wvPEAGH9GB3NfuQE8CmBhR13bWtqWuIErmJnInTiSQcLSc0QiXHclH/8Qlq+qjKCR7N/4wvtQ==} engines: {node: '>=14.0.0'} @@ -3766,17 +3640,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/util-defaults-mode-browser@2.0.1: - resolution: {integrity: sha512-w72Qwsb+IaEYEFtYICn0Do42eFju78hTaBzzJfT107lFOPdbjWjKnFutV+6GL/nZd5HWXY7ccAKka++C3NrjHw==} - engines: {node: '>= 10.0.0'} - dependencies: - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 - bowser: 2.11.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/util-defaults-mode-browser@2.0.3: resolution: {integrity: sha512-t9cirP55wYeSfDjjvPHSjNiuZj3wc9W3W3fjLXaVzuKKlKX98B9Vj7QM9WHJnFjJdsrYEwolLA8GVdqZeHOkHg==} engines: {node: '>= 10.0.0'} @@ -3787,19 +3650,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/util-defaults-mode-node@2.0.1: - resolution: {integrity: sha512-dNF45caelEBambo0SgkzQ0v76m4YM+aFKZNTtSafy7P5dVF8TbjZuR2UX1A5gJABD9XK6lzN+v/9Yfzj/EDgGg==} - engines: {node: '>= 10.0.0'} - dependencies: - '@smithy/config-resolver': 2.0.1 - '@smithy/credential-provider-imds': 2.0.1 - '@smithy/node-config-provider': 2.0.1 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/util-defaults-mode-node@2.0.3: resolution: {integrity: sha512-Gca+fL0h+tl8cbvoLDMWCVzs1CL4jWLWvz/I6MCYZzaEAKkmd1qO4kPzBeGaI6hGA/IbrlWCFg7L+MTPzLwzfg==} engines: {node: '>= 10.0.0'} @@ -3834,21 +3684,6 @@ packages: tslib: 2.6.1 dev: false - /@smithy/util-stream@2.0.1: - resolution: {integrity: sha512-2a0IOtwIKC46EEo7E7cxDN8u2jwOiYYJqcFKA6rd5rdXqKakHT2Gc+AqHWngr0IEHUfW92zX12wRQKwyoqZf2Q==} - engines: {node: '>=14.0.0'} - dependencies: - '@smithy/fetch-http-handler': 2.0.1 - '@smithy/node-http-handler': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/util-base64': 2.0.0 - '@smithy/util-buffer-from': 2.0.0 - '@smithy/util-hex-encoding': 2.0.0 - '@smithy/util-utf8': 2.0.0 - tslib: 2.6.1 - dev: false - optional: true - /@smithy/util-stream@2.0.3: resolution: {integrity: sha512-+8n2vIyp6o9KHGey0PoGatcDthwVb7C/EzWfqojXrHhZOXy6l+hnWlfoF8zVerKYH2CUtravdJKRTy7vdkOXfQ==} engines: {node: '>=14.0.0'} @@ -5055,14 +4890,14 @@ packages: dev: false optional: true - /chai@4.3.7: - resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} + /chai@4.3.10: + resolution: {integrity: sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==} engines: {node: '>=4'} dependencies: assertion-error: 1.1.0 - check-error: 1.0.2 + check-error: 1.0.3 deep-eql: 4.1.3 - get-func-name: 2.0.0 + get-func-name: 2.0.2 loupe: 2.3.6 pathval: 1.1.1 type-detect: 4.0.8 @@ -5084,8 +4919,10 @@ packages: ansi-styles: 4.3.0 supports-color: 7.2.0 - /check-error@1.0.2: - resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} + /check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + dependencies: + get-func-name: 2.0.2 dev: true /chokidar@3.5.3: @@ -5830,9 +5667,9 @@ packages: formidable: 1.2.6 dev: false - /express-rate-limit@6.8.1(express@4.18.2): - resolution: {integrity: sha512-xJyudsE60CsDShK74Ni1MxsldYaIoivmG3ieK2tAckMsYCBewEuGalss6p/jHmFFnqM9xd5ojE0W2VlanxcOKg==} - engines: {node: '>= 14.0.0'} + /express-rate-limit@7.1.0(express@4.18.2): + resolution: {integrity: sha512-pwKOMedrpJJeINON/9jhAa18udV2qwxPZSoklPZK8pmXxUyE5uXaptiwjGw8bZILbxqfUZ/p8pQA99ODjSgA5Q==} + engines: {node: '>= 16'} peerDependencies: express: ^4 || ^5 dependencies: @@ -6034,7 +5871,7 @@ packages: '@firebase/database-compat': 0.3.4 '@firebase/database-types': 0.10.4 '@types/node': 20.4.7 - jsonwebtoken: 9.0.1 + jsonwebtoken: 9.0.2 jwks-rsa: 3.0.1 node-forge: 1.3.1 uuid: 9.0.0 @@ -6168,8 +6005,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - /get-func-name@2.0.0: - resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==} + /get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} dev: true /get-intrinsic@1.2.1: @@ -6704,9 +6541,9 @@ packages: hasBin: true dev: false - /jsonwebtoken@8.5.1: - resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} - engines: {node: '>=4', npm: '>=1.4.28'} + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} dependencies: jws: 3.2.2 lodash.includes: 4.3.0 @@ -6717,16 +6554,6 @@ packages: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 5.7.2 - dev: false - - /jsonwebtoken@9.0.1: - resolution: {integrity: sha512-K8wx7eJ5TPvEjuiVSkv167EVboBDv9PZdDoF7BgeQnBLVvZWW9clr2PsQHVJDTKaEIH5JBIwHujGcHp7GgI2eg==} - engines: {node: '>=12', npm: '>=6'} - dependencies: - jws: 3.2.2 - lodash: 4.17.21 - ms: 2.1.3 semver: 7.5.4 dev: false @@ -6961,7 +6788,7 @@ packages: /loupe@2.3.6: resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==} dependencies: - get-func-name: 2.0.0 + get-func-name: 2.0.2 dev: true /lru-cache@4.0.2: @@ -7213,13 +7040,27 @@ packages: - aws-crt dev: false - /mongoose@6.11.5: - resolution: {integrity: sha512-ZarPe1rCHG4aVb78xLuok4BBIm0HMz/Y/CjxYXCk3Qz1mEhS7bPMy6ZhSX2/Dng//R7ei8719j6K87UVM/1b3g==} + /mongodb@4.17.1: + resolution: {integrity: sha512-MBuyYiPUPRTqfH2dV0ya4dcr2E5N52ocBuZ8Sgg/M030nGF78v855B3Z27mZJnp8PxjnUquEnAtjOsphgMZOlQ==} + engines: {node: '>=12.9.0'} + dependencies: + bson: 4.7.2 + mongodb-connection-string-url: 2.6.0 + socks: 2.7.1 + optionalDependencies: + '@aws-sdk/credential-providers': 3.385.0 + '@mongodb-js/saslprep': 1.1.1 + transitivePeerDependencies: + - aws-crt + dev: false + + /mongoose@6.12.0: + resolution: {integrity: sha512-sd/q83C6TBRPBrrD2A/POSbA/exbCFM2WOuY7Lf2JuIJFlHFG39zYSDTTAEiYlzIfahNOLmXPxBGFxdAch41Mw==} engines: {node: '>=12.0.0'} dependencies: bson: 4.7.2 kareem: 2.5.1 - mongodb: 4.16.0 + mongodb: 4.17.1 mpath: 0.9.0 mquery: 4.0.3 ms: 2.1.3 @@ -7267,6 +7108,13 @@ packages: engines: {node: '>= 0.6'} dev: false + /node-cron@3.0.2: + resolution: {integrity: sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==} + engines: {node: '>=6.0.0'} + dependencies: + uuid: 8.3.2 + dev: false + /node-fetch@2.6.12: resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==} engines: {node: 4.x || >=6.0.0} @@ -7305,9 +7153,9 @@ packages: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} dev: false - /nodemon@2.0.22: - resolution: {integrity: sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==} - engines: {node: '>=8.10.0'} + /nodemon@3.0.1: + resolution: {integrity: sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==} + engines: {node: '>=10'} hasBin: true dependencies: chokidar: 3.5.3 @@ -7315,8 +7163,8 @@ packages: ignore-by-default: 1.0.1 minimatch: 3.1.2 pstree.remy: 1.1.8 - semver: 5.7.2 - simple-update-notifier: 1.1.0 + semver: 7.5.4 + simple-update-notifier: 2.0.0 supports-color: 5.5.0 touch: 3.1.0 undefsafe: 2.0.5 @@ -8279,17 +8127,13 @@ packages: /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true + dev: false /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true dev: false - /semver@7.0.0: - resolution: {integrity: sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==} - hasBin: true - dev: true - /semver@7.5.4: resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} engines: {node: '>=10'} @@ -8388,11 +8232,11 @@ packages: is-arrayish: 0.3.2 dev: false - /simple-update-notifier@1.1.0: - resolution: {integrity: sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==} - engines: {node: '>=8.10.0'} + /simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} dependencies: - semver: 7.0.0 + semver: 7.5.4 dev: true /slash@3.0.0: diff --git a/src/lottery/index.js b/src/lottery/index.js new file mode 100644 index 00000000..9fc6f2c4 --- /dev/null +++ b/src/lottery/index.js @@ -0,0 +1,47 @@ +const express = require("express"); +const { + eventStatusModel, + questModel, + itemModel, + transactionModel, +} = require("./modules/stores/mongo"); + +const { buildResource } = require("../modules/adminResource"); +const { + addOneItemStockAction, + addFiveItemStockAction, +} = require("./modules/items"); + +const { eventConfig } = require("../../loadenv"); + +// [Routes] 기존 docs 라우터의 docs extend +eventConfig && require("./routes/docs")(); + +const lotteryRouter = express.Router(); + +// [Middleware] 모든 API 요청에 대하여 origin 검증 +lotteryRouter.use(require("../middlewares/originValidator")); + +// [Router] APIs +lotteryRouter.use("/global-state", require("./routes/globalState")); +lotteryRouter.use("/transactions", require("./routes/transactions")); +lotteryRouter.use("/items", require("./routes/items")); +lotteryRouter.use("/public-notice", require("./routes/publicNotice")); +lotteryRouter.use("/quests", require("./routes/quests")); + +const itemResource = buildResource([ + addOneItemStockAction, + addFiveItemStockAction, +])(itemModel); +const otherResources = [eventStatusModel, questModel, transactionModel].map( + buildResource() +); + +const contracts = + eventConfig && require(`./modules/contracts/${eventConfig.mode}`); + +module.exports = { + lotteryRouter, + resources: [itemResource, ...otherResources], + contracts, +}; diff --git a/src/lottery/middlewares/checkBanned.js b/src/lottery/middlewares/checkBanned.js new file mode 100644 index 00000000..dca4c310 --- /dev/null +++ b/src/lottery/middlewares/checkBanned.js @@ -0,0 +1,35 @@ +const { eventStatusModel } = require("../modules/stores/mongo"); +const logger = require("../../modules/logger"); + +/** + * 사용자가 차단 되었는지 여부를 판단합니다. + * 차단된 사용자는 이벤트에 한하여 서비스 이용에 제재를 받습니다. + * @param {*} req eventStatus가 성공적일 경우 req.eventStatus = eventStatus로 들어갑니다. + * @param {*} res + * @param {*} next + * @returns + */ +const checkBanned = async (req, res, next) => { + try { + const eventStatus = await eventStatusModel + .findOne({ userId: req.userOid }) + .lean(); + if (!eventStatus) { + return res + .status(400) + .json({ error: "checkBanned: nonexistent eventStatus" }); + } + if (eventStatus.isBanned) { + return res.status(400).json({ error: "checkBanned: banned user" }); + } + req.eventStatus = eventStatus; + next(); + } catch (err) { + logger.error(err); + res.error(500).json({ + error: "checkBanned: internal server error", + }); + } +}; + +module.exports = checkBanned; diff --git a/src/lottery/middlewares/timestampValidator.js b/src/lottery/middlewares/timestampValidator.js new file mode 100644 index 00000000..22511536 --- /dev/null +++ b/src/lottery/middlewares/timestampValidator.js @@ -0,0 +1,19 @@ +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.startAt), + endAt: new Date(eventConfig.endAt), +}; + +const timestampValidator = (req, res, next) => { + if ( + !eventPeriod || + req.timestamp >= eventPeriod.endAt || + req.timestamp < eventPeriod.startAt + ) { + return res.status(400).json({ error: "out of date" }); + } else { + next(); + } +}; + +module.exports = timestampValidator; diff --git a/src/lottery/modules/contracts/2023fall.js b/src/lottery/modules/contracts/2023fall.js new file mode 100644 index 00000000..e318483c --- /dev/null +++ b/src/lottery/modules/contracts/2023fall.js @@ -0,0 +1,271 @@ +const { buildQuests, completeQuest } = require("../quests"); +const mongoose = require("mongoose"); +const logger = require("../../../modules/logger"); + +const { eventConfig } = require("../../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.startAt), + endAt: new Date(eventConfig.endAt), +}; + +/** 전체 퀘스트 목록입니다. */ +const quests = buildQuests({ + firstLogin: { + name: "첫 발걸음", + description: + "로그인만 해도 송편을 얻을 수 있다고?? 이벤트 기간에 처음으로 SPARCS Taxi 서비스에 로그인하여 송편을 받아보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_firstLogin.png", + reward: { + ticket1: 1, + }, + }, + payingAndSending: { + name: "함께하는 택시의 여정", + description: + "2명 이상과 함께 택시를 타고 정산/송금까지 완료해보세요. 최대 3번까지 송편을 받을 수 있어요. 정산/송금 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_payingAndSending.png", + reward: 300, + maxCount: 3, + }, + firstRoomCreation: { + name: "첫 방 개설", + description: + "원하는 택시팟을 찾을 수 없다면? 원하는 조건으로 방 개설 페이지에서 방을 직접 개설해보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_firstRoomCreation.png", + reward: 50, + }, + roomSharing: { + name: "Taxi로 모여라", + description: + "방을 공유해 친구들을 택시에 초대해보세요. 채팅창 상단의 햄버거(☰) 버튼을 누르면 공유하기 버튼을 찾을 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_roomSharing.png", + reward: 50, + isApiRequired: true, + }, + paying: { + name: "정산해요 택시의 숲", + description: + "2명 이상과 함께 택시를 타고 택시비를 결제한 후 정산하기를 요청해보세요. 정산하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_paying.png", + reward: 100, + maxCount: 3, + }, + sending: { + name: "송금 완료! 친구야 고마워", + description: + "2명 이상과 함께 택시를 타고 택시비를 결제한 분께 송금해주세요. 송금하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_sending.png", + reward: 50, + maxCount: 3, + }, + nicknameChanging: { + name: "닉네임 변신", + description: + "닉네임을 변경하여 자신을 표현하세요. 마이페이지수정하기 버튼을 눌러 닉네임을 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_nicknameChanging.png", + reward: 50, + }, + accountChanging: { + name: "계좌 등록은 정산의 시작", + description: + "정산하기 기능을 더욱 빠르고 이용할 수 있다고? 계좌번호를 등록하면 정산하기를 할 때 계좌가 자동으로 입력돼요. 마이페이지수정하기 버튼을 눌러 계좌번호를 등록 또는 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_accountChanging.png", + reward: 50, + }, + adPushAgreement: { + name: "Taxi의 소울메이트", + description: + "Taxi 서비스를 잊지 않도록 가끔 찾아갈게요! 광고성 푸시 알림 수신 동의를 해주시면 방이 많이 모이는 시즌, 주변에 택시앱 사용자가 있을 때 알려드릴 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_adPushAgreement.png", + reward: 50, + }, + eventSharingOnInstagram: { + name: "나만 알기에는 아까운 이벤트", + description: + "추석에 맞춰 쏟아지는 혜택들. 나만 알 순 없죠. 인스타그램 친구들에게 스토리로 공유해보아요. 이벤트 안내 페이지에서 인스타그램 스토리에 공유하기을 눌러보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_eventSharingOnInstagram.png", + reward: 100, + isApiRequired: true, + }, + purchaseSharingOnInstagram: { + name: "상품 획득을 축하합니다", + description: + "이벤트를 열심히 즐긴 당신. 그 상품 획득을 축하 받을 자격이 충분합니다. 달토끼 상점에서 상품 구매 후 뜨는 인스타그램 스토리에 공유하기 버튼을 눌러 상품 획득을 공유하세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2023fall/quest_purchaseSharingOnInstagram.png", + reward: 100, + isApiRequired: true, + }, +}); + +/** + * firstLogin 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @usage lottery/globalState/createUserGlobalStateHandler + */ +const completeFirstLoginQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.firstLogin); +}; + +/** + * payingAndSending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} roomObject - 방의 정보입니다. + * @param {mongoose.Types.ObjectId} roomObject._id - 방의 ObjectId입니다. + * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. + * @param {Date} roomObject.time - 출발 시각입니다. + * @returns {Promise} + * @description 정산 요청 또는 송금이 이루어질 때마다 호출해 주세요. + * @usage rooms - commitPaymentHandler, rooms - settlementHandler + */ +const completePayingAndSendingQuest = async (userId, timestamp, roomObject) => { + logger.info( + `User ${userId} requested to complete payingAndSendingQuest in Room ${roomObject._id}` + ); + + if (roomObject.part.length < 2) return null; + if ( + roomObject.time >= eventPeriod.endAt || + roomObject.time < eventPeriod.startAt + ) + return null; // 택시 출발 시각이 이벤트 기간 내에 포함되지 않는 경우 퀘스트 완료 요청을 하지 않습니다. + + return await completeQuest(userId, timestamp, quests.payingAndSending); +}; + +/** + * firstRoomCreation 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 방을 만들 때마다 호출해 주세요. + * @usage rooms - createHandler + */ +const completeFirstRoomCreationQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.firstRoomCreation); +}; + +/** + * paying 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} roomObject - 방의 정보입니다. + * @param {mongoose.Types.ObjectId} roomObject._id - 방의 ObjectId입니다. + * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. + * @param {Date} roomObject.time - 출발 시각입니다. + * @returns {Promise} + * @description 정산 요청이 이루어질 때마다 호출해 주세요. + * @usage rooms - commitPaymentHandler + */ +const completePayingQuest = async (userId, timestamp, roomObject) => { + logger.info( + `User ${userId} requested to complete payingQuest in Room ${roomObject._id}` + ); + + if (roomObject.part.length < 2) return null; + if ( + roomObject.time >= eventPeriod.endAt || + roomObject.time < eventPeriod.startAt + ) + return null; // 택시 출발 시각이 이벤트 기간 내에 포함되지 않는 경우 퀘스트 완료 요청을 하지 않습니다. + + return await completeQuest(userId, timestamp, quests.paying); +}; + +/** + * sending 퀘스트의 완료를 요청합니다. 방의 참가자 수가 2명 미만이면 요청하지 않습니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} roomObject - 방의 정보입니다. + * @param {mongoose.Types.ObjectId} roomObject._id - 방의 ObjectId입니다. + * @param {Array<{ user: mongoose.Types.ObjectId }>} roomObject.part - 참여자 목록입니다. + * @param {Date} roomObject.time - 출발 시각입니다. + * @returns {Promise} + * @description 송금이 이루어질 때마다 호출해 주세요. + * @usage rooms - settlementHandler + */ +const completeSendingQuest = async (userId, timestamp, roomObject) => { + logger.info( + `User ${userId} requested to complete sendingQuest in Room ${roomObject._id}` + ); + + if (roomObject.part.length < 2) return null; + if ( + roomObject.time >= eventPeriod.endAt || + roomObject.time < eventPeriod.startAt + ) + return null; // 택시 출발 시각이 이벤트 기간 내에 포함되지 않는 경우 퀘스트 완료 요청을 하지 않습니다. + + return await completeQuest(userId, timestamp, quests.sending); +}; + +/** + * nicknameChanging 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 닉네임을 변경할 때마다 호출해 주세요. + * @usage users - editNicknameHandler + */ +const completeNicknameChangingQuest = async (userId, timestamp) => { + return await completeQuest(userId, timestamp, quests.nicknameChanging); +}; + +/** + * accountChanging 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {string} newAccount - 변경된 계좌입니다. + * @returns {Promise} + * @description 계좌를 변경할 때마다 호출해 주세요. + * @usage users - editAccountHandler + */ +const completeAccountChangingQuest = async (userId, timestamp, newAccount) => { + if (newAccount === "") return null; + + return await completeQuest(userId, timestamp, quests.accountChanging); +}; + +/** + * adPushAgreementQuest 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {boolean} advertisement - 변경된 광고성 알림 수신 동의 여부입니다. + * @returns {Promise} + * @description 알림 옵션을 변경할 때마다 호출해 주세요. + * @usage notifications/editOptionsHandler + */ +const completeAdPushAgreementQuest = async ( + userId, + timestamp, + advertisement +) => { + if (!advertisement) return null; + + return await completeQuest(userId, timestamp, quests.adPushAgreement); +}; + +module.exports = { + quests, + completeFirstLoginQuest, + completePayingAndSendingQuest, + completeFirstRoomCreationQuest, + completePayingQuest, + completeSendingQuest, + completeNicknameChangingQuest, + completeAccountChangingQuest, + completeAdPushAgreementQuest, +}; diff --git a/src/lottery/modules/items.js b/src/lottery/modules/items.js new file mode 100644 index 00000000..dae8e906 --- /dev/null +++ b/src/lottery/modules/items.js @@ -0,0 +1,66 @@ +const { itemModel } = require("./stores/mongo"); +const { buildRecordAction } = require("../../modules/adminResource"); +const logger = require("../../modules/logger"); + +const addItemStockActionHandler = (count) => async (req, res, context) => { + const itemId = context.record.params._id; + const oldStock = context.record.params.stock; + + try { + const item = await itemModel + .findOneAndUpdate( + { _id: itemId }, + { + $inc: { + stock: count, + }, + }, + { + new: true, + } + ) + .lean(); + if (!item) throw new Error("Fail to update stock"); + + let record = context.record.toJSON(context.currentAdmin); + record.params = item; + + return { + record, + notice: { + message: `성공적으로 재고 ${count}개를 추가했습니다. (${oldStock} → ${item.stock})`, + }, + response: {}, + }; + } catch (err) { + logger.error(err); + logger.error( + `Fail to process addItemStockActionHandler(${count}) for Item ${itemId}` + ); + + return { + record: context.record.toJSON(context.currentAdmin), + notice: { + message: `재고를 추가하지 못했습니다. 오류 메세지: ${err}`, + type: "error", + }, + }; + } +}; +const addItemStockActionLogs = ["update"]; + +const addOneItemStockAction = buildRecordAction( + "addOneItemStock", + addItemStockActionHandler(1), + addItemStockActionLogs +); +const addFiveItemStockAction = buildRecordAction( + "addFiveItemStock", + addItemStockActionHandler(5), + addItemStockActionLogs +); + +module.exports = { + addOneItemStockAction, + addFiveItemStockAction, +}; diff --git a/src/lottery/modules/populates/transactions.js b/src/lottery/modules/populates/transactions.js new file mode 100644 index 00000000..6d965258 --- /dev/null +++ b/src/lottery/modules/populates/transactions.js @@ -0,0 +1,23 @@ +const transactionPopulateOption = [ + { + path: "item", + select: + "name imageUrl instagramStoryStickerImageUrl price description isDisabled stock itemType", + }, +]; + +const publicNoticePopulateOption = [ + { + path: "userId", + select: "nickname", + }, + { + path: "item", + select: "name price description", + }, +]; + +module.exports = { + transactionPopulateOption, + publicNoticePopulateOption, +}; diff --git a/src/lottery/modules/quests.js b/src/lottery/modules/quests.js new file mode 100644 index 00000000..c994a141 --- /dev/null +++ b/src/lottery/modules/quests.js @@ -0,0 +1,166 @@ +const { + eventStatusModel, + questModel, + itemModel, + transactionModel, +} = require("./stores/mongo"); +const logger = require("../../modules/logger"); +const mongoose = require("mongoose"); + +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.startAt), + endAt: new Date(eventConfig.endAt), +}; + +const requiredQuestFields = ["name", "description", "imageUrl", "reward"]; +const buildQuests = (quests) => { + for (const [id, quest] of Object.entries(quests)) { + // quest에 필수 필드가 모두 포함되어 있는지 확인합니다. + const hasError = requiredQuestFields.reduce((before, field) => { + if (quest[field] !== undefined) return before; + + logger.error(`There is no ${field} field in ${id}Quest`); + return true; + }, false); + if (hasError) return null; + + // quest.id 필드를 설정합니다. + quest.id = id; + + // quest.reward가 number인 경우, object로 변환합니다. + if (typeof quest.reward === "number") { + const credit = quest.reward; + quest.reward = { + credit, + }; + } + + // quest.reward에 누락된 필드가 있는 경우, 기본값(0)으로 설정합니다. + quest.reward.credit = quest.reward.credit ?? 0; + quest.reward.ticket1 = quest.reward.ticket1 ?? 0; + + // quest.maxCount가 없는 경우, 기본값(1)으로 설정합니다. + quest.maxCount = quest.maxCount ?? 1; + + // quest.isApiRequired가 없는 경우, 기본값(false)으로 설정합니다. + quest.isApiRequired = quest.isApiRequired ?? false; + } + + return quests; +}; + +/** + * 퀘스트 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @param {Object} quest - 퀘스트의 정보입니다. + * @param {string} quest.id - 퀘스트의 Id입니다. + * @param {string} quest.name - 퀘스트의 이름입니다. + * @param {Object} quest.reward - 퀘스트의 완료 보상입니다. + * @param {number} quest.reward.credit - 퀘스트의 완료 보상 중 재화의 양입니다. + * @param {number} quest.reward.ticket1 - 퀘스트의 완료 보상 중 일반 티켓의 개수입니다. + * @param {number} quest.maxCount - 퀘스트의 최대 완료 가능 횟수입니다. + * @returns {Object|null} 성공한 경우 Object를, 실패한 경우 null을 반환합니다. 이미 최대 완료 횟수에 도달했거나, 퀘스트가 원격으로 비활성화 된 경우에도 실패로 처리됩니다. + */ +const completeQuest = async (userId, timestamp, quest) => { + try { + // 1단계: 유저의 EventStatus를 가져옵니다. 블록드리스트인지도 확인합니다. + const eventStatus = await eventStatusModel.findOne({ userId }).lean(); + if (!eventStatus || eventStatus.isBanned) return null; + + // 2단계: 이벤트 기간인지 확인합니다. + if (timestamp >= eventPeriod.endAt || timestamp < eventPeriod.startAt) { + logger.info( + `User ${userId} failed to complete auto-disabled ${quest.id}Quest` + ); + return null; + } + + // 3단계: 유저의 퀘스트 완료 횟수를 확인합니다. + const questCount = eventStatus.completedQuests.filter( + (completedQuestId) => completedQuestId === quest.id + ).length; + if (questCount >= quest.maxCount) { + logger.info( + `User ${userId} already completed ${quest.id}Quest ${questCount} times` + ); + return null; + } + + // 4단계: 원격으로 비활성화된 퀘스트인지 확인합니다. + // 비활성화된 퀘스트만 DB에 저장할 것이기 때문에, questDoc이 null이어도 오류를 발생시키면 안됩니다. + const questDoc = await questModel.findOne({ id: quest.id }).lean(); + if (questDoc?.isDisabled) { + logger.info( + `User ${userId} failed to complete disabled ${quest.id}Quest` + ); + return null; + } + + // 5단계: 완료 보상 중 티켓이 있는 경우, 티켓 정보를 가져옵니다. + const ticket1 = + quest.reward.ticket1 && (await itemModel.findOne({ itemType: 1 }).lean()); + if (quest.reward.ticket1 && !ticket1) + throw new Error("Fail to find ticket1"); + + // 6단계: 유저의 EventStatus를 업데이트합니다. + await eventStatusModel.updateOne( + { userId }, + { + $inc: { + creditAmount: quest.reward.credit, + ticket1Amount: quest.reward.ticket1, + }, + $push: { + completedQuests: quest.id, + }, + } + ); + + // 7단계: Transaction을 생성합니다. + const transactionsId = []; + if (quest.reward.credit) { + const transaction = new transactionModel({ + type: "get", + amount: quest.reward.credit, + userId, + questId: quest.id, + comment: `"${quest.name}" 퀘스트를 완료해 송편 ${quest.reward.credit}개를 획득했습니다.`, + }); + await transaction.save(); + + transactionsId.push(transaction._id); + } + if (quest.reward.ticket1) { + const transaction = new transactionModel({ + type: "use", + amount: 0, + userId, + questId: quest.id, + item: ticket1._id, + comment: `"${quest.name}" 퀘스트를 완료해 "${ticket1.name}" ${quest.reward.ticket1}개를 획득했습니다.`, + }); + await transaction.save(); + + transactionsId.push(transaction._id); + } + + logger.info(`User ${userId} successfully completed ${quest.id}Quest`); + return { + quest, + transactionsId, + }; + } catch (err) { + logger.error(err); + logger.error( + `User ${userId} failed to complete ${quest.id}Quest due to exception` + ); + return null; + } +}; + +module.exports = { + buildQuests, + completeQuest, +}; diff --git a/src/lottery/modules/stores/mongo.js b/src/lottery/modules/stores/mongo.js new file mode 100644 index 00000000..0f495f67 --- /dev/null +++ b/src/lottery/modules/stores/mongo.js @@ -0,0 +1,146 @@ +const mongoose = require("mongoose"); +const Schema = mongoose.Schema; + +const integerValidator = { + validator: Number.isInteger, + message: "{VALUE} is not an integer value", +}; + +const eventStatusSchema = Schema({ + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + completedQuests: { + type: [String], + default: [], + }, + creditAmount: { + type: Number, + default: 0, + min: 0, + validate: integerValidator, + }, + ticket1Amount: { + type: Number, + default: 0, + min: 0, + validate: integerValidator, + }, + ticket2Amount: { + type: Number, + default: 0, + min: 0, + validate: integerValidator, + }, + isBanned: { + type: Boolean, + }, +}); + +const questSchema = Schema({ + id: { + type: String, + required: true, + unique: true, + }, + isDisabled: { + type: Boolean, + required: true, + }, +}); + +const itemSchema = Schema({ + name: { + type: String, + required: true, + }, + imageUrl: { + type: String, + required: true, + }, + instagramStoryStickerImageUrl: { + type: String, + }, + price: { + type: Number, + required: true, + min: 0, + validate: integerValidator, + }, + description: { + type: String, + required: true, + }, + isDisabled: { + type: Boolean, + default: false, + }, + stock: { + type: Number, + required: true, + min: 0, + validate: integerValidator, + }, + itemType: { + type: Number, + enum: [0, 1, 2, 3], + required: true, + }, + isRandomItem: { + type: Boolean, + required: true, + }, + randomWeight: { + type: Number, + required: true, + min: 0, + validate: integerValidator, + }, +}); + +const transactionSchema = Schema({ + type: { + type: String, + enum: ["get", "use"], + required: true, + }, + amount: { + type: Number, + required: true, + min: 0, + validate: integerValidator, + }, + userId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + questId: { + type: String, + }, + item: { + type: Schema.Types.ObjectId, + ref: "Item", + }, + itemType: { + type: Number, + enum: [0, 1, 2, 3], + }, + comment: { + type: String, + required: true, + }, +}); +transactionSchema.set("timestamps", { + createdAt: "createAt", + updatedAt: false, +}); + +module.exports = { + eventStatusModel: mongoose.model("EventStatus", eventStatusSchema), + questModel: mongoose.model("Quest", questSchema), + itemModel: mongoose.model("Item", itemSchema), + transactionModel: mongoose.model("Transaction", transactionSchema), +}; diff --git a/src/lottery/routes/docs/globalState.js b/src/lottery/routes/docs/globalState.js new file mode 100644 index 00000000..d1dbff44 --- /dev/null +++ b/src/lottery/routes/docs/globalState.js @@ -0,0 +1,175 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/global-state`; + +const globalStateDocs = {}; +globalStateDocs[`${apiPrefix}/`] = { + get: { + tags: [`${apiPrefix}`], + summary: "Frontend에서 Global state로 관리하는 정보 반환", + description: + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: [ + "isAgreeOnTermsOfEvent", + "creditAmount", + "completedQuests", + "ticket1Amount", + "ticket2Amount", + "quests", + ], + properties: { + isAgreeOnTermsOfEvent: { + type: "boolean", + description: "유저의 이벤트 참여 동의 여부", + example: true, + }, + creditAmount: { + type: "number", + description: "재화 개수. 0 이상입니다.", + example: 10000, + }, + completedQuests: { + type: "array", + description: + "유저가 완료한 퀘스트의 배열. 여러 번 완료할 수 있는 퀘스트의 경우 배열 내에 같은 퀘스트가 여러 번 포함됩니다.", + items: { + type: "string", + description: "Quest의 Id", + example: "QUEST ID", + }, + }, + ticket1Amount: { + type: "number", + description: "일반 티켓의 개수. 0 이상입니다.", + example: 10, + }, + ticket2Amount: { + type: "number", + description: "고급 티켓의 개수. 0 이상입니다.", + example: 10, + }, + quests: { + type: "array", + description: "Quest의 배열", + items: { + type: "object", + required: [ + "id", + "name", + "description", + "imageUrl", + "reward", + "maxCount", + "isApiRequired", + ], + properties: { + id: { + type: "string", + description: "Quest의 Id", + example: "QUEST ID", + }, + name: { + type: "string", + description: "퀘스트의 이름", + example: "최초 로그인 퀘스트", + }, + description: { + type: "string", + description: "퀘스트의 설명", + example: + "처음으로 이벤트 기간 중 Taxi에 로그인하면 송편을 드립니다.", + }, + imageUrl: { + type: "string", + description: "이미지 썸네일 URL", + example: "THUMBNAIL URL", + }, + reward: { + type: "object", + description: "완료 보상", + required: ["credit", "ticket1"], + properties: { + credit: { + type: "number", + description: "완료 보상 중 재화의 개수입니다.", + example: 100, + }, + ticket1: { + type: "number", + description: "완료 보상 중 일반 티켓의 개수입니다.", + example: 1, + }, + }, + }, + maxCount: { + type: "number", + description: "최대 완료 가능 횟수", + example: 1, + }, + isApiRequired: { + type: "boolean", + description: `/events/${eventConfig.mode}/quests/complete/:questId API를 통해 퀘스트 완료를 요청할 수 있는지 여부`, + example: false, + }, + }, + }, + }, + isBanned: { + type: "boolean", + description: "해당 유저 제재 대상 여부", + example: false, + }, + }, + }, + }, + }, + }, + }, + }, +}; +globalStateDocs[`${apiPrefix}/create`] = { + post: { + tags: [`${apiPrefix}`], + summary: "Frontend에서 Global state로 관리하는 정보 생성", + description: + "유저의 재화 개수, 퀘스트 완료 상태 등 Frontend에서 Global state로 관리할 정보를 생성합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/createUserGlobalStateHandler", + }, + }, + }, + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부. 항상 true입니다.", + example: true, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = globalStateDocs; diff --git a/src/lottery/routes/docs/globalStateSchema.js b/src/lottery/routes/docs/globalStateSchema.js new file mode 100644 index 00000000..7a9a5260 --- /dev/null +++ b/src/lottery/routes/docs/globalStateSchema.js @@ -0,0 +1,15 @@ +const globalStateSchema = { + createUserGlobalStateHandler: { + type: "object", + required: ["phoneNumber"], + properties: { + phoneNumber: { + type: "string", + pattern: "^010-?([0-9]{3,4})-?([0-9]{4})$", + }, + }, + errorMessage: "validation: bad request", + }, +}; + +module.exports = globalStateSchema; diff --git a/src/lottery/routes/docs/index.js b/src/lottery/routes/docs/index.js new file mode 100644 index 00000000..fa845079 --- /dev/null +++ b/src/lottery/routes/docs/index.js @@ -0,0 +1,22 @@ +const swaggerUi = require("swagger-ui-express"); +const swaggerDocs = require("../../../routes/docs/swaggerDocs"); +const eventSwaggerDocs = require("./swaggerDocs"); + +swaggerDocs.tags = [...swaggerDocs.tags, ...eventSwaggerDocs.tags]; + +swaggerDocs.paths = { + ...swaggerDocs.paths, + ...eventSwaggerDocs.paths, +}; + +swaggerDocs.components.schemas = { + ...swaggerDocs.components.schemas, + ...eventSwaggerDocs.components.schemas, +}; + +/** 기존 docs 라우터에 이벤트 API docs를 추가합니다. */ +const appendEventDocs = () => { + swaggerUi.setup(swaggerDocs, { explorer: true }); +}; + +module.exports = appendEventDocs; diff --git a/src/lottery/routes/docs/items.js b/src/lottery/routes/docs/items.js new file mode 100644 index 00000000..1137dc2b --- /dev/null +++ b/src/lottery/routes/docs/items.js @@ -0,0 +1,95 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/items`; + +const itemsDocs = {}; +itemsDocs[`${apiPrefix}/list`] = { + get: { + tags: [`${apiPrefix}`], + summary: "상점에서 판매하는 모든 상품의 목록 반환", + description: + "상점에서 판매하는 모든 상품의 목록을 가져옵니다. 매진된 상품도 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: "Item의 배열", + items: { + $ref: "#/components/schemas/item", + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; +itemsDocs[`${apiPrefix}/purchase/:itemId`] = { + post: { + tags: [`${apiPrefix}`], + summary: "상품 구매", + description: "상품을 구매합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/purchaseHandler", + }, + }, + }, + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부. 항상 true입니다.", + example: true, + }, + reward: { + $ref: "#/components/schemas/rewardItem", + }, + }, + }, + }, + }, + }, + 400: { + description: + "checkBanned에서 이벤트에 동의하지 않은 사람과 제재 대상을 선별합니다.", + content: { + "application/json": { + schema: { + type: "object", + required: ["error"], + properties: { + error: { + type: "string", + description: "", + example: "checkBanned: banned user", + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = itemsDocs; diff --git a/src/lottery/routes/docs/itemsSchema.js b/src/lottery/routes/docs/itemsSchema.js new file mode 100644 index 00000000..2549540b --- /dev/null +++ b/src/lottery/routes/docs/itemsSchema.js @@ -0,0 +1,96 @@ +/** Item에 대한 기본적인 프로퍼티를 갖고 있는 스키마입니다. */ +const itemBase = { + type: "object", + required: [ + "_id", + "name", + "imageUrl", + "price", + "description", + "isDisabled", + "stock", + ], + properties: { + _id: { + type: "string", + description: "Item의 ObjectId", + example: "OBJECT ID", + }, + name: { + type: "string", + description: "상품의 이름", + example: "진짜송편", + }, + imageUrl: { + type: "string", + description: "이미지 썸네일 URL", + example: "THUMBNAIL URL", + }, + instagramStoryStickerImageUrl: { + type: "string", + description: "인스타그램 스토리 스티커 이미지 URL", + example: "STICKER URL", + }, + price: { + type: "number", + description: "상품의 가격. 0 이상입니다.", + example: 400, + }, + description: { + type: "string", + description: "상품의 설명", + example: "맛있는 송편입니다.", + }, + isDisabled: { + type: "boolean", + description: "판매 중지 여부", + example: false, + }, + stock: { + type: "number", + description: "남은 상품 재고. 재고가 있는 경우 1, 없는 경우 0입니다.", + example: 1, + }, + }, +}; + +/** itemBase에 itemType(상품 유형) 프로퍼티가 추가된 스키마입니다. */ +const itemWithType = { + type: itemBase.type, + required: itemBase.required.concat(["itemType"]), + properties: { + ...itemBase.properties, + itemType: { + type: "number", + description: + "상품 유형. 0: 일반 상품, 1: 일반 티켓, 2: 고급 티켓, 3: 랜덤박스입니다.", + example: 0, + }, + }, +}; + +const itemsSchema = { + item: itemWithType, + relatedItem: { + ...itemWithType, + description: + "Transaction과 관련된 아이템의 Object. 아이템과 관련된 Transaction인 경우에만 포함됩니다.", + }, + rewardItem: { + ...itemBase, + description: "랜덤박스를 구입한 경우에만 포함됩니다.", + }, + purchaseHandler: { + type: "object", + required: ["itemId"], + properties: { + itemId: { + type: "string", + pattern: "^[a-fA-F\\d]{24}$", + }, + }, + errorMessage: "validation: bad request", + }, +}; + +module.exports = itemsSchema; diff --git a/src/lottery/routes/docs/publicNotice.js b/src/lottery/routes/docs/publicNotice.js new file mode 100644 index 00000000..e5ce2f55 --- /dev/null +++ b/src/lottery/routes/docs/publicNotice.js @@ -0,0 +1,142 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/public-notice`; + +const publicNoticeDocs = {}; +publicNoticeDocs[`${apiPrefix}/recentTransactions`] = { + get: { + tags: [`${apiPrefix}`], + summary: "최근의 유의미한 상품 획득 기록 반환", + description: "모든 유저의 상품 획득 내역 중 유의미한 기록을 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["transactions"], + properties: { + transactions: { + type: "array", + description: "상품 획득 기록의 배열", + items: { + type: "string", + example: + "tu**************님께서 일반응모권을(를) 획득하셨습니다.", + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; +publicNoticeDocs[`${apiPrefix}/leaderboard`] = { + get: { + tags: [`${apiPrefix}`], + summary: "리더보드 반환", + description: + "티켓 개수(고급 티켓은 일반 티켓 5개와 등가입니다.) 기준의 리더보드와 관련된 정보를 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: [ + "leaderboard", + "totalTicket1Amount", + "totalTicket2Amount", + "totalUserAmount", + ], + properties: { + leaderboard: { + type: "array", + description: "상위 20명만 포함된 리더보드", + items: { + type: "object", + required: [ + "nickname", + "profileImageUrl", + "ticket1Amount", + "ticket2Amount", + "probability", + "probabilityV2", + ], + properties: { + nickname: { + type: "string", + description: "유저의 닉네임", + example: "asdf", + }, + profileImageUrl: { + type: "string", + description: "프로필 이미지 URL", + example: "IMAGE URL", + }, + ticket1Amount: { + type: "number", + description: "일반 티켓의 개수. 0 이상입니다.", + example: 10, + }, + ticket2Amount: { + type: "number", + description: "고급 티켓의 개수. 0 이상입니다.", + example: 10, + }, + probability: { + type: "number", + description: "1등 당첨 확률", + example: 0.001, + }, + probabilityV2: { + type: "number", + description: "근사적인 상품 당첨 확률", + example: 0.015, + }, + }, + }, + }, + totalTicket1Amount: { + type: "number", + description: "전체 일반 티켓의 수", + example: 300, + }, + totalTicket2Amount: { + type: "number", + description: "전체 고급 티켓의 수", + example: 100, + }, + totalUserAmount: { + type: "number", + description: "리더보드에 포함된 유저의 수", + example: 100, + }, + rank: { + type: "number", + description: "유저의 리더보드 순위. 1부터 시작합니다.", + example: 30, + }, + probability: { + type: "number", + description: "1등 당첨 확률", + example: 0.00003, + }, + probabilityV2: { + type: "number", + description: "근사적인 상품 당첨 확률", + example: 0.00045, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = publicNoticeDocs; diff --git a/src/lottery/routes/docs/quests.js b/src/lottery/routes/docs/quests.js new file mode 100644 index 00000000..cd624688 --- /dev/null +++ b/src/lottery/routes/docs/quests.js @@ -0,0 +1,62 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/quests`; + +const eventsDocs = {}; +eventsDocs[`${apiPrefix}/complete/:questId`] = { + post: { + tags: [`${apiPrefix}`], + summary: "퀘스트 완료 요청", + description: "퀘스트의 완료를 요청합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/completeHandler", + }, + }, + }, + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["result"], + properties: { + result: { + type: "boolean", + description: "성공 여부", + example: true, + }, + }, + }, + }, + }, + }, + 400: { + description: + "checkBanned에서 이벤트에 동의하지 않은 사람과 제재 대상을 선별합니다.", + content: { + "application/json": { + schema: { + type: "object", + required: ["error"], + properties: { + error: { + type: "string", + description: "", + example: "checkBanned: banned user", + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = eventsDocs; diff --git a/src/lottery/routes/docs/questsSchema.js b/src/lottery/routes/docs/questsSchema.js new file mode 100644 index 00000000..3432cc8c --- /dev/null +++ b/src/lottery/routes/docs/questsSchema.js @@ -0,0 +1,18 @@ +const questsSchema = { + completeHandler: { + type: "object", + required: ["questId"], + properties: { + questId: { + type: "string", + enum: [ + "roomSharing", + "eventSharingOnInstagram", + "purchaseSharingOnInstagram", + ], + }, + }, + }, +}; + +module.exports = questsSchema; diff --git a/src/lottery/routes/docs/swaggerDocs.js b/src/lottery/routes/docs/swaggerDocs.js new file mode 100644 index 00000000..73fa7b66 --- /dev/null +++ b/src/lottery/routes/docs/swaggerDocs.js @@ -0,0 +1,53 @@ +const globalStateDocs = require("./globalState"); +const itemsDocs = require("./items"); +const publicNoticeDocs = require("./publicNotice"); +const questsDocs = require("./quests"); +const transactionsDocs = require("./transactions"); + +const itemsSchema = require("./itemsSchema"); +const globalStateSchema = require("./globalStateSchema"); +const questsSchema = require("./questsSchema"); + +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}`; + +const eventSwaggerDocs = { + tags: [ + { + name: `${apiPrefix}/global-state`, + description: "이벤트 - Global State 관련 API", + }, + { + name: `${apiPrefix}/items`, + description: "이벤트 - 아이템 관련 API", + }, + { + name: `${apiPrefix}/public-notice`, + description: "이벤트 - 아이템 구매, 뽑기, 획득 공지 관련 API", + }, + { + name: `${apiPrefix}/quests`, + description: "이벤트 - 퀘스트 관련 API", + }, + { + name: `${apiPrefix}/transactions`, + description: "이벤트 - 입출금 내역 관련 API", + }, + ], + paths: { + ...globalStateDocs, + ...itemsDocs, + ...publicNoticeDocs, + ...questsDocs, + ...transactionsDocs, + }, + components: { + schemas: { + ...globalStateSchema, + ...itemsSchema, + ...questsSchema, + }, + }, +}; + +module.exports = eventSwaggerDocs; diff --git a/src/lottery/routes/docs/transactions.js b/src/lottery/routes/docs/transactions.js new file mode 100644 index 00000000..9bb82f41 --- /dev/null +++ b/src/lottery/routes/docs/transactions.js @@ -0,0 +1,73 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig.mode}/transactions`; + +const transactionsDocs = {}; +transactionsDocs[`${apiPrefix}/`] = { + get: { + tags: [`${apiPrefix}`], + summary: "재화 입출금 내역 반환", + description: "유저의 재화 입출금 내역을 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["transactions"], + properties: { + transactions: { + type: "array", + description: "유저의 재화 입출금 기록의 배열", + items: { + type: "object", + required: ["_id", "type", "amount", "comment", "createAt"], + properties: { + _id: { + type: "string", + description: "Transaction의 ObjectId", + example: "OBJECT ID", + }, + type: { + type: "string", + description: + "재화의 입금 또는 출금 여부. get 또는 use 중 하나입니다.", + example: "use", + }, + amount: { + type: "number", + description: "재화의 변화량의 절댓값", + example: 50, + }, + questId: { + type: "string", + description: + "Transaction과 관련된 퀘스트의 Id. 퀘스트와 관련된 Transaction인 경우에만 포함됩니다.", + example: "QUEST ID", + }, + item: { + $ref: "#/components/schemas/relatedItem", + }, + comment: { + type: "string", + description: "입출금 내역에 대한 설명", + example: "랜덤 상자 구입 - 50개 차감", + }, + createAt: { + type: "string", + description: "입출금이 일어난 시각", + example: "2023-01-01 00:00:00", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = transactionsDocs; diff --git a/src/lottery/routes/globalState.js b/src/lottery/routes/globalState.js new file mode 100644 index 00000000..cb69d782 --- /dev/null +++ b/src/lottery/routes/globalState.js @@ -0,0 +1,20 @@ +const express = require("express"); + +const router = express.Router(); +const globalStateHandlers = require("../services/globalState"); +const { validateBody } = require("../../middlewares/ajv"); +const globalStateSchema = require("./docs/globalStateSchema"); + +router.get("/", globalStateHandlers.getUserGlobalStateHandler); + +// 아래의 Endpoint 접근 시 로그인 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/create", + validateBody(globalStateSchema.createUserGlobalStateHandler), + globalStateHandlers.createUserGlobalStateHandler +); + +module.exports = router; diff --git a/src/lottery/routes/items.js b/src/lottery/routes/items.js new file mode 100644 index 00000000..7d1513f5 --- /dev/null +++ b/src/lottery/routes/items.js @@ -0,0 +1,22 @@ +const express = require("express"); + +const router = express.Router(); +const itemsHandlers = require("../services/items"); + +const { validateParams } = require("../../middlewares/ajv"); +const itemsSchema = require("./docs/itemsSchema"); + +router.get("/list", itemsHandlers.listHandler); + +// 아래의 Endpoint 접근 시 로그인, 블록드리스트 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/checkBanned")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/purchase/:itemId", + validateParams(itemsSchema.purchaseHandler), + itemsHandlers.purchaseHandler +); + +module.exports = router; diff --git a/src/lottery/routes/publicNotice.js b/src/lottery/routes/publicNotice.js new file mode 100644 index 00000000..ac17e481 --- /dev/null +++ b/src/lottery/routes/publicNotice.js @@ -0,0 +1,13 @@ +const express = require("express"); + +const router = express.Router(); +const publicNoticeHandlers = require("../services/publicNotice"); + +// 상점 공지는 로그인을 요구하지 않습니다. +router.get( + "/recentTransactions", + publicNoticeHandlers.getRecentPurchaceItemListHandler +); +router.get("/leaderboard", publicNoticeHandlers.getTicketLeaderboardHandler); + +module.exports = router; diff --git a/src/lottery/routes/quests.js b/src/lottery/routes/quests.js new file mode 100644 index 00000000..2aa55624 --- /dev/null +++ b/src/lottery/routes/quests.js @@ -0,0 +1,19 @@ +const express = require("express"); + +const router = express.Router(); +const questsHandlers = require("../services/quests"); + +const { validateParams } = require("../../middlewares/ajv"); +const questsSchema = require("./docs/questsSchema"); + +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/checkBanned")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/complete/:questId", + validateParams(questsSchema.completeHandler), + questsHandlers.completeHandler +); + +module.exports = router; diff --git a/src/lottery/routes/transactions.js b/src/lottery/routes/transactions.js new file mode 100644 index 00000000..391e0cf6 --- /dev/null +++ b/src/lottery/routes/transactions.js @@ -0,0 +1,11 @@ +const express = require("express"); + +const router = express.Router(); +const transactionsHandlers = require("../services/transactions"); + +// 라우터 접근 시 로그인 필요 +router.use(require("../../middlewares/auth")); + +router.get("/", transactionsHandlers.getUserTransactionsHandler); + +module.exports = router; diff --git a/src/lottery/services/globalState.js b/src/lottery/services/globalState.js new file mode 100644 index 00000000..13ce9bb7 --- /dev/null +++ b/src/lottery/services/globalState.js @@ -0,0 +1,73 @@ +const { eventStatusModel } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); +const logger = require("../../modules/logger"); +const { isLogin, getLoginInfo } = require("../../modules/auths/login"); + +const { eventConfig } = require("../../../loadenv"); +const contracts = + eventConfig && require(`../modules/contracts/${eventConfig.mode}`); +const quests = contracts ? Object.values(contracts.quests) : undefined; + +const getUserGlobalStateHandler = async (req, res) => { + try { + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + const eventStatus = + userId && + (await eventStatusModel.findOne({ userId }, "-_id -userId -__v").lean()); + if (eventStatus) + return res.json({ + isAgreeOnTermsOfEvent: true, + ...eventStatus, + quests, + }); + else + return res.json({ + isAgreeOnTermsOfEvent: false, + completedQuests: [], + creditAmount: 0, + ticket1Amount: 0, + ticket2Amount: 0, + quests, + }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "GlobalState/ : internal server error" }); + } +}; + +const createUserGlobalStateHandler = async (req, res) => { + try { + let eventStatus = await eventStatusModel + .findOne({ userId: req.userOid }) + .lean(); + if (eventStatus) + return res + .status(400) + .json({ error: "GlobalState/Create : already created" }); + + eventStatus = new eventStatusModel({ + userId: req.userOid, + creditAmount: 100, // 초기 송편 개수는 0개가 아닌 100개입니다. + }); + await eventStatus.save(); + + //logic2. 수집한 유저 전화번호 user Scheme 에 저장 + const user = await userModel.findOne({ _id: req.userOid }); + user.phoneNumber = req.body.phoneNumber; + await user.save(); + + await contracts.completeFirstLoginQuest(req.userOid, req.timestamp); + + res.json({ result: true }); + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "GlobalState/Create : internal server error" }); + } +}; + +module.exports = { + getUserGlobalStateHandler, + createUserGlobalStateHandler, +}; diff --git a/src/lottery/services/items.js b/src/lottery/services/items.js new file mode 100644 index 00000000..21c4310c --- /dev/null +++ b/src/lottery/services/items.js @@ -0,0 +1,218 @@ +const { + eventStatusModel, + itemModel, + transactionModel, +} = require("../modules/stores/mongo"); +const logger = require("../../modules/logger"); + +const updateEventStatus = async ( + userId, + { creditDelta = 0, ticket1Delta = 0, ticket2Delta = 0 } = {} +) => + await eventStatusModel.updateOne( + { userId }, + { + $inc: { + creditAmount: creditDelta, + ticket1Amount: ticket1Delta, + ticket2Amount: ticket2Delta, + }, + } + ); + +const hideItemStock = (item) => { + item.stock = item.stock > 0 ? 1 : 0; + return item; +}; + +const getRandomItem = async (req, depth) => { + if (depth >= 10) { + logger.error(`User ${req.userOid} failed to open random box`); + return null; + } + + const items = await itemModel + .find({ + isRandomItem: true, + stock: { $gt: 0 }, + isDisabled: false, + }) + .lean(); + const randomItems = items + .map((item) => Array(item.randomWeight).fill(item)) + .reduce((a, b) => a.concat(b), []); + const dumpRandomItems = randomItems + .map((item) => item._id.toString()) + .join(","); + + logger.info( + `User ${req.userOid}'s ${ + depth + 1 + }th random box probability is: [${dumpRandomItems}]` + ); + + if (randomItems.length === 0) return null; + + const randomItem = + randomItems[Math.floor(Math.random() * randomItems.length)]; + try { + // 1단계: 재고를 차감합니다. + const newRandomItem = await itemModel + .findOneAndUpdate( + { _id: randomItem._id, stock: { $gt: 0 } }, + { + $inc: { + stock: -1, + }, + }, + { + new: true, + fields: { + itemType: 0, + isRandomItem: 0, + randomWeight: 0, + }, + } + ) + .lean(); + if (!newRandomItem) { + throw new Error(`Item ${randomItem._id.toString()} was already sold out`); + } + + // 2단계: 유저 정보를 업데이트합니다. + await updateEventStatus(req.userOid, { + ticket1Delta: randomItem.itemType === 1 ? 1 : 0, + ticket2Delta: randomItem.itemType === 2 ? 1 : 0, + }); + + // 3단계: Transaction을 추가합니다. + const transaction = new transactionModel({ + type: "use", + amount: 0, + userId: req.userOid, + item: randomItem._id, + itemType: randomItem.itemType, + comment: `랜덤박스에서 "${randomItem.name}" 1개를 획득했습니다.`, + }); + await transaction.save(); + + return newRandomItem; + } catch (err) { + logger.error(err); + logger.warn( + `User ${req.userOid}'s ${depth + 1}th random box failed due to exception` + ); + + return await getRandomItem(req, depth + 1); + } +}; + +const listHandler = async (_, res) => { + try { + const items = await itemModel + .find( + {}, + "name imageUrl instagramStoryStickerImageUrl price description isDisabled stock itemType" + ) + .lean(); + res.json({ items: items.map(hideItemStock) }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Items/List : internal server error" }); + } +}; + +const purchaseHandler = async (req, res) => { + try { + const { itemId } = req.params; + const item = await itemModel.findOne({ _id: itemId }).lean(); + if (!item) + return res.status(400).json({ error: "Items/Purchase : invalid Item" }); + + // 구매 가능 조건: 크레딧이 충분하며, 재고가 남아있으며, 판매 중인 아이템이어야 합니다. + if (item.isDisabled) + return res.status(400).json({ error: "Items/Purchase : disabled item" }); + if (req.eventStatus.creditAmount < item.price) + return res + .status(400) + .json({ error: "Items/Purchase : not enough credit" }); + if (item.stock <= 0) + return res + .status(400) + .json({ error: "Items/Purchase : item out of stock" }); + + // 1단계: 재고를 차감합니다. + const { modifiedCount } = await itemModel.updateOne( + { _id: item._id, stock: { $gt: 0 } }, + { + $inc: { + stock: -1, + }, + } + ); + if (modifiedCount === 0) + return res + .status(400) + .json({ error: "Items/Purchase : item out of stock" }); + + // 2단계: 유저 정보를 업데이트합니다. + await updateEventStatus(req.userOid, { + creditDelta: -item.price, + ticket1Delta: item.itemType === 1 ? 1 : 0, + ticket2Delta: item.itemType === 2 ? 1 : 0, + }); + + // 3단계: Transaction을 추가합니다. + const transaction = new transactionModel({ + type: "use", + amount: item.price, + userId: req.userOid, + item: item._id, + itemType: item.itemType, + comment: `송편 ${item.price}개를 사용해 "${item.name}" 1개를 획득했습니다.`, + }); + await transaction.save(); + + // 4단계: 랜덤박스인 경우 아이템을 추첨합니다. + if (item.itemType !== 3) return res.json({ result: true }); + + const randomItem = await getRandomItem(req, 0); + if (!randomItem) { + // 랜덤박스가 실패한 경우, 상태를 구매 이전으로 되돌립니다. + // TODO: Transactions 도입 후 이 코드는 삭제합니다. + logger.info(`User ${req.userOid}'s status will be restored`); + + await transactionModel.deleteOne({ _id: transaction._id }); + await updateEventStatus(req.userOid, { + creditDelta: item.price, + }); + await itemModel.updateOne( + { _id: item._id }, + { + $inc: { + stock: 1, + }, + } + ); + + logger.info(`User ${req.userOid}'s status was successfully restored`); + + return res + .status(500) + .json({ error: "Items/Purchase : random box error" }); + } + + res.json({ + result: true, + reward: hideItemStock(randomItem), + }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Items/Purchase : internal server error" }); + } +}; + +module.exports = { + listHandler, + purchaseHandler, +}; diff --git a/src/lottery/services/publicNotice.js b/src/lottery/services/publicNotice.js new file mode 100644 index 00000000..f81aaf87 --- /dev/null +++ b/src/lottery/services/publicNotice.js @@ -0,0 +1,178 @@ +const { transactionModel } = require("../modules/stores/mongo"); +const { eventStatusModel } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); +const { isLogin, getLoginInfo } = require("../../modules/auths/login"); +const logger = require("../../modules/logger"); +const { + publicNoticePopulateOption, +} = require("../modules/populates/transactions"); + +/** + * getValueRank 사용자의 상품 구매 내역 또는 경품 추첨 내역의 순위 결정을 위한 가치를 평가하는 함수 + * 상품 가격이 높을수록, 상품 구매 일시가 최근일 수록 가치가 높습니다. + * 요청이 들어온 시간과 트랜젝션이 있었던 시간의 차를 로그스케일로 변환후 이를 가격에 곱하여 가치를 구합니다. + * 시간의 단위는 millisecond입니다. + * t_1/2(반감기, half-life)는 4일입니다 . + * (2일 = 2 * 24 * 60 * 60 * 1000 = 172800000ms) + * Tau는 반감기를 결정하는 상수입니다. + * Tau = t_1/2 / ln(2) 로 구할 수 있습니다. + * Tau = 249297703 + * N_0(초기값)는 item.price를 사용합니다. + * @param {Object} item + * @param {number|Date} createAt + * @param {number|Date} timestamp + * @returns {Promise} + * @description 가치를 기준으로 정렬하기 위해 사용됨 + */ +const getValueRank = (item, createAt, timestamp) => { + const t = timestamp - new Date(createAt).getTime(); // millisecond + const Tau = 249297703; + return item.price * Math.exp(-t / Tau); +}; + +const getRecentPurchaceItemListHandler = async (req, res) => { + try { + const transactions = ( + await transactionModel + .find({ type: "use", itemType: 0 }) + .sort({ createAt: -1 }) + .limit(1000) + .populate(publicNoticePopulateOption) + .lean() + ) + .sort( + (x, y) => + getValueRank(y.item, y.createAt, req.timestamp) - + getValueRank(x.item, x.createAt, req.timestamp) + ) + .slice(0, 5) + .map(({ userId, item, comment, createAt }) => ({ + text: `${userId.nickname}님께서 ${item.name}${ + comment.startsWith("송편") + ? "을(를) 구입하셨습니다." + : comment.startsWith("랜덤박스") + ? "을(를) 뽑았습니다." + : "을(를) 획득하셨습니다." + }`, + createAt, + })); + res.json({ transactions }); + } catch (err) { + logger.error(err); + res.status(500).json({ + error: "PublicNotice/RecentTransactions : internal server error", + }); + } +}; + +const calculateProbabilityV2 = (users, weightSum, weight) => { + // 유저 수가 상품 수보다 적거나 같으면 무조건 상품을 받게된다. + if (users.length <= 15) return 1; + + /** + * 실험적으로 발견한 사실 + * + * x를 티켓 수라고 하면, 실제 당첨 확률은 1-a^x꼴의 지수함수를 따르는 것을 시뮬레이션을 통해 발견하였다. + * 이때 a는 전체 유저 수, 전체 티켓 수, 각 유저의 티켓 수에 의해 결정되는 값이다. + * + * a값의 계산 과정 + * + * 매번 a값을 정확하게 계산하는 것은 현실적으로 어렵다. + * 따라서, 모든 유저가 같은 수의 티켓을 가지고 있다고 가정하고 a를 계산한 뒤, 이를 확률 계산에 사용한다. + * M을 전체 티켓 수, N을 전체 유저 수라고 하자. + * 모든 유저가 같은 수의 티켓 M/N개를 가지고 있다면, 한 유저가 상품에 당첨될 확률은 15/N임을 직관적으로 알 수 있다. + * 실제 당첨 확률은 1-a^x꼴의 지수함수를 따르므로, 1-a^(M/N) = 15/N이라는 식을 세울 수 있다. + * a에 대해 정리하면, a = (1-15/N)^(N/M)을 얻는다. + */ + const base = Math.pow(1 - 15 / users.length, users.length / weightSum); + return 1 - Math.pow(base, weight); +}; + +const getTicketLeaderboardHandler = async (req, res) => { + try { + const users = await eventStatusModel + .find({ + $or: [{ ticket1Amount: { $gt: 0 } }, { ticket2Amount: { $gt: 0 } }], + }) + .lean(); + const sortedUsers = users + .map((user) => ({ + userId: user.userId.toString(), + ticket1Amount: user.ticket1Amount, + ticket2Amount: user.ticket2Amount, + weight: user.ticket1Amount + 5 * user.ticket2Amount, + })) + .sort((a, b) => -(a.weight - b.weight)); + + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + let rank = -1; + + const [weightSum, totalTicket1Amount, totalTicket2Amount] = + sortedUsers.reduce( + ( + [_weightSum, _totalTicket1Amount, _totalTicket2Amount], + user, + index + ) => { + if (rank < 0 && user.userId === userId) { + rank = index; + } + return [ + _weightSum + user.weight, + _totalTicket1Amount + user.ticket1Amount, + _totalTicket2Amount + user.ticket2Amount, + ]; + }, + [0, 0, 0] + ); + const leaderboard = await Promise.all( + sortedUsers.slice(0, 20).map(async (user) => { + const userInfo = await userModel.findOne({ _id: user.userId }).lean(); + if (!userInfo) { + logger.error(`Fail to find user ${user.userId}`); + return null; + } + return { + nickname: userInfo.nickname, + profileImageUrl: userInfo.profileImageUrl, + ticket1Amount: user.ticket1Amount, + ticket2Amount: user.ticket2Amount, + probability: user.weight / weightSum, + probabilityV2: calculateProbabilityV2(users, weightSum, user.weight), + }; + }) + ); + if (leaderboard.includes(null)) + return res + .status(500) + .json({ error: "PublicNotice/Leaderboard : internal server error" }); + + res.json({ + leaderboard, + totalTicket1Amount, + totalTicket2Amount, + totalUserAmount: users.length, + ...(rank >= 0 + ? { + rank: rank + 1, + probability: sortedUsers[rank].weight / weightSum, + probabilityV2: calculateProbabilityV2( + users, + weightSum, + sortedUsers[rank].weight + ), + } + : {}), + }); + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "PublicNotice/Leaderboard : internal server error" }); + } +}; + +module.exports = { + getRecentPurchaceItemListHandler, + getTicketLeaderboardHandler, +}; diff --git a/src/lottery/services/quests.js b/src/lottery/services/quests.js new file mode 100644 index 00000000..5dd4f0e5 --- /dev/null +++ b/src/lottery/services/quests.js @@ -0,0 +1,25 @@ +const { completeQuest } = require("../modules/quests"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); +const quests = eventConfig + ? require(`../modules/contracts/${eventConfig.mode}`).quests + : undefined; + +const completeHandler = async (req, res) => { + try { + const quest = quests[req.params.questId]; + if (!quest || !quest.isApiRequired) + return res.status(400).json({ error: "Quests/Complete: invalid Quest" }); + + const result = await completeQuest(req.userOid, req.timestamp, quest); + res.json({ result: !!result }); // boolean으로 변환하기 위해 !!를 사용합니다. + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Quests/Complete: internal server error" }); + } +}; + +module.exports = { + completeHandler, +}; diff --git a/src/lottery/services/transactions.js b/src/lottery/services/transactions.js new file mode 100644 index 00000000..695cf3bf --- /dev/null +++ b/src/lottery/services/transactions.js @@ -0,0 +1,35 @@ +const { transactionModel } = require("../modules/stores/mongo"); +const logger = require("../../modules/logger"); +const { + transactionPopulateOption, +} = require("../modules/populates/transactions"); + +const hideItemStock = (transaction) => { + if (transaction.item) { + transaction.item.stock = transaction.item.stock > 0 ? 1 : 0; + } + return transaction; +}; + +const getUserTransactionsHandler = async (req, res) => { + try { + // userId는 이미 Frontend에서 알고 있고, 중복되는 값이므로 제외합니다. + const transactions = await transactionModel + .find({ userId: req.userOid }, "-userId -__v") + .populate(transactionPopulateOption) + .lean(); + if (transactions) + res.json({ + transactions: transactions.map(hideItemStock), + }); + else + res.status(500).json({ error: "Transactions/ : internal server error" }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Transactions/ : internal server error" }); + } +}; + +module.exports = { + getUserTransactionsHandler, +}; diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js index 3e3e7f35..e521f9f4 100644 --- a/src/middlewares/auth.js +++ b/src/middlewares/auth.js @@ -8,8 +8,9 @@ const authMiddleware = (req, res, next) => { error: "not logged in", }); } else { - const { id } = getLoginInfo(req); + const { id, oid } = getLoginInfo(req); req.userId = id; + req.userOid = oid; next(); } }; diff --git a/src/middlewares/authAdmin.js b/src/middlewares/authAdmin.js index 218f724d..8db1f2a0 100644 --- a/src/middlewares/authAdmin.js +++ b/src/middlewares/authAdmin.js @@ -1,31 +1,30 @@ // 관리자 유무를 확인하기 위한 미들웨어입니다. const { isLogin, getLoginInfo } = require("../modules/auths/login"); -const { frontUrl } = require("../../loadenv"); const { userModel, adminIPWhitelistModel } = require("../modules/stores/mongo"); const authAdminMiddleware = async (req, res, next) => { try { // 로그인 여부를 확인 - if (!isLogin(req)) return res.redirect(frontUrl); + if (!isLogin(req)) return res.redirect(req.origin); // 관리자 유무를 확인 const { id } = getLoginInfo(req); const user = await userModel.findOne({ id }); - if (!user.isAdmin) return res.redirect(frontUrl); + if (!user.isAdmin) return res.redirect(req.origin); // 접속한 IP가 화이트리스트에 있는지 확인 const ipWhitelist = await adminIPWhitelistModel.find({}); - if (!req.clientIP) return res.redirect(frontUrl); + if (!req.clientIP) return res.redirect(req.origin); if ( ipWhitelist.length > 0 && ipWhitelist.map((x) => x.ip).indexOf(req.clientIP) < 0 ) - return res.redirect(frontUrl); + return res.redirect(req.origin); next(); } catch (e) { - res.redirect(frontUrl); + res.redirect(req.origin); } }; diff --git a/src/middlewares/cors.js b/src/middlewares/cors.js new file mode 100644 index 00000000..0b644243 --- /dev/null +++ b/src/middlewares/cors.js @@ -0,0 +1,8 @@ +var cors = require("cors"); +const { corsWhiteList } = require("../../loadenv"); + +module.exports = cors({ + origin: corsWhiteList, + credentials: true, + exposedHeaders: ["Date"], +}); diff --git a/src/middlewares/errorHandler.js b/src/middlewares/errorHandler.js new file mode 100644 index 00000000..369391c6 --- /dev/null +++ b/src/middlewares/errorHandler.js @@ -0,0 +1,24 @@ +const logger = require("../modules/logger"); + +/** + * Express app에서 사용할 custom global error handler를 정의합니다. + * @summary Express 핸들러에서 발생한 uncaught exception은 이 핸들러를 통해 처리됩니다. + * Express에서 제공하는 기본 global error handler는 클라이언트에 오류 발생 call stack을 그대로 반환합니다. + * 이 때문에 클라이언트에게 잠재적으로 보안 취약점을 노출할 수 있으므로, call stack을 반환하지 않는 error handler를 정의합니다. + * @param {Error} err - 오류 객체 + * @param {Express.Request} req - 요청 객체 + * @param {Express.Response} res - 응답 객체 + * @param {Function} next - 다음 미들웨어 함수. Express에서는 next 함수에 err 인자를 넘겨주면 기본 global error handler가 호출됩니다. + */ +const errorHandler = (err, req, res, next) => { + // 이미 클라이언트에 HTTP 응답 헤더를 전송한 경우, 응답 헤더를 다시 전송하지 않아야 합니다. + // 클라이언트에게 스트리밍 형태로 응답을 전송하는 도중 오류가 발생하는 경우가 여기에 해당합니다. + // 이럴 때 기본 global error handler를 호출하면 기본 global error handler가 클라이언트와의 연결을 종료시켜 줍니다. + logger.error(err); + if (res.headersSent) { + return next(err); + } + res.status(500).send("internal server error"); +}; + +module.exports = errorHandler; diff --git a/src/middlewares/information.js b/src/middlewares/information.js index f5d5b014..f7197d1f 100644 --- a/src/middlewares/information.js +++ b/src/middlewares/information.js @@ -1,4 +1,4 @@ -module.exports = (req, _, next) => { +module.exports = (req, res, next) => { req.clientIP = req.headers["x-forwarded-for"] || req.connection.remoteAddress; req.timestamp = Date.now(); next(); diff --git a/src/middlewares/limitRate.js b/src/middlewares/limitRate.js index b218b0ba..c5069c8f 100644 --- a/src/middlewares/limitRate.js +++ b/src/middlewares/limitRate.js @@ -2,9 +2,13 @@ const rateLimit = require("express-rate-limit"); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes - max: 1500, // Limit each IP to 100 requests per `window` (here, per 15 minutes) + max: 1500, // Limit each IP to 1500 requests per `window` (here, per 15 minutes) standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers + validate: { + default: true, + trustProxy: false, // Disable the validation error caused by 'trust proxy' set to true + }, }); module.exports = limiter; diff --git a/src/middlewares/originValidator.js b/src/middlewares/originValidator.js new file mode 100644 index 00000000..2aec851d --- /dev/null +++ b/src/middlewares/originValidator.js @@ -0,0 +1,13 @@ +module.exports = (req, res, next) => { + req.origin = + req.headers.origin || + req.headers.referer || + req.session?.loginAfterState?.redirectOrigin; // sparcssso/callback 요청은 헤더에 origin이 없음 + + if (!req.origin) { + return res.status(400).json({ + error: "Bad Request : request must have origin in header", + }); + } + next(); +}; diff --git a/src/modules/adminResource.js b/src/modules/adminResource.js new file mode 100644 index 00000000..f5ba3eb8 --- /dev/null +++ b/src/modules/adminResource.js @@ -0,0 +1,105 @@ +const { buildFeature } = require("adminjs"); +const { adminLogModel } = require("./stores/mongo"); + +const createLog = async (req, action, target) => { + const newLog = new adminLogModel({ + user: req.userOid, // Log 취급자 User + time: req.timestamp, // Log 발생 시각 + ip: req.clientIP, // 접속 IP 주소 + target, // 처리한 정보주체 정보 + action, // 수행 업무 + }); + await newLog.save(); +}; + +const generateTarget = (context, isList) => { + const modelName = context?.resource?.MongooseModel?.modelName; + const recordLength = `(length = ${context?.records?.length})`; + const recordId = `(_id = ${context?.record?.params?._id})`; + + return isList + ? `List<${modelName}>${recordLength}` + : `${modelName}${recordId}`; +}; + +const defaultActionAfterHandler = (actionName) => async (res, req, context) => { + if ( + ["new", "edit", "bulkDelete"].includes(actionName) && + req.method !== "post" + ) + return res; // 왜 필요한건지는 잘 모르겠으나, 기존에 존재하던 코드라 지우지 않고 유지합니다. + + const [action, isList] = { + list: ["read", true], + show: ["read", false], + new: ["create", false], + edit: ["update", false], + delete: ["delete", false], + bulkDelete: ["delete", true], + }?.[actionName]; + + const target = generateTarget(context, isList); + await createLog(req, action, target); + + return res; +}; + +const defaultActionLogFeature = buildFeature({ + actions: ["list", "show", "new", "edit", "delete", "bulkDelete"].reduce( + (before, actionName) => ({ + ...before, + [actionName]: { + after: defaultActionAfterHandler(actionName), + }, + }), + {} + ), +}); + +const recordActionAfterHandler = (actions) => async (res, req, context) => { + if (!res.response) return res; + + const actionsWrapper = Array.isArray(actions) ? actions : [actions]; + for (const action of actionsWrapper) { + if (typeof action === "string") { + const target = generateTarget(context, false); + await createLog(req, action, target); + } else { + await createLog(req, action.action, action.target(res, req, context)); + } + } + + return res; +}; + +const buildRecordAction = (actionName, handler, logActions) => ({ + actionName, + actionType: "record", + component: false, + handler, + after: recordActionAfterHandler(logActions), +}); + +const buildResource = + (actions = [], features = []) => + (resource) => ({ + resource, + options: { + actions: actions.reduce( + (before, action) => ({ + ...before, + [action.actionName]: { + ...action, // actionName이 포함되는 문제가 있지만 있어도 상관은 없을 것 같습니다. + }, + }), + {} + ), + }, + features: features.concat([defaultActionLogFeature]), + }); + +module.exports = { + generateTarget, + buildRecordAction, + buildResource, +}; diff --git a/src/modules/auths/login.js b/src/modules/auths/login.js index f8e4308c..8181c991 100644 --- a/src/modules/auths/login.js +++ b/src/modules/auths/login.js @@ -27,7 +27,7 @@ const login = (req, sid, id, oid, name) => { const logout = (req) => { // 로그아웃 전 socket.io 소켓들 연결부터 끊기 const io = req.app.get("io"); - if (io) io.in(req.session.id).disconnectSockets(true); + if (io) io.in(`session-${req.session.id}`).disconnectSockets(true); req.session.destroy((err) => { if (err) logger.error(err); diff --git a/src/modules/logger.js b/src/modules/logger.js index 0e02e56e..e532e7aa 100644 --- a/src/modules/logger.js +++ b/src/modules/logger.js @@ -1,72 +1,95 @@ -const { createLogger, format, transports } = require("winston"); -const dailyRotateFileTransport = require("winston-daily-rotate-file"); const path = require("path"); +const { createLogger, format, transports } = require("winston"); +const DailyRotateFileTransport = require("winston-daily-rotate-file"); const { nodeEnv } = require("../../loadenv"); -// 로깅에 사용하기 위한 포맷을 추가로 정의합니다. -const customFormat = { - time: "YYYY-MM-DD HH:mm:ss", // 로깅 시각 - line: format.printf(({ level, message, timestamp, stack }) => { - return `${timestamp} [${level}]: ${message} ${ - level === "error" ? stack : "" - }`; - }), // 메시지 포맷 - fileDate: "YYYY-MM-DD-HH", // 파일명에 포함되는 시각 -}; +// logger에서 사용할 포맷들을 정의합니다. +const baseFormat = format.combine( + format.timestamp({ format: "YYYY-MM-DD HH:mm:ss(UTCZ)" }), + format.errors({ stack: true }), + format.splat(), + format.json() +); +const finalFormat = format.printf( + ({ level, message, timestamp, stack }) => + `${timestamp} [${level}]: ${message} ${ + level === "error" && stack !== undefined ? stack : "" + }` +); + +// 파일 출력 시 사용될 포맷. 색 관련 특수문자가 파일에 쓰여지는 것을 방지하기 위해 색상이 표시되지 않습니다. +const uncolorizedFormat = format.combine( + baseFormat, + format.uncolorize(), + finalFormat +); + +// 콘솔 출력 시 사용될 포맷. 색상이 표시됩니다. +const colorizedFormat = format.combine( + baseFormat, + format.colorize({ all: true }), + finalFormat +); + +// 로그 파일명에 포함되는 시각 +const datePattern = "YYYY-MM-DD-HH"; +// 로그 파일당 최대 크기(=5MB). +const maxSize = 5 * 1024 * 1024; + +// 콘솔에 출력하기 위한 winston transport +const consoleTransport = new transports.Console(); /** - * console.log 대신 사용되는 winston Logger 객체입니다. + * console.log()와 console.error() 대신 사용되는 winston Logger 객체입니다. * - * 전체 로그는 *.combined.log 파일에, 예외 처리로 핸들링 된 오류 로그는 *.error.log 파일에, 예외 처리가 되지 않은 오류는 *.unhandled.log에 저장됩니다. - * @property {function} info() - 일반적인 정보 기록을 위한 로깅(API 접근 기록 등)을 위해 사용합니다. - * @property {function} error() - 오류 메시지를 로깅하기 위해 사용합니다. + * - "production" 환경: 모든 로그는 파일 시스템에 저장되고, 콘솔로도 출력됩니다. + * - "development" & "test" 환경: 모든 로그는 콘솔에 출력됩니다. + * + * @method info(message: string, callback: winston.LogCallback) - 일반적인 정보(API 접근 등) 기록을 위해 사용합니다. + * @method error(message: string, callback: winston.LogCallback) - 오류 메시지를 기록하기 위해 사용합니다. */ -const logger = createLogger({ - format: format.combine( - format.timestamp({ format: customFormat.time }), - format.errors({ stack: true }), - format.splat(), - format.json(), - customFormat.line - ), - defaultMeta: { service: "sparcs-taxi" }, - transports: [ - new dailyRotateFileTransport({ - filename: path.resolve("logs/%DATE%-combined.log"), - datePattern: customFormat.fileDate, - maxsize: 5242880, // 5MB - level: "info", - }), - new dailyRotateFileTransport({ - filename: path.resolve("logs/%DATE%-error.log"), - datePattern: customFormat.fileDate, - maxsize: 5242880, // 5MB - level: "error", - }), - ], - exceptionHandlers: [ - new dailyRotateFileTransport({ - filename: path.resolve("logs/%DATE%-unhandled.log"), - datePattern: customFormat.fileDate, - maxsize: 5242880, // 5MB - }), - ], -}); - -// If the environment is not production, the log is also recorded on console -if (nodeEnv !== "production") { - logger.add( - new transports.Console({ - format: format.combine( - format.timestamp({ format: customFormat.time }), - format.errors({ stack: true }), - format.splat(), - format.colorize(), - customFormat.line - ), - }) - ); -} +const logger = + nodeEnv === "production" + ? // "production" 환경에서 사용되는 Logger 객체 + createLogger({ + level: "info", + format: uncolorizedFormat, + defaultMeta: { service: "sparcs-taxi" }, + transports: [ + // 전체 로그("info", "warn", "error")를 파일로 출력합니다. + new DailyRotateFileTransport({ + level: "info", + filename: path.resolve("logs/%DATE%-combined.log"), + datePattern, + maxSize, + }), + // 예외 처리로 핸들링 된 오류 로그("error")를 파일과 콘솔에 출력합니다. + new DailyRotateFileTransport({ + level: "error", + filename: path.resolve("logs/%DATE%-error.log"), + datePattern, + maxSize, + }), + consoleTransport, + ], + exceptionHandlers: [ + // 예외 처리가 되지 않은 오류 로그("error")를 파일과 콘솔에 출력합니다. + new DailyRotateFileTransport({ + filename: path.resolve("logs/%DATE%-unhandled.log"), + datePattern, + maxSize, + }), + consoleTransport, + ], + }) + : // "development", "test" 환경에서 사용되는 Logger 객체 + createLogger({ + level: "info", + format: colorizedFormat, + defaultMeta: { service: "sparcs-kaist" }, + transports: [consoleTransport], + exceptionHandlers: [consoleTransport], + }); module.exports = logger; diff --git a/src/modules/patterns.js b/src/modules/patterns.js index c39706ad..7111ec1a 100644 --- a/src/modules/patterns.js +++ b/src/modules/patterns.js @@ -10,7 +10,7 @@ module.exports = { nickname: RegExp("^[A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ0-9-_ ]{3,25}$"), allowedEmployeeTypes: RegExp("^([PEUR]|[SA]|[PEUR][SA])$"), profileImgType: RegExp("^(image/png|image/jpg|image/jpeg)$"), - account: RegExp("^[A-Za-z가-힣]{2,7} ([0-9]{10,14}|)$"), + account: RegExp("^[A-Za-z가-힣]{2,7} [0-9]{10,14}$|^$"), }, chat: { chatImgType: RegExp("^(image/png|image/jpg|image/jpeg)$"), diff --git a/src/modules/populates/rooms.js b/src/modules/populates/rooms.js index 18cbedf9..7243d648 100644 --- a/src/modules/populates/rooms.js +++ b/src/modules/populates/rooms.js @@ -7,7 +7,7 @@ const roomPopulateOption = [ { path: "to", select: "_id koName enName" }, { path: "part", - select: "-_id user settlementStatus", + select: "-_id user settlementStatus readAt", populate: { path: "user", select: "_id id name nickname profileImageUrl" }, }, ]; @@ -29,13 +29,14 @@ const formatSettlement = ( roomObject.part = roomObject.part.map((participantSubDocument) => { const { _id, name, nickname, profileImageUrl } = participantSubDocument.user; - const { settlementStatus } = participantSubDocument; + const { settlementStatus, readAt } = participantSubDocument; return { _id, name, nickname, profileImageUrl, isSettlement: includeSettlement ? settlementStatus : undefined, + readAt: readAt ?? roomObject.madeat, }; }); roomObject.settlementTotal = includeSettlement diff --git a/src/modules/socket.js b/src/modules/socket.js index 0dc5fa1a..bca99161 100644 --- a/src/modules/socket.js +++ b/src/modules/socket.js @@ -7,7 +7,7 @@ const { roomModel, userModel, chatModel } = require("./stores/mongo"); const { getS3Url } = require("./stores/aws"); const { getTokensOfUsers, sendMessageByTokens } = require("./fcm"); -const { frontUrl } = require("../../loadenv"); +const { corsWhiteList } = require("../../loadenv"); const { chatPopulateOption } = require("./populates/chats"); /** @@ -44,9 +44,9 @@ const transformChatsForRoom = async (chats) => { chatsToSend.push({ roomId: chat.roomId, type: chat.type, - authorId: chat.authorId._id, - authorName: chat.authorId.nickname, - authorProfileUrl: chat.authorId.profileImageUrl, + authorId: chat.authorId?._id, + authorName: chat.authorId?.nickname, + authorProfileUrl: chat.authorId?.profileImageUrl, content: chat.content, time: chat.time, isValid: chat.isValid, @@ -61,36 +61,47 @@ const transformChatsForRoom = async (chats) => { * FCM 알림으로 보내는 content는 채팅 type에 따라 달라집니다. * 예를 들어, type이 "text"인 경우 `${nickname}: ${content}`를 보냅니다. */ -const getMessageBody = (type, nickname, content) => { +const getMessageBody = (type, nickname = "", content = "") => { + // 닉네임이 9글자를 넘어가면 "..."으로 표시합니다. + const ellipsisedNickname = + nickname.length > 9 ? nickname.slice(0, 7) + "..." : nickname; + // TODO: 채팅 메시지 유형에 따라 Body를 다르게 표시합니다. - if (type === "text") { - // 채팅 메시지 유형이 텍스트인 경우 본문은 "${nickname}: ${content}"가 됩니다. - return `${nickname}: ${content}`; - } else if (type === "s3img") { - // 채팅 유형이 이미지인 경우 본문은 "${nickname} 님이 이미지를 전송하였습니다"가 됩니다. - // TODO: 사용자 언어를 가져올 수 있으면 개선할 수 있다고 생각합니다. - const suffix = " 님이 이미지를 전송하였습니다"; - return `${nickname} ${suffix}`; - } else if (type === "in" || type === "out") { - // 채팅 메시지 type이 "in"이거나 "out"인 경우 본문은 "${nickname} 님이 입장하였습니다" 또는 "${nickname} 님이 퇴장하였습니다"가 됩니다. - // TODO: 사용자 언어를 가져올 수 있으면 개선할 수 있다고 생각합니다. - const suffix = - type === "in" ? " 님이 입장하였습니다" : "님이 퇴장하였습니다"; - return `${nickname} ${suffix}`; - } else if (type === "payment" || type === "settlement") { - // 채팅 메시지 type이 "in"이거나 "out"인 경우 본문은 "${nickname} 님이 결제를 완료하였습니다" 또는 "${nickname} 님이 정산을 완료하였습니다"가 됩니다. - // TODO: 사용자 언어를 가져올 수 있다면 개선할 수 있다고 생각합니다. - const suffix = - type === "payment" - ? " 님이 결제를 완료하였습니다" - : " 님이 정산을 완료하였습니다"; - return `${nickname} ${suffix}`; - } else if (type === "account") { - const suffix = " 님이 계좌번호를 전송하였습니다"; - return `${nickname} ${suffix}`; - } else { - // 정의되지 않은 type의 경우에는 nickname만 반환합니다. - return nickname; + // TODO: 사용자 언어를 가져올 수 있으면 개선할 수 있다고 생각합니다. + switch (type) { + case "text": + return `${ellipsisedNickname}: ${content}`; + case "s3img": { + const suffix = "님이 이미지를 전송하였습니다"; + return `${ellipsisedNickname} ${suffix}`; + } + case "in": { + const suffix = "님이 입장하였습니다"; + return `${ellipsisedNickname} ${suffix}`; + } + case "out": { + const suffix = "님이 퇴장하였습니다"; + return `${ellipsisedNickname} ${suffix}`; + } + case "payment": { + const suffix = "님이 정산을 시작하였습니다"; + return `${ellipsisedNickname} ${suffix}`; + } + case "settlement": { + const suffix = "님이 송금을 완료하였습니다"; + return `${ellipsisedNickname} ${suffix}`; + } + case type === "account": { + const suffix = "님이 계좌번호를 전송하였습니다"; + return `${ellipsisedNickname} ${suffix}`; + } + case "departure": + return `택시 출발 ${content}분 전 입니다`; + case "arrival": + return "아직 정산 시작을 하지 않았거나 송금을 완료하지 않은 사용자가 있습니다"; + default: + // 정의되지 않은 type의 경우에는 nickname만 반환합니다. + return ellipsisedNickname; } }; @@ -100,30 +111,35 @@ const getMessageBody = (type, nickname, content) => { * @param {Server} io - Socket.io 서버 인스턴스입니다. req.app.get("io")를 통해 접근할 수 있습니다. * @param {Object} chat - 채팅 메시지 내용입니다. * @param {string} chat.roomId - 채팅 및 채팅 알림을 보낼 방의 ObjectId입니다. - * @param {string} chat.type - 채팅 메시지의 유형입니다. "text" | "s3img" | "in" | "out" | "payment" | "settlement" 입니다. + * @param {string} chat.type - 채팅 메시지의 유형입니다. "text" | "s3img" | "in" | "out" | "payment" | "settlement" | "account" | "departure" | "arrival" 입니다. * @param {string} chat.content - 채팅 메시지의 본문입니다. chat.type이 "s3img"인 경우에는 채팅의 objectId입니다. chat.type이 "in"이거나 "out"인 경우 입퇴장한 사용자의 id(!==ObjectId)입니다. - * @param {string} chat.authorId - 채팅을 보낸 사용자의 ObjectId입니다. + * @param {string} chat.authorId - optional. 채팅을 보낸 사용자의 ObjectId입니다. * @param {Date?} chat.time - optional. 채팅 메시지 전송 시각입니다. * @return {Promise} 채팅 및 알림 전송에 성공하면 true, 중간에 오류가 발생하면 false를 반환합니다. */ const emitChatEvent = async (io, chat) => { try { - // chat must contain type, content and authorId - // chat can contain time or not. const { roomId, type, content, authorId } = chat; - if (!io || !roomId || !type || !content || !authorId) { + // chat must contains roomId, type, and content + if (!io || !roomId || !type || !content) { throw new IllegalArgumentsException(); } + // chat optionally contains time const time = chat?.time || Date.now(); + + // roomId must be valid const { name, part } = await roomModel.findById(roomId, "name part"); - const { nickname, profileImageUrl } = await userModel.findById( - authorId, - "nickname profileImageUrl" - ); + if (!name || !part) { + throw new IllegalArgumentsException(); + } - if (!nickname || !profileImageUrl || !name || !part) { + // chat optionally contains authorId + const { nickname, profileImageUrl } = authorId + ? await userModel.findById(authorId, "nickname profileImageUrl") + : {}; + if (authorId && (!nickname || !profileImageUrl)) { throw new IllegalArgumentsException(); } @@ -151,14 +167,12 @@ const emitChatEvent = async (io, chat) => { chatDocument.authorName = nickname; chatDocument.authorProfileUrl = profileImageUrl; - const urlOnClick = `/myroom/${roomId}`; const userIds = part.map((participant) => participant.user); - const userIdsExceptAuthor = part - .map((participant) => participant.user) - .filter((userId) => userId.toString() !== authorId.toString()); - const deviceTokens = await getTokensOfUsers(userIdsExceptAuthor, { - chatting: true, - }); + const userIdsExceptAuthor = authorId + ? part + .map((participant) => participant.user) + .filter((userId) => userId.toString() !== authorId.toString()) + : userIds; // 방의 모든 사용자에게 socket 메세지 수신 이벤트를 발생시킵니다. const chatsForRoom = await transformChatsForRoom([chatDocument]); @@ -171,15 +185,44 @@ const emitChatEvent = async (io, chat) => { ) ); - // 해당 방에 참여중인 사용자들에게 알림을 전송합니다. + // 방의 작성자를 제외한 참여중인 사용자들에게 푸시 알림을 전송합니다. + const deviceTokensExceptAuthor = await getTokensOfUsers( + userIdsExceptAuthor, + { chatting: true } + ); await sendMessageByTokens( - deviceTokens, + deviceTokensExceptAuthor, type, name, getMessageBody(type, nickname, content), getS3Url(`/profile-img/${profileImageUrl}`), - urlOnClick + `/myroom/${roomId}` ); + + return true; + } catch (err) { + logger.error(err); + return false; + } +}; + +const emitUpdateEvent = async (io, roomId) => { + try { + // 방의 모든 사용자에게 socket 메세지 업데이트 이벤트를 발생시킵니다. + if (!io || !roomId) { + throw new IllegalArgumentsException(); + } + + const { name, part } = await roomModel.findById(roomId, "name part"); + + if (!name || !part) { + throw new IllegalArgumentsException(); + } + + part.forEach(({ user }) => io.in(`user-${user}`).emit("chat_update"), { + roomId, + }); + return true; } catch (err) { logger.error(err); @@ -209,7 +252,7 @@ const startSocketServer = (server) => { }); }, cors: { - origin: [frontUrl], + origin: corsWhiteList, methods: ["GET", "POST"], credentials: true, }, @@ -247,5 +290,6 @@ const startSocketServer = (server) => { module.exports = { transformChatsForRoom, emitChatEvent, + emitUpdateEvent, startSocketServer, }; diff --git a/src/modules/stores/mongo.js b/src/modules/stores/mongo.js index 244241f0..f05f266a 100755 --- a/src/modules/stores/mongo.js +++ b/src/modules/stores/mongo.js @@ -12,6 +12,7 @@ const userSchema = Schema({ ongoingRoom: [{ type: Schema.Types.ObjectId, ref: "Room" }], // 참여중인 진행중인 방 배열 doneRoom: [{ type: Schema.Types.ObjectId, ref: "Room" }], // 참여중인 완료된 방 배열 withdraw: { type: Boolean, default: false }, + phoneNumber: { type: String }, // 전화번호 (2023FALL 이벤트부터 추가) ban: { type: Boolean, default: false }, //계정 정지 여부 joinat: { type: Date, required: true }, //가입 시각 agreeOnTermsOfService: { type: Boolean, default: false }, //이용약관 동의 여부 @@ -34,6 +35,7 @@ const participantSchema = Schema({ enum: ["not-departed", "paid", "send-required", "sent"], default: "not-departed", }, + readAt: { type: Date }, }); const deviceTokenSchema = Schema({ @@ -114,16 +116,27 @@ const locationSchema = Schema({ koName: { type: String, required: true }, priority: { type: Number, default: 0 }, isValid: { type: Boolean, default: true }, - // latitude: { type: Number, required: true }, - // longitude: { type: Number, required: true } + latitude: { type: Number }, // 이후 required: true 로 수정 필요 + longitude: { type: Number }, // 이후 required: true 로 수정 필요 }); + const chatSchema = Schema({ roomId: { type: Schema.Types.ObjectId, ref: "Room", required: true }, type: { type: String, - enum: ["text", "in", "out", "s3img", "payment", "settlement", "account"], + enum: [ + "text", + "in", + "out", + "s3img", + "payment", + "settlement", + "account", + "departure", // 출발 15분 전 알림 + "arrival", // 출발 (1|24)시간 이후 알림 - 정산/송금 권유 + ], }, // 메시지 종류 - authorId: { type: Schema.Types.ObjectId, ref: "User", required: true }, // 작성자 id + authorId: { type: Schema.Types.ObjectId, ref: "User" }, // 작성자 id content: { type: String, default: "" }, time: { type: Date, required: true }, isValid: { type: Boolean, default: true }, @@ -160,6 +173,8 @@ const adminLogSchema = Schema({ }, // 수행 업무 }); +mongoose.set("strictQuery", true); + const database = mongoose.connection; database.on("error", console.error.bind(console, "mongoose connection error.")); database.on("open", () => { @@ -180,12 +195,14 @@ database.on("disconnected", function () { }, 5000); }); -mongoose.connect(mongoUrl, { - useNewUrlParser: true, - useUnifiedTopology: true, -}); +const connectDatabase = () => + mongoose.connect(mongoUrl, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); module.exports = { + connectDatabase, userModel: mongoose.model("User", userSchema), deviceTokenModel: mongoose.model("DeviceToken", deviceTokenSchema), notificationOptionModel: mongoose.model( diff --git a/src/routes/admin.js b/src/routes/admin.js index 363e1282..e2ddaae4 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1,6 +1,5 @@ const express = require("express"); const AdminJS = require("adminjs"); -const { buildFeature } = require("adminjs"); const AdminJSExpress = require("@adminjs/express"); const AdminJSMongoose = require("@adminjs/mongoose"); const { @@ -14,6 +13,8 @@ const { deviceTokenModel, notificationOptionModel, } = require("../modules/stores/mongo"); +const { eventConfig } = require("../../loadenv"); +const { buildResource } = require("../modules/adminResource"); const router = express.Router(); @@ -24,72 +25,23 @@ router.use(require("../middlewares/auth")); // Registration of the mongoose adapter AdminJS.registerAdapter(AdminJSMongoose); -// AdminJS에서 Log 저장을 하는 action -const logAction = (actionName) => async (res, req, context) => { - const user = await userModel.findOne({ id: req.userId }); - const modelName = context?.resource?.MongooseModel?.modelName; - const recordLength = `(length = ${context?.records?.length})`; - const recordId = `(_id = ${context?.record?.params?._id})` || recordLength; - const [action, target] = { - list: ["read", `List<${modelName}>${recordLength}`], - show: ["read", `${modelName}${recordId}`], - new: ["create", `${modelName}${recordId}`], - edit: ["update", `${modelName}${recordId}`], - delete: ["delete", `${modelName}${recordId}`], - bulkDelete: ["delete", `${modelName}${recordId}`], - }?.[actionName]; - - if ( - ["new", "edit", "bulkDelete"].includes(actionName) && - req.method !== "post" - ) - return res; - - if (user?._id && action && target) { - const newLog = new adminLogModel({ - user: user._id, // Log 취급자 User - time: req.timestamp, // Log 발생 시각 - ip: req.clientIP, // 접속 IP 주소 - target, // 처리한 정보주체 정보 - action, // 수행 업무 - }); - await newLog.save(); - } - return res; -}; - -// AdminJS에서 Log 기록을 하도록 action을 수정합니다 -const resourceWrapper = (resource) => ({ - resource, - features: [ - buildFeature({ - actions: ["list", "show", "new", "edit", "delete", "bulkDelete"].reduce( - (before, actionName) => ({ - ...before, - [actionName]: { - after: logAction(actionName), - }, - }), - {} - ), - }), - ], -}); +const baseResources = [ + userModel, + roomModel, + locationModel, + chatModel, + reportModel, + adminIPWhitelistModel, + adminLogModel, + deviceTokenModel, + notificationOptionModel, +].map(buildResource()); +const resources = baseResources.concat( + eventConfig?.mode === "2023fall" ? require("../lottery").resources : [] +); // Create router for admin page -const adminJS = new AdminJS({ - resources: [ - userModel, - roomModel, - locationModel, - chatModel, - reportModel, - adminIPWhitelistModel, - adminLogModel, - deviceTokenModel, - notificationOptionModel, - ].map(resourceWrapper), -}); +const adminJS = new AdminJS({ resources }); router.use(AdminJSExpress.buildRouter(adminJS)); module.exports = router; diff --git a/src/routes/chats.js b/src/routes/chats.js index 812755f6..f689348c 100644 --- a/src/routes/chats.js +++ b/src/routes/chats.js @@ -54,6 +54,17 @@ router.post( chatsHandlers.sendChatHandler ); +/** + * 채팅 읽은 시각 업데이트 요청을 처리합니다. + * 같은 방에 있는 user들에게 업데이트를 요청합니다. + */ +router.post( + "/read", + body("roomId").isMongoId(), + validator, + chatsHandlers.readChatHandler +); + // 채팅 이미지를 업로드할 수 있는 Presigned-url을 발급합니다. router.post( "/uploadChatImg/getPUrl", diff --git a/src/routes/docs/logininfo.js b/src/routes/docs/logininfo.js index ec424f63..f572f942 100644 --- a/src/routes/docs/logininfo.js +++ b/src/routes/docs/logininfo.js @@ -30,6 +30,10 @@ const logininfoDocs = { withdraw: { type: "boolean", }, + phoneNumber: { + type: "string", + description: "사용자 전화번호", + }, ban: { type: "boolean", }, @@ -40,7 +44,7 @@ const logininfoDocs = { agreeOnTermsOfService: { type: "boolean", }, - subinfio: { + subinfo: { type: "object", properties: { kaist: { diff --git a/src/routes/docs/swaggerDocs.js b/src/routes/docs/swaggerDocs.js index cf52ff2a..c6c13060 100644 --- a/src/routes/docs/swaggerDocs.js +++ b/src/routes/docs/swaggerDocs.js @@ -2,6 +2,7 @@ const reportsSchema = require("./reportsSchema"); const reportsDocs = require("./reports"); const logininfoDocs = require("./logininfo"); const locationsDocs = require("./locations"); +const usersDocs = require("./users"); const swaggerDocs = { openapi: "3.0.3", @@ -23,6 +24,10 @@ const swaggerDocs = { name: "reports", description: "사용자 신고 및 신고 기록 조회", }, + { + name: "users", + description: "유저 계정 정보 수정 및 조회", + }, ], consumes: ["application/json"], produces: ["application/json"], @@ -30,6 +35,7 @@ const swaggerDocs = { ...reportsDocs, ...logininfoDocs, ...locationsDocs, + ...usersDocs, }, components: { schemas: { diff --git a/src/routes/docs/users.js b/src/routes/docs/users.js new file mode 100644 index 00000000..e76cd2b4 --- /dev/null +++ b/src/routes/docs/users.js @@ -0,0 +1,68 @@ +const tag = "users"; +const apiPrefix = "/users"; + +const usersDocs = {}; +usersDocs[`${apiPrefix}/resetNickname`] = { + get: { + tags: [tag], + summary: "유저 닉네임 기본값으로 재설정", + description: "유저의 별명을 기본값(랜덤한 닉네임)으로 초기화합니다", + responses: { + 200: { + content: { + "text/html": { + example: "User/resetNickname : reset user nickname successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "User/resetNickname : such user does not exist", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "User/resetNickname : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/resetProfileImg`] = { + get: { + tags: [tag], + summary: "유저 프로필 사진 기본값으로 재설정", + description: "유저의 프로필 사진을 기본값(랜덤한 사진)으로 초기화합니다", + responses: { + 200: { + content: { + "text/html": { + example: + "User/resetProfileImg : reset user profile image successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "User/resetProfileImg : such user does not exist", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "User/resetProfileImg : internal server error", + }, + }, + }, + }, + }, +}; + +module.exports = usersDocs; diff --git a/src/routes/users.js b/src/routes/users.js index f7736cd6..31bde597 100755 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -31,6 +31,9 @@ router.post( userHandlers.editNicknameHandler ); +// 넥네임을 기본값으로 재설정합니다. +router.get("/resetNickname", userHandlers.resetNicknameHandler); + // 새 계좌번호를 받아 로그인된 유저의 계좌번호를 변경합니다. router.post( "/editAccount", @@ -50,4 +53,7 @@ router.post( // 프로필 이미지가 S3에 정상적으로 업로드가 되었는지 확인합니다. router.get("/editProfileImg/done", userHandlers.editProfileImgDoneHandler); +// 프로필 이미지를 기본값으로 재설정합니다. +router.get("/resetProfileImg", userHandlers.resetProfileImgHandler); + module.exports = router; diff --git a/src/schedules/index.js b/src/schedules/index.js new file mode 100644 index 00000000..97818b92 --- /dev/null +++ b/src/schedules/index.js @@ -0,0 +1,8 @@ +const cron = require("node-cron"); + +const registerSchedules = (app) => { + cron.schedule("*/5 * * * *", require("./notifyBeforeDepart")(app)); + cron.schedule("*/10 * * * *", require("./notifyAfterArrival")(app)); +}; + +module.exports = registerSchedules; diff --git a/src/schedules/notifyAfterArrival.js b/src/schedules/notifyAfterArrival.js new file mode 100644 index 00000000..3202af08 --- /dev/null +++ b/src/schedules/notifyAfterArrival.js @@ -0,0 +1,59 @@ +const { roomModel, chatModel } = require("../modules/stores/mongo"); +// const { roomPopulateOption } = require("../modules/populates/rooms"); +const { emitChatEvent } = require("../modules/socket"); +const logger = require("../modules/logger"); + +const MS_PER_MINUTE = 60000; + +/** + * 출발 한시간 이후 정산/송금하기를 완료하지 않은 사용자가 있다면 알림을 전송합니다. + */ + +module.exports = (app) => async () => { + try { + const io = app.get("io"); + const expiredDate = new Date(Date.now() - 90 * MS_PER_MINUTE).toISOString(); + const arrivalDate = new Date(Date.now() - 60 * MS_PER_MINUTE).toISOString(); + + /** + * 알림을 전송하는 방의 첫 번째 조건은 다음과 같습니다. + * - 출발한 지 60분 이상 90분 이하가 지나야 합니다. + * - 결제를 진행한 사용자가 없거나, 결제가 진행된 후 정산을 하지 않은 사용자가 있어야 합니다. + */ + const candidateRooms = await roomModel.find({ + $and: [ + { time: { $gte: expiredDate } }, + { time: { $lte: arrivalDate } }, + { + part: { + $elemMatch: { settlementStatus: { $nin: ["paid", "sent"] } }, + }, + }, + ], + }); + + await Promise.all( + candidateRooms.map(async ({ _id: roomId, time }) => { + /** + * 알림을 전송하는 방의 두 번째 조건은 다음과 같습니다. + * - '출발 후 알림'이 아직 전송되지 않았어야 합니다. + * 모든 조건에 만족이 되면 해당 방에 참여 중인 모든 사용자에게 알림이 전송됩니다. + */ + const countArrivalChat = await chatModel.countDocuments({ + roomId, + type: "arrival", + }); + if (countArrivalChat > 0) return; + const minuteDiff = Math.floor((Date.now() - time) / MS_PER_MINUTE); + if (minuteDiff <= 0) return; + await emitChatEvent(io, { + roomId: roomId, + type: "arrival", + content: minuteDiff.toString(), + }); + }) + ); + } catch (err) { + logger.error(err); + } +}; diff --git a/src/schedules/notifyBeforeDepart.js b/src/schedules/notifyBeforeDepart.js new file mode 100644 index 00000000..ffe66386 --- /dev/null +++ b/src/schedules/notifyBeforeDepart.js @@ -0,0 +1,54 @@ +const { roomModel, chatModel } = require("../modules/stores/mongo"); +const { emitChatEvent } = require("../modules/socket"); +const logger = require("../modules/logger"); + +const MS_PER_MINUTE = 60000; + +/** + * 출발까지 15분 남은 방들에 참여하고 있는 사용자들에게 리마인더 알림을 전송합니다. + */ + +module.exports = (app) => async () => { + try { + const io = app.get("io"); + const currentDate = new Date(Date.now()).toISOString(); + const departDate = new Date(Date.now() + 15 * MS_PER_MINUTE).toISOString(); + + /** + * 알림을 전송하는 방의 첫 번째 조건은 다음과 같습니다. + * - 출출발까지 15분 이하가 남아있어야 합니다. + * - 2명 이상이 방에 참여 중이어야 합니다. + */ + const candidatesRooms = await roomModel.find({ + $and: [ + { time: { $gte: currentDate } }, + { time: { $lte: departDate } }, + { "part.1": { $exists: true } }, + ], + }); + + await Promise.all( + candidatesRooms.map(async ({ _id: roomId, time }) => { + /** + * 알림을 전송하는 방의 두 번째 조건은 다음과 같습니다. + * - '출발 후 알림'이 아직 전송되지 않았어야 합니다. + * 모든 조건에 만족이 되면 해당 방에 참여 중인 모든 사용자에게 알림이 전송됩니다. + */ + const countDepartureChat = await chatModel.countDocuments({ + roomId, + type: "departure", + }); + if (countDepartureChat > 0) return; + const minuteDiff = Math.ceil((time - Date.now()) / MS_PER_MINUTE); + if (minuteDiff <= 0) return; + await emitChatEvent(io, { + roomId: roomId, + type: "departure", + content: minuteDiff.toString(), + }); + }) + ); + } catch (err) { + logger.error(err); + } +}; diff --git a/src/services/auth.js b/src/services/auth.js index d001e2f7..83b598de 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -1,6 +1,5 @@ const { sparcssso: sparcsssoEnv, - frontUrl, nodeEnv, testAccounts, } = require("../../loadenv"); @@ -15,6 +14,7 @@ const { getFullUsername, } = require("../modules/modifyProfile"); const jwt = require("../modules/auths/jwt"); +const logger = require("../modules/logger"); // SPARCS SSO const Client = require("../modules/auths/sparcssso"); @@ -39,8 +39,7 @@ const transUserData = (userData) => { return info; }; -// 가입시키기 -const joinus = (req, res, userData) => { +const joinus = async (req, userData) => { const newUser = new userModel({ id: userData.id, name: userData.name, @@ -55,54 +54,50 @@ const joinus = (req, res, userData) => { }, email: userData.email, }); - newUser.save((err) => { - if (err) { - loginFail(req, res); - return; - } - loginDone(req, res, userData); - }); + await newUser.save(); }; -const update = async (req, res, userData) => { +const update = async (userData) => { const updateInfo = { name: userData.name }; await userModel.updateOne({ id: userData.id }, updateInfo); - loginDone(req, res, userData); }; -const loginDone = (req, res, userData) => { - userModel.findOne( - { id: userData.id }, - "_id name id withdraw ban", - async (err, result) => { - if (err) loginFail(req, res); - else if (!result) joinus(req, res, userData); - else if (result.name != userData.name) update(req, res, userData); - else { - if (req.session.isApp) { - const { token: accessToken } = await jwt.sign({ - id: result._id, - type: "access", - }); - const { token: refreshToken } = await jwt.sign({ - id: result._id, - type: "refresh", - }); - req.session.accessToken = accessToken; - req.session.refreshToken = refreshToken; - } - - const redirectPath = req.session?.state_redirectPath || "/"; - req.session.state_redirectPath = undefined; - login(req, userData.sid, result.id, result._id, result.name); - res.redirect(frontUrl + redirectPath); - } +const tryLogin = async (req, res, userData, redirectOrigin, redirectPath) => { + try { + const user = await userModel.findOne( + { id: userData.id }, + "_id name id withdraw ban" + ); + if (!user) { + await joinus(req, userData); + return tryLogin(req, res, userData, redirectOrigin, redirectPath); + } + if (user.name != userData.name) { + await update(userData); + return tryLogin(req, res, userData, redirectOrigin, redirectPath); } - ); -}; -const loginFail = (req, res, redirectUrl = "") => { - res.redirect(redirectUrl || frontUrl + "/login/fail"); + if (req.session.isApp) { + const { token: accessToken } = await jwt.sign({ + id: user._id, + type: "access", + }); + const { token: refreshToken } = await jwt.sign({ + id: user._id, + type: "refresh", + }); + req.session.accessToken = accessToken; + req.session.refreshToken = refreshToken; + } + + login(req, userData.sid, user.id, user._id, user.name); + + res.redirect(new URL(redirectPath, redirectOrigin).href); + } catch (err) { + logger.error(err); + const redirectUrl = new URL("/login/fail", redirectOrigin).href; + res.redirect(redirectUrl); + } }; const sparcsssoHandler = (req, res) => { @@ -110,33 +105,47 @@ const sparcsssoHandler = (req, res) => { const isApp = !!req.query.isApp; const { url, state } = client.getLoginParams(); - req.session.state = state; - req.session.state_redirectPath = redirectPath; + req.session.loginAfterState = { + state: state, + redirectOrigin: req.origin, + redirectPath: redirectPath, + }; req.session.isApp = isApp; res.redirect(url + "&social_enabled=0&show_disabled_button=0"); }; const sparcsssoCallbackHandler = (req, res) => { - const state1 = req.session.state; - const state2 = req.body.state || req.query.state; - - if (state1 !== state2) loginFail(req, res); - else { - const code = req.body.code || req.query.code; - client.getUserInfo(code).then((userDataBefore) => { - const userData = transUserData(userDataBefore); - const isTestAccount = testAccounts?.includes(userData.email); - if (userData.isEligible || nodeEnv !== "production" || isTestAccount) { - loginDone(req, res, userData); - } else { - // 카이스트 구성원이 아닌 경우, SSO 로그아웃 이후, 로그인 실패 URI 로 이동합니다 - const { sid } = userData; - const redirectUrl = frontUrl + "/login/fail"; - const ssoLogoutUrl = client.getLogoutUrl(sid, redirectUrl); - loginFail(req, res, ssoLogoutUrl); - } - }); + const loginAfterState = req.session?.loginAfterState; + const { state: stateForCmp, code } = req.query; + + if (!loginAfterState) + return res.status(400).send("SparcsssoCallbackHandler : invalid request"); + + const { state, redirectOrigin, redirectPath } = loginAfterState; + req.session.loginAfterState = undefined; + + if (!state || !redirectOrigin || !redirectPath) { + return res.status(400).send("SparcsssoCallbackHandler : invalid request"); } + + if (state !== stateForCmp) { + const redirectUrl = new URL("/login/fail", redirectOrigin).href; + return res.redirect(redirectUrl); + } + + client.getUserInfo(code).then((userDataBefore) => { + const userData = transUserData(userDataBefore); + const isTestAccount = testAccounts?.includes(userData.email); + if (userData.isEligible || nodeEnv !== "production" || isTestAccount) { + tryLogin(req, res, userData, redirectOrigin, redirectPath); + } else { + // 카이스트 구성원이 아닌 경우, SSO 로그아웃 이후, 로그인 실패 URI 로 이동합니다 + const { sid } = userData; + const redirectUrl = new URL("/login/fail", redirectOrigin).href; + const ssoLogoutUrl = client.getLogoutUrl(sid, redirectUrl); + res.redirect(ssoLogoutUrl); + } + }); }; const loginReplaceHandler = (req, res) => { @@ -158,7 +167,7 @@ const logoutHandler = async (req, res) => { } // 로그아웃 URL을 생성 및 반환 - const redirectUrl = frontUrl + redirectPath; + const redirectUrl = new URL(redirectPath, req.origin).href; const ssoLogoutUrl = client.getLogoutUrl(sid, redirectUrl); logout(req, res); res.json({ ssoLogoutUrl }); @@ -168,6 +177,7 @@ const logoutHandler = async (req, res) => { }; module.exports = { + tryLogin, sparcsssoHandler, sparcsssoCallbackHandler, loginReplaceHandler, diff --git a/src/services/auth.mobile.js b/src/services/auth.mobile.js index fac66032..0e537b33 100644 --- a/src/services/auth.mobile.js +++ b/src/services/auth.mobile.js @@ -36,6 +36,7 @@ const tokenLoginHandler = async (req, res) => { login(req, user.sid, user.id, user._id, user.name); req.session.isApp = true; req.session.deviceToken = deviceToken; + return res.status(200).json({ message: "success" }); } catch (e) { logger.error(e); diff --git a/src/services/auth.replace.js b/src/services/auth.replace.js index d6470680..a433e6f6 100644 --- a/src/services/auth.replace.js +++ b/src/services/auth.replace.js @@ -1,4 +1,3 @@ -const { frontUrl } = require("../../loadenv"); const { userModel } = require("../modules/stores/mongo"); const { logout, login } = require("../modules/auths/login"); @@ -10,10 +9,10 @@ const { const logger = require("../modules/logger"); const jwt = require("../modules/auths/jwt"); -const { registerDeviceTokenHandler } = require("../services/auth"); +const { registerDeviceTokenHandler, tryLogin } = require("../services/auth"); const loginReplacePage = require("../views/loginReplacePage"); -const makeInfo = (id) => { +const createUserData = (id) => { const info = { id: id, sid: id + "-sid", @@ -29,75 +28,26 @@ const makeInfo = (id) => { return info; }; -// 새로운 유저 만들기 -// 이거 왜 이름이 joinus? -const joinus = (req, res, userData, redirectPath = "/") => { - const newUser = new userModel({ - id: userData.id, - name: userData.name, - nickname: userData.nickname, - profileImageUrl: userData.profileImageUrl, - joinat: req.timestamp, - subinfo: { - kaist: userData.kaist, - sparcs: userData.sparcs, - facebook: userData.facebook, - twitter: userData.twitter, - }, - email: userData.email, - }); - newUser.save((err) => { - if (err) { - logger.error(err); - return; - } - loginDone(req, res, userData, redirectPath); - }); -}; - -// 주어진 데이터로 DB 검색 -// 만약 없으면 새로운 유저 만들기 -// 있으면 로그인 진행 후 리다이렉트 -const loginDone = (req, res, userData, redirectPath = "/") => { - userModel.findOne( - { id: userData.id }, - "_id name id withdraw ban", - async (err, result) => { - if (err) logger.error(logger.error(err)); - else if (!result) joinus(req, res, userData, redirectPath); - else { - if (req.session.isApp) { - const { token: accessToken } = await jwt.sign({ - id: result._id, - type: "access", - }); - const { token: refreshToken } = await jwt.sign({ - id: result._id, - type: "refresh", - }); - req.session.accessToken = accessToken; - req.session.refreshToken = refreshToken; - } - - login(req, userData.sid, result.id, result._id, result.name); - res.redirect(frontUrl + redirectPath); - } - } - ); -}; - const loginReplaceHandler = (req, res) => { const { id } = req.body; - const redirectPath = decodeURIComponent(req.body?.redirect || "%2F"); - loginDone(req, res, makeInfo(id), redirectPath); + const loginAfterState = req.session?.loginAfterState; + if (!loginAfterState) + return res.status(400).send("SparcsssoCallbackHandler : invalid request"); + const { redirectOrigin, redirectPath } = loginAfterState; + req.session.loginAfterState = undefined; + tryLogin(req, res, createUserData(id), redirectOrigin, redirectPath); }; const sparcsssoHandler = (req, res) => { const redirectPath = decodeURIComponent(req.query?.redirect || "%2F"); const isApp = !!req.query.isApp; + req.session.loginAfterState = { + redirectOrigin: req.origin, + redirectPath: redirectPath, + }; req.session.isApp = isApp; - res.end(loginReplacePage(redirectPath)); + res.end(loginReplacePage); }; const logoutHandler = async (req, res) => { @@ -111,7 +61,7 @@ const logoutHandler = async (req, res) => { } // sparcs-sso 로그아웃 URL을 생성 및 반환 - const ssoLogoutUrl = frontUrl + redirectPath; + const ssoLogoutUrl = new URL(redirectPath, req.origin).href; logout(req, res); res.json({ ssoLogoutUrl }); } catch (e) { diff --git a/src/services/chats.js b/src/services/chats.js index 14063e0e..abcbd073 100644 --- a/src/services/chats.js +++ b/src/services/chats.js @@ -1,7 +1,13 @@ const { chatModel, userModel, roomModel } = require("../modules/stores/mongo"); const { chatPopulateOption } = require("../modules/populates/chats"); +const { roomPopulateOption } = require("../modules/populates/rooms"); const aws = require("../modules/stores/aws"); -const { transformChatsForRoom, emitChatEvent } = require("../modules/socket"); +const { + transformChatsForRoom, + emitChatEvent, + emitUpdateEvent, +} = require("../modules/socket"); +const logger = require("../modules/logger"); const chatCount = 60; @@ -42,6 +48,7 @@ const loadRecentChatHandler = async (req, res) => { res.status(500).send("Chat/ : internal server error"); } } catch (e) { + logger.error(e); res.status(500).send("Chat/ : internal server error"); } }; @@ -85,6 +92,7 @@ const loadBeforeChatHandler = async (req, res) => { res.status(500).send("Chat/load/before : internal server error"); } } catch (e) { + logger.error(e); res.status(500).send("Chat/load/before : internal server error"); } }; @@ -125,6 +133,7 @@ const loadAfterChatHandler = async (req, res) => { res.status(500).send("Chat/load/after : internal server error"); } } catch (e) { + logger.error(e); res.status(500).send("Chat/load/after : internal server error"); } }; @@ -161,10 +170,56 @@ const sendChatHandler = async (req, res) => { res.json({ result: true }); else res.status(500).send("Chat/send : internal server error"); } catch (e) { + logger.error(e); res.status(500).send("Chat/send : internal server error"); } }; +const readChatHandler = async (req, res) => { + try { + const io = req.app.get("io"); + const { userId } = req; + const { roomId } = req.body; + const user = await userModel.findOne({ id: userId }); + + if (!userId || !user) { + return res.status(500).send("Chat/read : internal server error"); + } + if (!io) { + return res.status(403).send("Chat/read : socket did not connected"); + } + + const roomObject = await roomModel + .findOneAndUpdate( + { + _id: roomId, + part: { + $elemMatch: { + user: user._id, + }, + }, + }, + { + $set: { "part.$[updater].readAt": req.timestamp }, + }, + { + new: true, + arrayFilters: [{ "updater.user": { $eq: user._id } }], + } + ) + .lean(); + + if (!roomObject) { + return res.status(404).send("Chat/read : cannot find room info"); + } + + if (await emitUpdateEvent(io, roomId)) res.json({ result: true }); + else res.status(500).send("Chat/read : failed to emit socket events"); + } catch (e) { + res.status(500).send("Chat/read : internal server error"); + } +}; + const uploadChatImgGetPUrlHandler = async (req, res) => { try { const { type, roomId } = req.body; @@ -203,6 +258,7 @@ const uploadChatImgGetPUrlHandler = async (req, res) => { }); }); } catch (e) { + logger.error(e); res.status(500).send("Chat/uploadChatImg/getPUrl : internal server error"); } }; @@ -249,6 +305,7 @@ const uploadChatImgDoneHandler = async (req, res) => { }); }); } catch (e) { + logger.error(e); res.status(500).send("Chat/uploadChatImg/done : internal server error"); } }; @@ -277,4 +334,5 @@ module.exports = { sendChatHandler, uploadChatImgGetPUrlHandler, uploadChatImgDoneHandler, + readChatHandler, }; diff --git a/src/services/logininfo.js b/src/services/logininfo.js index 32a6b96d..eebb09ff 100644 --- a/src/services/logininfo.js +++ b/src/services/logininfo.js @@ -9,7 +9,7 @@ const logininfoHandler = async (req, res) => { const userDetail = await userModel.findOne( { id: user.id }, - "_id name nickname id withdraw ban joinat agreeOnTermsOfService subinfo email profileImageUrl account" + "_id name nickname id withdraw phoneNumber ban joinat agreeOnTermsOfService subinfo email profileImageUrl account" ); res.json({ @@ -18,6 +18,7 @@ const logininfoHandler = async (req, res) => { name: userDetail.name, nickname: userDetail.nickname, withdraw: userDetail.withdraw, + phoneNumber: userDetail.phoneNumber, ban: userDetail.ban, joinat: userDetail.joinat, agreeOnTermsOfService: userDetail.agreeOnTermsOfService, diff --git a/src/services/notifications.js b/src/services/notifications.js index 799c8bb7..633f6739 100644 --- a/src/services/notifications.js +++ b/src/services/notifications.js @@ -4,6 +4,9 @@ const logger = require("../modules/logger"); const { registerDeviceToken, validateDeviceToken } = require("../modules/fcm"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); + const registerDeviceTokenHandler = async (req, res) => { try { // 해당 FCM device token이 유효한지 검사합니다. @@ -104,6 +107,13 @@ const editOptionsHandler = async (req, res) => { .send("Notification/editOptions: deviceToken not found"); } + // 이벤트 코드입니다. + await contracts?.completeAdPushAgreementQuest( + req.userOid, + req.timestamp, + options.advertisement + ); + res.status(200).json(updatedNotificationOptions); } catch (err) { logger.error(err); diff --git a/src/services/reports.js b/src/services/reports.js index 98ff110e..0451b0cc 100644 --- a/src/services/reports.js +++ b/src/services/reports.js @@ -35,7 +35,7 @@ const createHandler = async (req, res) => { type, etcDetail, time, - roomId + roomId, }); await report.save(); @@ -46,6 +46,7 @@ const createHandler = async (req, res) => { const emailRoomName = room ? room.name : ""; const emailRoomId = room ? room._id : ""; const emailHtml = emailPage( + req.origin, reported.name, reported.nickname, emailRoomName, diff --git a/src/services/rooms.js b/src/services/rooms.js index 0456d534..d4b7557e 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -11,6 +11,9 @@ const { getIsOver, } = require("../modules/populates/rooms"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); + const createHandler = async (req, res) => { const { name, from, to, time, maxPartLength } = req.body; @@ -81,7 +84,12 @@ const createHandler = async (req, res) => { }); const roomObject = (await room.populate(roomPopulateOption)).toObject(); - return res.send(formatSettlement(roomObject)); + const roomObjectFormated = formatSettlement(roomObject); + + // 이벤트 코드입니다. + await contracts?.completeFirstRoomCreationQuest(req.userOid, req.timestamp); + + return res.send(roomObjectFormated); } catch (err) { logger.error(err); res.status(500).json({ @@ -483,6 +491,18 @@ const commitPaymentHandler = async (req, res) => { authorId: user._id, }); + // 이벤트 코드입니다. + await contracts?.completePayingQuest( + req.userOid, + req.timestamp, + roomObject + ); + await contracts?.completePayingAndSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); } catch (err) { @@ -549,6 +569,18 @@ const settlementHandler = async (req, res) => { authorId: user._id, }); + // 이벤트 코드입니다. + await contracts?.completeSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + await contracts?.completePayingAndSendingQuest( + req.userOid, + req.timestamp, + roomObject + ); + // 수정한 방 정보를 반환합니다. res.send(formatSettlement(roomObject, { isOver: true })); } catch (err) { diff --git a/src/services/users.js b/src/services/users.js index 77af8c7d..3c26b164 100644 --- a/src/services/users.js +++ b/src/services/users.js @@ -2,6 +2,13 @@ const { userModel } = require("../modules/stores/mongo"); const logger = require("../modules/logger"); const aws = require("../modules/stores/aws"); +// 이벤트 코드입니다. +const { contracts } = require("../lottery"); +const { + generateNickname, + generateProfileImageUrl, +} = require("../modules/modifyProfile"); + const agreeOnTermsOfServiceHandler = async (req, res) => { try { let user = await userModel.findOne({ id: req.userId }); @@ -34,43 +41,54 @@ const getAgreeOnTermsOfServiceHandler = async (req, res) => { }; const editNicknameHandler = async (req, res) => { - const newNickname = req.body.nickname; + try { + const newNickname = req.body.nickname; + const result = await userModel.findOneAndUpdate( + { id: req.userId }, + { nickname: newNickname } + ); - // 닉네임을 갱신하고 결과를 반환 - await userModel - .findOneAndUpdate({ id: req.userId }, { nickname: newNickname }) - .then((result) => { - if (result) { - res - .status(200) - .send("User/editNickname : edit user nickname successful"); - } else { - res.status(400).send("User/editNickname : such user id does not exist"); - } - }) - .catch((err) => { - logger.error(err); - res.status(500).send("User/editNickname : internal server error"); - }); + if (result) { + // 이벤트 코드입니다. + await contracts?.completeNicknameChangingQuest( + req.userOid, + req.timestamp + ); + + res.status(200).send("User/editNickname : edit user nickname successful"); + } else { + res.status(400).send("User/editNickname : such user id does not exist"); + } + } catch (err) { + logger.error(err); + res.status(500).send("User/editNickname : internal server error"); + } }; const editAccountHandler = async (req, res) => { - const newAccount = req.body.account; + try { + const newAccount = req.body.account; + const result = await userModel.findOneAndUpdate( + { id: req.userId }, + { account: newAccount } + ); - // 계좌번호를 갱신하고 결과를 반환 - await userModel - .findOneAndUpdate({ id: req.userId }, { account: newAccount }) - .then((result) => { - if (result) { - res.status(200).send("User/editAccount : edit user account successful"); - } else { - res.status(400).send("User/editAccount : such user id does not exist"); - } - }) - .catch((err) => { - logger.error(err); - res.status(500).send("User/editAccount : internal server error"); - }); + if (result) { + // 이벤트 코드입니다. + await contracts?.completeAccountChangingQuest( + req.userOid, + req.timestamp, + newAccount + ); + + res.status(200).send("User/editAccount : edit user account successful"); + } else { + res.status(400).send("User/editAccount : such user id does not exist"); + } + } catch (err) { + logger.error(err); + res.status(500).send("User/editAccount : internal server error"); + } }; const editProfileImgGetPUrlHandler = async (req, res) => { @@ -137,6 +155,43 @@ const editProfileImgDoneHandler = async (req, res) => { } }; +const resetNicknameHandler = async (req, res) => { + try { + const result = await userModel.findOneAndUpdate( + { id: req.userId }, + { nickname: generateNickname(req.body.id) }, + { new: true } + ); + if (!result) + return res + .status(400) + .send("User/resetNickname : such user does not exist"); + res.status(200).send("User/resetNickname : reset user nickname successful"); + } catch (err) { + logger.error(err); + res.status(500).send("User/resetNickname : internal server error"); + } +}; + +const resetProfileImgHandler = async (req, res) => { + try { + const result = await userModel.findOneAndUpdate( + { id: req.userId }, + { profileImageUrl: generateProfileImageUrl() }, + { new: true } + ); + if (!result) + return res + .status(400) + .send("User/resetProfileImg : such user does not exist"); + res + .status(200) + .send("User/resetProfileImg : reset user profile image successful"); + } catch (err) { + res.status(500).send("User/resetProfileImg : internal server error"); + } +}; + module.exports = { agreeOnTermsOfServiceHandler, getAgreeOnTermsOfServiceHandler, @@ -144,4 +199,6 @@ module.exports = { editAccountHandler, editProfileImgGetPUrlHandler, editProfileImgDoneHandler, + resetNicknameHandler, + resetProfileImgHandler, }; diff --git a/src/views/emailNoSettlementPage.js b/src/views/emailNoSettlementPage.js index 8949c27f..5143c279 100644 --- a/src/views/emailNoSettlementPage.js +++ b/src/views/emailNoSettlementPage.js @@ -1,7 +1,6 @@ -const { frontUrl } = require("../../loadenv"); const emailPage = require("./emailPage"); -module.exports = (name, nickname, roomName, payer, roomId) => +module.exports = (origin, name, nickname, roomName, payer, roomId) => emailPage( "미정산 내역 관련 안내", `${name} (${nickname}) 님께

@@ -19,12 +18,16 @@ module.exports = (name, nickname, roomName, payer, roomId) =>
링크 - ${frontUrl}/myroom/${roomId} + ${new URL(`/myroom/${roomId}`, origin).href}

위 방에서 채팅을 확인하실 수 있으며, 결제하신 분께 해당 금액을 정산해주시기를 부탁드립니다.
미정산이 반복되는 경우 Taxi 서비스 이용이 제한될 수 있음을 알려드립니다.
- 문의가 필요하신 경우, 택시 서비스 내부의 "채널톡 문의하기" 혹은 메일 회신 주시면 됩니다.

+ 문의가 필요하신 경우, 택시 서비스 내부의 "채널톡 문의하기" 혹은 메일 회신 주시면 됩니다.

감사합니다.
SPARCS Taxi팀 드림. ` diff --git a/src/views/loginReplacePage.js b/src/views/loginReplacePage.js index c41c6025..209e0bb0 100644 --- a/src/views/loginReplacePage.js +++ b/src/views/loginReplacePage.js @@ -1,4 +1,4 @@ -module.exports = (redirectPath) => ` +module.exports = ` @@ -26,7 +26,6 @@ module.exports = (redirectPath) => ` const value = document.getElementById("input-id").value; if(value) post('/auth/login/replace', { id: value, - redirect: "${encodeURIComponent(redirectPath)}", }); } const enterHandler = (e) => { diff --git a/test/modules/auths/jwt.js b/test/modules/auths/jwt.js new file mode 100644 index 00000000..bac7edfa --- /dev/null +++ b/test/modules/auths/jwt.js @@ -0,0 +1,29 @@ +const { expect } = require("chai"); +const { sign, verify } = require("../../../src/modules/auths/jwt"); + +// jwt.js 관련 2개의 함수를 테스트 +// 1. jwt 서명과 검증이 성공적으로 되는지 테스트 +describe("[jwt] 1.sign & verify", () => { + it("should sign and verify jwt correctly", async () => { + // JWT 서명에 사용되는 사용자 + const user = { + _id: "507f191e810c19729de860ea", + }; + + // 토큰 생성이 성공적으로 되는지 테스트 + const { token: accessToken } = await sign({ + id: user._id, + type: "access", + }); + const { token: refreshToken } = await sign({ + id: user._id, + type: "refresh", + }); + + // 토큰 검증이 성공적으로 되는지 테스트 + const accessTokenStatus = await verify(accessToken); + expect(accessTokenStatus).to.has.property("id", user._id); + const refreshTokenStatus = await verify(refreshToken); + expect(refreshTokenStatus).to.has.property("id", user._id); + }); +}); diff --git a/test/auth.replace.js b/test/services/auth.replace.js similarity index 54% rename from test/auth.replace.js rename to test/services/auth.replace.js index 330d610d..643eddf7 100644 --- a/test/auth.replace.js +++ b/test/services/auth.replace.js @@ -1,11 +1,10 @@ const request = require("supertest"); -const authHandlers = require("../src/services/auth.replace"); -const { userModel } = require("../src/modules/stores/mongo"); -const { frontUrl } = require("../loadenv"); +const authHandlers = require("../../src/services/auth.replace"); +const { userModel } = require("../../src/modules/stores/mongo"); // auth.replace.js 관련 1개의 handler을 테스트 -// 1. dev 환경에서 front의 URL로 잘 redirect 되는지 확인 +// 1. dev 환경에서 로그인이 성공적으로 이루어지는지 확인 describe("[auth.replace] 1.sparcsssoHandler", () => { const removeTestUser = async () => { // drop all collections @@ -14,14 +13,13 @@ describe("[auth.replace] 1.sparcsssoHandler", () => { before(removeTestUser); - it("should redirect to frontUrl after successful user creation", () => { + it("should redirect after successful user creation", () => { request(authHandlers.sparcsssoHandler) .post("/auth/sparcssso") .send({ id: "test", }) - .expect(302) - .expect("Location", frontUrl); + .expect(302); }); after(removeTestUser); diff --git a/test/locations.js b/test/services/locations.js similarity index 87% rename from test/locations.js rename to test/services/locations.js index 19308e51..7df01482 100644 --- a/test/locations.js +++ b/test/services/locations.js @@ -1,15 +1,15 @@ -const expect = require("chai").expect; -const locationHandlers = require("../src/services/locations"); -const httpMocks = require("node-mocks-http"); - -// locations.js 관련 1개의 handler을 테스트 -// 1. 모든 location 정보를 잘 가져오는지 확인 -describe("[locations] 1.getAllLocationsHandler", () => { - it("should return information of locations correctly", async () => { - let req = httpMocks.createRequest({}); - let res = httpMocks.createResponse(); - await locationHandlers.getAllLocationsHandler(req, res); - - expect(res._getJSONData().locations).not.to.have.lengthOf(0); - }); -}); +const expect = require("chai").expect; +const locationHandlers = require("../../src/services/locations"); +const httpMocks = require("node-mocks-http"); + +// locations.js 관련 1개의 handler을 테스트 +// 1. 모든 location 정보를 잘 가져오는지 확인 +describe("[locations] 1.getAllLocationsHandler", () => { + it("should return information of locations correctly", async () => { + let req = httpMocks.createRequest({}); + let res = httpMocks.createResponse(); + await locationHandlers.getAllLocationsHandler(req, res); + + expect(res._getJSONData().locations).not.to.have.lengthOf(0); + }); +}); diff --git a/test/logininfo.js b/test/services/logininfo.js similarity index 94% rename from test/logininfo.js rename to test/services/logininfo.js index 64e492f7..30204643 100644 --- a/test/logininfo.js +++ b/test/services/logininfo.js @@ -1,6 +1,6 @@ const expect = require("chai").expect; -const logininfoHandlers = require("../src/services/logininfo"); -const { userModel } = require("../src/modules/stores/mongo"); +const logininfoHandlers = require("../../src/services/logininfo"); +const { userModel } = require("../../src/modules/stores/mongo"); // 1-1. 로그인 한 유저가 없을 시 undefined를 return 하는지 확인 // 1-2. login 정보를 잘 return 하는지 확인 diff --git a/test/reports.js b/test/services/reports.js similarity index 92% rename from test/reports.js rename to test/services/reports.js index 67bf4001..86347d4a 100644 --- a/test/reports.js +++ b/test/services/reports.js @@ -1,7 +1,7 @@ const expect = require("chai").expect; -const reportHandlers = require("../src/services/reports"); -const { userModel } = require("../src/modules/stores/mongo"); -const { userGenerator, roomGenerator, testRemover } = require("./utils"); +const reportHandlers = require("../../src/services/reports"); +const { userModel } = require("../../src/modules/stores/mongo"); +const { userGenerator, roomGenerator, testRemover } = require("../utils"); const httpMocks = require("node-mocks-http"); let testData = { rooms: [], users: [], chat: [], location: [], report: [] }; @@ -24,7 +24,7 @@ describe("[reports] 1.createHandler", () => { type: "etc-reason", etcDetail: "etc-detail", time: Date.now(), - roomId: testRoom._id + roomId: testRoom._id, }, }); let res = httpMocks.createResponse(); diff --git a/test/rooms.js b/test/services/rooms.js similarity index 95% rename from test/rooms.js rename to test/services/rooms.js index 6ba0c232..e17ae6af 100644 --- a/test/rooms.js +++ b/test/services/rooms.js @@ -1,203 +1,203 @@ -const expect = require("chai").expect; -const express = require("express"); -const roomsHandlers = require("../src/services/rooms"); -const { - userModel, - roomModel, - locationModel, -} = require("../src/modules/stores/mongo"); -const { userGenerator, testRemover } = require("./utils"); -const app = express(); -const httpMocks = require("node-mocks-http"); - -let testData = { rooms: [], users: [], chat: [], location: [], report: [] }; -const removeTestData = async () => { - // drop all testData - await testRemover(testData); -}; - -// rooms.js 관련 9개의 handler을 테스트 -// 1. test1이 1분 뒤에 출발하는 test-room 방을 생성, 제대로 생성 되었는지 확인 -describe("[rooms] 1.createHandler", () => { - it("should create room which departs after 1 minute", async () => { - const testUser1 = await userGenerator("test1", testData); - const testFrom = await locationModel.findOne({ koName: "대전역" }); - const testTo = await locationModel.findOne({ koName: "택시승강장" }); - let req = httpMocks.createRequest({ - body: { - name: "test-room", - from: testFrom._id, - to: testTo._id, - time: Date.now() + 60 * 1000, - maxPartLength: 4, - }, - userId: testUser1.id, - app, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.createHandler(req, res); - - const testRoom = await roomModel.findOne({ name: "test-room" }); - testData["rooms"].push(testRoom); - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - }); -}); - -// 2. test1을 통하여 방의 정보를 제대로 가져오는지 확인 -describe("[rooms] 2.infoHandler", () => { - it("should return information of room", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - const testRoom = await roomModel.findOne({ name: "test-room" }); - let req = httpMocks.createRequest({ - query: { id: testRoom._id }, - userId: testUser1.id, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.infoHandler(req, res); - - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - expect(resData).to.has.property("isOver"); - }); -}); - -// 3. 로그인되지 않은 유저가 방의 정보를 제대로 가져오는지 확인 -describe("[rooms] 3.publicInfoHandler", () => { - it("should return information of room", async () => { - const testRoom = await roomModel.findOne({ name: "test-room" }); - let req = httpMocks.createRequest({ - query: { id: testRoom._id }, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.publicInfoHandler(req, res); - - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - expect(resData).to.has.property("isOver", undefined); - }); -}); - -// 4. test2가 test-room에 join, 방에 잘 join 했는지 확인 -describe("[rooms] 4.joinHandler", () => { - it("should return information of room and join", async () => { - const testUser2 = await userGenerator("test2", testData); - const testRoom = await roomModel.findOne({ name: "test-room" }); - let req = httpMocks.createRequest({ - body: { - roomId: testRoom._id, - }, - userId: testUser2.id, - app, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.joinHandler(req, res); - - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - expect(resData.part).to.have.lengthOf(2); - }); -}); - -// 5. 방의 정보를 통해 검색, 검색 정보가 예상과 일치하는지 확인 -describe("[rooms] 5.searchHandler", () => { - it("should return information of searching room", async () => { - const testFrom = await locationModel.findOne({ koName: "대전역" }); - const testTo = await locationModel.findOne({ koName: "택시승강장" }); - let req = httpMocks.createRequest({ - query: { - name: "test-room", - from: testFrom._id, - to: testTo._id, - time: Date.now(), - withTime: true, - maxPartLength: 4, - }, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.searchHandler(req, res); - - const resJson = res._getJSONData(); - expect(resJson[0]).to.has.property("name", "test-room"); - expect(resJson[0].settlementTotal).to.be.undefined; - }); -}); - -// 6. 방에 속한 유저를 통해 검색 -// ongoing은 test-room이 검색되고, done은 아무것도 검색되지 않아야함 -describe("[rooms] 6.searchByUserHandler", () => { - it("should return information of searching room", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - let req = httpMocks.createRequest({ - userId: testUser1.id, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.searchByUserHandler(req, res); - - const resJson = res._getJSONData(); - expect(resJson["ongoing"][0]).to.has.property("name", "test-room"); - expect(resJson["done"][0]).to.be.undefined; - }); -}); - -// 7. 1분이 지난 후, 정산 정보를 불러옴. 예상과 같은 정보를 불러오는지 확인 -describe("[rooms] 7.commitPaymentHandler", () => { - it("should return information of room and commit payment", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - const testRoom = await roomModel.findOne({ name: "test-room" }); - let req = httpMocks.createRequest({ - body: { roomId: testRoom._id }, - userId: testUser1.id, - timestamp: Date.now() + 60 * 1000, - app, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.commitPaymentHandler(req, res); - - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - expect(resData).to.has.property("isOver", true); - expect(resData).to.has.property("settlementTotal", 1); - }); -}); - -// 8. 도착 정보를 불러옴. 예상과 같은 정보를 불러오는지 확인 -describe("[rooms] 8.settlementHandler", () => { - it("should return information of room and set settlement", async () => { - const testUser2 = await userModel.findOne({ id: "test2" }); - const testRoom = await roomModel.findOne({ name: "test-room" }); - let req = httpMocks.createRequest({ - body: { roomId: testRoom._id }, - userId: testUser2.id, - app, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.settlementHandler(req, res); - - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - expect(resData).to.has.property("isOver", true); - expect(resData).to.has.property("settlementTotal", 2); - }); -}); - -// 9. test2 방에서 퇴장, 제대로 방에서 나갔는지 확인하고 생성해준 data 모두 삭제 -describe("[rooms] 9.abortHandler", () => { - it("should return information of room and abort user", async () => { - const testUser2 = await userModel.findOne({ id: "test2" }); - const testRoom = await roomModel.findOne({ name: "test-room" }); - let req = httpMocks.createRequest({ - body: { roomId: testRoom._id }, - userId: testUser2.id, - session: {}, - app, - }); - let res = httpMocks.createResponse(); - await roomsHandlers.abortHandler(req, res); - afterEach(removeTestData); - - const resData = res._getData(); - expect(resData).to.has.property("name", "test-room"); - expect(resData.part).to.have.lengthOf(1); - }); -}); +const expect = require("chai").expect; +const express = require("express"); +const roomsHandlers = require("../../src/services/rooms"); +const { + userModel, + roomModel, + locationModel, +} = require("../../src/modules/stores/mongo"); +const { userGenerator, testRemover } = require("../utils"); +const app = express(); +const httpMocks = require("node-mocks-http"); + +let testData = { rooms: [], users: [], chat: [], location: [], report: [] }; +const removeTestData = async () => { + // drop all testData + await testRemover(testData); +}; + +// rooms.js 관련 9개의 handler을 테스트 +// 1. test1이 1분 뒤에 출발하는 test-room 방을 생성, 제대로 생성 되었는지 확인 +describe("[rooms] 1.createHandler", () => { + it("should create room which departs after 1 minute", async () => { + const testUser1 = await userGenerator("test1", testData); + const testFrom = await locationModel.findOne({ koName: "대전역" }); + const testTo = await locationModel.findOne({ koName: "택시승강장" }); + let req = httpMocks.createRequest({ + body: { + name: "test-room", + from: testFrom._id, + to: testTo._id, + time: Date.now() + 60 * 1000, + maxPartLength: 4, + }, + userId: testUser1.id, + app, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.createHandler(req, res); + + const testRoom = await roomModel.findOne({ name: "test-room" }); + testData["rooms"].push(testRoom); + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + }); +}); + +// 2. test1을 통하여 방의 정보를 제대로 가져오는지 확인 +describe("[rooms] 2.infoHandler", () => { + it("should return information of room", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + const testRoom = await roomModel.findOne({ name: "test-room" }); + let req = httpMocks.createRequest({ + query: { id: testRoom._id }, + userId: testUser1.id, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.infoHandler(req, res); + + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + expect(resData).to.has.property("isOver"); + }); +}); + +// 3. 로그인되지 않은 유저가 방의 정보를 제대로 가져오는지 확인 +describe("[rooms] 3.publicInfoHandler", () => { + it("should return information of room", async () => { + const testRoom = await roomModel.findOne({ name: "test-room" }); + let req = httpMocks.createRequest({ + query: { id: testRoom._id }, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.publicInfoHandler(req, res); + + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + expect(resData).to.has.property("isOver", undefined); + }); +}); + +// 4. test2가 test-room에 join, 방에 잘 join 했는지 확인 +describe("[rooms] 4.joinHandler", () => { + it("should return information of room and join", async () => { + const testUser2 = await userGenerator("test2", testData); + const testRoom = await roomModel.findOne({ name: "test-room" }); + let req = httpMocks.createRequest({ + body: { + roomId: testRoom._id, + }, + userId: testUser2.id, + app, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.joinHandler(req, res); + + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + expect(resData.part).to.have.lengthOf(2); + }); +}); + +// 5. 방의 정보를 통해 검색, 검색 정보가 예상과 일치하는지 확인 +describe("[rooms] 5.searchHandler", () => { + it("should return information of searching room", async () => { + const testFrom = await locationModel.findOne({ koName: "대전역" }); + const testTo = await locationModel.findOne({ koName: "택시승강장" }); + let req = httpMocks.createRequest({ + query: { + name: "test-room", + from: testFrom._id, + to: testTo._id, + time: Date.now(), + withTime: true, + maxPartLength: 4, + }, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.searchHandler(req, res); + + const resJson = res._getJSONData(); + expect(resJson[0]).to.has.property("name", "test-room"); + expect(resJson[0].settlementTotal).to.be.undefined; + }); +}); + +// 6. 방에 속한 유저를 통해 검색 +// ongoing은 test-room이 검색되고, done은 아무것도 검색되지 않아야함 +describe("[rooms] 6.searchByUserHandler", () => { + it("should return information of searching room", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + let req = httpMocks.createRequest({ + userId: testUser1.id, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.searchByUserHandler(req, res); + + const resJson = res._getJSONData(); + expect(resJson["ongoing"][0]).to.has.property("name", "test-room"); + expect(resJson["done"][0]).to.be.undefined; + }); +}); + +// 7. 1분이 지난 후, 정산 정보를 불러옴. 예상과 같은 정보를 불러오는지 확인 +describe("[rooms] 7.commitPaymentHandler", () => { + it("should return information of room and commit payment", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + const testRoom = await roomModel.findOne({ name: "test-room" }); + let req = httpMocks.createRequest({ + body: { roomId: testRoom._id }, + userId: testUser1.id, + timestamp: Date.now() + 60 * 1000, + app, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.commitPaymentHandler(req, res); + + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + expect(resData).to.has.property("isOver", true); + expect(resData).to.has.property("settlementTotal", 1); + }); +}); + +// 8. 도착 정보를 불러옴. 예상과 같은 정보를 불러오는지 확인 +describe("[rooms] 8.settlementHandler", () => { + it("should return information of room and set settlement", async () => { + const testUser2 = await userModel.findOne({ id: "test2" }); + const testRoom = await roomModel.findOne({ name: "test-room" }); + let req = httpMocks.createRequest({ + body: { roomId: testRoom._id }, + userId: testUser2.id, + app, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.settlementHandler(req, res); + + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + expect(resData).to.has.property("isOver", true); + expect(resData).to.has.property("settlementTotal", 2); + }); +}); + +// 9. test2 방에서 퇴장, 제대로 방에서 나갔는지 확인하고 생성해준 data 모두 삭제 +describe("[rooms] 9.abortHandler", () => { + it("should return information of room and abort user", async () => { + const testUser2 = await userModel.findOne({ id: "test2" }); + const testRoom = await roomModel.findOne({ name: "test-room" }); + let req = httpMocks.createRequest({ + body: { roomId: testRoom._id }, + userId: testUser2.id, + session: {}, + app, + }); + let res = httpMocks.createResponse(); + await roomsHandlers.abortHandler(req, res); + afterEach(removeTestData); + + const resData = res._getData(); + expect(resData).to.has.property("name", "test-room"); + expect(resData.part).to.have.lengthOf(1); + }); +}); diff --git a/test/users.js b/test/services/users.js similarity index 94% rename from test/users.js rename to test/services/users.js index 1efa0d86..1bb2f586 100644 --- a/test/users.js +++ b/test/services/users.js @@ -1,140 +1,140 @@ -const expect = require("chai").expect; -const usersHandlers = require("../src/services/users"); -const { userModel } = require("../src/modules/stores/mongo"); -const { userGenerator, testRemover } = require("./utils"); -const httpMocks = require("node-mocks-http"); - -let testData = { rooms: [], users: [], chat: [], location: [], report: [] }; -const removeTestData = async () => { - await testRemover(testData); -}; - -// users.js 관련 5개의 handler을 테스트 -// 1. test1 유저를 생성 후, agreeOnTermsOfServiceHandler가 제대로 msg를 send 하는지 확인 -describe("[users] 1.agreeOnTermsOfServiceHandler", () => { - it("should return correct response from handler", async () => { - const testUser1 = await userGenerator("test1", testData); - const msg = - "User/agreeOnTermsOfService : agree on Terms of Service successful"; - let req = httpMocks.createRequest({ - userId: testUser1.id, - }); - let res = httpMocks.createResponse(); - await usersHandlers.agreeOnTermsOfServiceHandler(req, res); - - const resData = res._getData(); - expect(res).to.has.property("statusCode", 200); - expect(resData).to.equal(msg); - }); -}); - -// 2. test1 유저의 agreeOnTermsOfService 정보를 가져와서 true인지 확인 -describe("[users] 2.getAgreeOnTermsOfServiceHandler", () => { - it("should return AgreeOnTermsOfService of user", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - let req = httpMocks.createRequest({ - userId: testUser1.id, - }); - let res = httpMocks.createResponse(); - await usersHandlers.getAgreeOnTermsOfServiceHandler(req, res); - - const resJson = res._getJSONData(); - expect(res).to.has.property("statusCode", 200); - expect(resJson).to.has.property("agreeOnTermsOfService", true); - }); -}); - -// 3. test1 유저의 nickname을 test-nickname으로 변경, 성공 메세지가 제대로 오는지 확인 -describe("[users] 3.editNicknameHandler", () => { - const testNickname = "test-nickname"; - - it("should return correct response from handler", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - const msg = "User/editNickname : edit user nickname successful"; - let req = httpMocks.createRequest({ - userId: testUser1.id, - body: { - nickname: testNickname, - }, - }); - let res = httpMocks.createResponse(); - await usersHandlers.editNicknameHandler(req, res); - - const resData = res._getData(); - expect(res).to.has.property("statusCode", 200); - expect(resData).to.equal(msg); - }); - - it("should be changed to new nickname", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - expect(testUser1).to.have.property("nickname", testNickname); - }); -}); - -// 3. test1 유저의 계좌번호를 testAccount으로 변경, 성공 메세지가 제대로 오는지 확인 -describe("[users] 4.editAccountHandler", () => { - const testAccount = "신한 0123456789012"; - - it("should return correct response from handler", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - const msg = "User/editAccount : edit user account successful"; - let req = httpMocks.createRequest({ - userId: testUser1.id, - body: { - account: testAccount, - }, - }); - let res = httpMocks.createResponse(); - await usersHandlers.editAccountHandler(req, res); - - const resData = res._getData(); - expect(res).to.has.property("statusCode", 200); - expect(resData).to.equal(msg); - }); - - it("should be changed to new account", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - expect(testUser1).to.have.property("account", testAccount); - }); -}); - -// 5. test1 유저의 프로필 업로드를 위한 PUrl을 제대로 받았는지 확인 -// 추가 검증을 위해, key와 Content-Type이 일치하는지 확인 -describe("[users] 5.editProfileImgGetPUrlHandler", () => { - it("should return url and fields of data", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - const testImgType = "image/jpg"; - let req = httpMocks.createRequest({ - userId: testUser1.id, - body: { - type: testImgType, - }, - }); - let res = httpMocks.createResponse(); - await usersHandlers.editProfileImgGetPUrlHandler(req, res); - - const resJson = res._getJSONData(); - expect(res).to.has.property("statusCode", 200); - expect(resJson).to.has.property("url"); - expect(resJson.fields).to.has.property( - "key", - `profile-img/${testUser1._id}` - ); - expect(resJson.fields).to.has.property("Content-Type", testImgType); - }); -}); - -// 6. test1 유저의 프로필 업로드가 정상적으로 완료되었는지 확인 -describe("[users] 6.editProfileImgDoneHandler", () => { - it("should return correct result and new profileImageUrl", async () => { - const testUser1 = await userModel.findOne({ id: "test1" }); - let req = httpMocks.createRequest({ - userId: testUser1.id, - }); - let res = httpMocks.createResponse(); - await usersHandlers.editProfileImgDoneHandler(req, res); - afterEach(removeTestData); - - expect(res).to.has.property("statusCode", 200); - }); -}); +const expect = require("chai").expect; +const usersHandlers = require("../../src/services/users"); +const { userModel } = require("../../src/modules/stores/mongo"); +const { userGenerator, testRemover } = require("../utils"); +const httpMocks = require("node-mocks-http"); + +let testData = { rooms: [], users: [], chat: [], location: [], report: [] }; +const removeTestData = async () => { + await testRemover(testData); +}; + +// users.js 관련 5개의 handler을 테스트 +// 1. test1 유저를 생성 후, agreeOnTermsOfServiceHandler가 제대로 msg를 send 하는지 확인 +describe("[users] 1.agreeOnTermsOfServiceHandler", () => { + it("should return correct response from handler", async () => { + const testUser1 = await userGenerator("test1", testData); + const msg = + "User/agreeOnTermsOfService : agree on Terms of Service successful"; + let req = httpMocks.createRequest({ + userId: testUser1.id, + }); + let res = httpMocks.createResponse(); + await usersHandlers.agreeOnTermsOfServiceHandler(req, res); + + const resData = res._getData(); + expect(res).to.has.property("statusCode", 200); + expect(resData).to.equal(msg); + }); +}); + +// 2. test1 유저의 agreeOnTermsOfService 정보를 가져와서 true인지 확인 +describe("[users] 2.getAgreeOnTermsOfServiceHandler", () => { + it("should return AgreeOnTermsOfService of user", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + let req = httpMocks.createRequest({ + userId: testUser1.id, + }); + let res = httpMocks.createResponse(); + await usersHandlers.getAgreeOnTermsOfServiceHandler(req, res); + + const resJson = res._getJSONData(); + expect(res).to.has.property("statusCode", 200); + expect(resJson).to.has.property("agreeOnTermsOfService", true); + }); +}); + +// 3. test1 유저의 nickname을 test-nickname으로 변경, 성공 메세지가 제대로 오는지 확인 +describe("[users] 3.editNicknameHandler", () => { + const testNickname = "test-nickname"; + + it("should return correct response from handler", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + const msg = "User/editNickname : edit user nickname successful"; + let req = httpMocks.createRequest({ + userId: testUser1.id, + body: { + nickname: testNickname, + }, + }); + let res = httpMocks.createResponse(); + await usersHandlers.editNicknameHandler(req, res); + + const resData = res._getData(); + expect(res).to.has.property("statusCode", 200); + expect(resData).to.equal(msg); + }); + + it("should be changed to new nickname", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + expect(testUser1).to.have.property("nickname", testNickname); + }); +}); + +// 3. test1 유저의 계좌번호를 testAccount으로 변경, 성공 메세지가 제대로 오는지 확인 +describe("[users] 4.editAccountHandler", () => { + const testAccount = "신한 0123456789012"; + + it("should return correct response from handler", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + const msg = "User/editAccount : edit user account successful"; + let req = httpMocks.createRequest({ + userId: testUser1.id, + body: { + account: testAccount, + }, + }); + let res = httpMocks.createResponse(); + await usersHandlers.editAccountHandler(req, res); + + const resData = res._getData(); + expect(res).to.has.property("statusCode", 200); + expect(resData).to.equal(msg); + }); + + it("should be changed to new account", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + expect(testUser1).to.have.property("account", testAccount); + }); +}); + +// 5. test1 유저의 프로필 업로드를 위한 PUrl을 제대로 받았는지 확인 +// 추가 검증을 위해, key와 Content-Type이 일치하는지 확인 +describe("[users] 5.editProfileImgGetPUrlHandler", () => { + it("should return url and fields of data", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + const testImgType = "image/jpg"; + let req = httpMocks.createRequest({ + userId: testUser1.id, + body: { + type: testImgType, + }, + }); + let res = httpMocks.createResponse(); + await usersHandlers.editProfileImgGetPUrlHandler(req, res); + + const resJson = res._getJSONData(); + expect(res).to.has.property("statusCode", 200); + expect(resJson).to.has.property("url"); + expect(resJson.fields).to.has.property( + "key", + `profile-img/${testUser1._id}` + ); + expect(resJson.fields).to.has.property("Content-Type", testImgType); + }); +}); + +// 6. test1 유저의 프로필 업로드가 정상적으로 완료되었는지 확인 +describe("[users] 6.editProfileImgDoneHandler", () => { + it("should return correct result and new profileImageUrl", async () => { + const testUser1 = await userModel.findOne({ id: "test1" }); + let req = httpMocks.createRequest({ + userId: testUser1.id, + }); + let res = httpMocks.createResponse(); + await usersHandlers.editProfileImgDoneHandler(req, res); + afterEach(removeTestData); + + expect(res).to.has.property("statusCode", 200); + }); +}); diff --git a/test/utils.js b/test/utils.js index 5fbfcd38..b3920e83 100644 --- a/test/utils.js +++ b/test/utils.js @@ -4,9 +4,12 @@ const { chatModel, locationModel, reportModel, + connectDatabase, } = require("../src/modules/stores/mongo"); const { generateProfileImageUrl } = require("../src/modules/modifyProfile"); +connectDatabase(); + // 테스트를 위한 유저 생성 함수 const userGenerator = async (username, testData) => { const testUser = new userModel({