diff --git a/.env.example b/.env.example index 769e0fa8..ea107da9 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,10 @@ CORS_WHITELIST=[CORS 정책에서 허용하는 도메인의 목록(e.g. ["http:/ GOOGLE_APPLICATION_CREDENTIALS=[GOOGLE_APPLICATION_CREDENTIALS JSON] TEST_ACCOUNTS=[스팍스SSO로 로그인시 무조건 테스트로 로그인이 가능한 허용 아이디 목록] SLACK_REPORT_WEBHOOK_URL=[Slack 웹훅 URL들이 담긴 JSON] + +# optional environment variables for taxiSampleGenerator +SAMPLE_NUM_OF_ROOMS=[방의 개수] +SAMPLE_NUM_OF_CHATS=[각 방의 채팅 개수] +SAMPLE_MAXIMUM_INTERVAL_BETWEEN_CHATS=[채팅 간 최대 시간 간격(단위: 초, 실수도 가능)] +SAMPLE_OCCURENCE_OF_JOIN=[새로운 채팅이 입장 메세지일 확률(0 ~ 1 사이의 값)] +SAMPLE_OCCURENCE_OF_ABORT=[새로운 채팅이 퇴장 메세지일 확률(0 ~ 1 사이의 값)] \ No newline at end of file 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..2ccc5415 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -12,38 +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 - - id: submodule-local - name: Save local version of submodule - run: echo "ver=`cd sampleGenerator && git log --pretty="%h" -1 && cd ..`" >> $GITHUB_OUTPUT - - id: submodule-origin - name: Save origin version of submodule - run: echo "ver=`cd sampleGenerator && git log origin --pretty="%h" -1 && cd ..`" >> $GITHUB_OUTPUT - - name: Check submodule version - if: ${{ steps.submodule-local.outputs.ver != steps.submodule-origin.outputs.ver }} - uses: actions/github-script@v3 + - name: Install Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 with: - script: | - core.setFailed('Please update submodule to the latest version by using \"git submodule update --remote\"') - - name: Install sampleGenerator dependencies from package-lock.json - run: cd sampleGenerator && pnpm i --force --frozen-lockfile && cd .. + node-version: ${{ matrix.node-version }} + cache: 'pnpm' - name: Install taxi-back dependencies from package-lock.json run: pnpm i --force --frozen-lockfile - name: Run unit tests @@ -53,6 +38,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/.gitmodules b/.gitmodules deleted file mode 100644 index f15db4d8..00000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "sampleGenerator"] - path = sampleGenerator - url = https://github.com/sparcs-kaist/taxiSampleGenerator - branch = main 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 fbd01b8a..9a335131 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,7 +25,7 @@ $ git clone https://github.com/sparcs-kaist/taxi-back ### Install Requirements ```bash -$ npm install --save +$ pnpm install ``` ### Set Environment Configuration @@ -32,7 +33,9 @@ See [notion page](https://www.notion.so/sparcs/Environment-Variables-1b404bd385f 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 @@ -46,4 +49,3 @@ See [contributors](https://github.com/sparcs-kaist/taxi-front/graphs/contributor - app : https://github.com/sparcs-kaist/taxi-app - docker : https://github.com/sparcs-kaist/taxi-docker - figma : https://www.figma.com/file/li34hP1oStJAzLNjcG5KjN/SPARCS-Taxi?node-id=0%3A1 - - taxiSampleGenerator : https://github.com/sparcs-kaist/taxiSampleGenerator diff --git a/app.js b/app.js index 6049e736..db9773dc 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,14 @@ // 모듈 require const express = require("express"); const http = require("http"); -const { port: httpPort } = require("./loadenv"); +const { + nodeEnv, + port: httpPort, + eventConfig, + mongo: mongoUrl, +} = require("./loadenv"); const logger = require("./src/modules/logger"); +const { connectDatabase } = require("./src/modules/stores/mongo"); const { startSocketServer } = require("./src/modules/socket"); // Firebase Admin 초기설정 @@ -11,10 +17,16 @@ require("./src/modules/fcm").initializeApp(); // 익스프레스 서버 생성 const app = express(); +// 데이터베이스 연결 +connectDatabase(mongoUrl); + // [Middleware] request body 파싱 app.use(express.urlencoded({ extended: false })); app.use(express.json()); +// reverse proxy가 설정한 헤더를 신뢰합니다. +if (nodeEnv === "production") app.set("trust proxy", 2); + // [Middleware] CORS 설정 app.use(require("./src/middlewares/cors")); @@ -38,6 +50,13 @@ app.use(require("./src/middlewares/limitRate")); // [Router] Swagger (API 문서) app.use("/docs", require("./src/routes/docs")); +// [Router] 이벤트 전용 라우터입니다. +eventConfig && + app.use( + `/events/${eventConfig.mode}`, + require("./src/lottery").lotteryRouter + ); + // [Middleware] 모든 API 요청에 대하여 origin 검증 app.use(require("./src/middlewares/originValidator")); @@ -58,7 +77,7 @@ app.use(require("./src/middlewares/errorHandler")); const serverHttp = http .createServer(app) .listen(httpPort, () => - logger.info(`Express 서버가 ${httpPort}번 포트에서 시작됨.`) + logger.info(`Express server started from port ${httpPort}`) ); // socket.io 서버 시작 diff --git a/loadenv.js b/loadenv.js index 62c50d7f..438b6f01 100644 --- a/loadenv.js +++ b/loadenv.js @@ -2,9 +2,12 @@ require("dotenv").config({ path: `./.env.${process.env.NODE_ENV}` }); module.exports = { - nodeEnv: process.env.NODE_ENV, + nodeEnv: process.env.NODE_ENV, // required ("production" or "development" or "test") mongo: process.env.DB_PATH, // required - session: process.env.SESSION_KEY || "TAXI_SESSION_KEY", // optional + session: { + secret: process.env.SESSION_KEY || "TAXI_SESSION_KEY", // optional + expiry: 14 * 24 * 3600 * 1000, // 14일, ms 단위입니다. + }, redis: process.env.REDIS_PATH, // optional sparcssso: { id: process.env.SPARCSSSO_CLIENT_ID || "", // optional @@ -25,6 +28,8 @@ 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, @@ -38,4 +43,5 @@ module.exports = { slackWebhookUrl: { report: process.env.SLACK_REPORT_WEBHOOK_URL || "", // optional }, + eventConfig: process.env.EVENT_CONFIG && JSON.parse(process.env.EVENT_CONFIG), // optional }; diff --git a/package.json b/package.json index cdb57f4f..4ea2a648 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,27 @@ "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", + "runscript": "cross-env TZ='Asia/Seoul' NODE_ENV=production node", + "lint": "npx eslint --fix .", + "sample": "cd src/sampleGenerator && npm start && cd .." + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, "dependencies": { "@adminjs/express": "^5.1.0", "@adminjs/mongoose": "^3.0.3", "adminjs": "^6.8.7", - "ajv": "^8.12.0", - "ajv-errors": "^3.0.0", - "ajv-formats": "^2.1.1", "aws-sdk": "^2.1386.0", "axios": "^0.27.2", "ci": "^2.2.0", @@ -22,14 +35,15 @@ "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", + "nodemailer": "^6.9.9", "querystring": "^0.2.1", "redis": "^4.2.0", "response-time": "^2.3.2", @@ -37,25 +51,17 @@ "swagger-ui-express": "^4.6.0", "validator": "^13.7.0", "winston": "^3.8.1", - "winston-daily-rotate-file": "^4.7.1" + "winston-daily-rotate-file": "^4.7.1", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.4" }, "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", + "mongodb": "^4.1.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 2249f8ac..0317beb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,22 +7,13 @@ settings: dependencies: '@adminjs/express': specifier: ^5.1.0 - 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) + 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.2) '@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) adminjs: specifier: ^6.8.7 version: 6.8.7 - ajv: - specifier: ^8.12.0 - version: 8.12.0 - ajv-errors: - specifier: ^3.0.0 - version: 3.0.0(ajv@8.12.0) - ajv-formats: - specifier: ^2.1.1 - version: 2.1.1(ajv@8.12.0) aws-sdk: specifier: ^2.1386.0 version: 2.1430.0 @@ -34,7 +25,7 @@ dependencies: version: 2.2.0 connect-mongo: specifier: ^4.6.0 - version: 4.6.0(express-session@1.17.3)(mongodb@4.16.0) + version: 4.6.0(express-session@1.17.3)(mongodb@4.17.1) connect-redis: specifier: ^6.1.3 version: 6.1.3 @@ -60,8 +51,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 @@ -72,17 +63,20 @@ 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 + nodemailer: + specifier: ^6.9.9 + version: 6.9.9 querystring: specifier: ^0.2.1 version: 0.2.1 @@ -107,11 +101,17 @@ dependencies: winston-daily-rotate-file: specifier: ^4.7.1 version: 4.7.1(winston@3.10.0) + zod: + specifier: ^3.22.4 + version: 3.22.4 + zod-to-json-schema: + specifier: ^3.22.4 + version: 3.22.4(zod@3.22.4) devDependencies: chai: - specifier: '*' - version: 4.3.7 + specifier: ^4.3.10 + version: 4.3.10 eslint: specifier: ^8.22.0 version: 8.22.0 @@ -119,11 +119,14 @@ devDependencies: specifier: ^10.1.0 version: 10.1.0(eslint@8.22.0) mocha: - specifier: '*' + specifier: ^10.2.0 version: 10.2.0 + mongodb: + specifier: ^4.1.0 + version: 4.17.1 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 @@ -181,7 +184,7 @@ packages: - prop-types dev: false - /@adminjs/express@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/express@5.1.0(adminjs@6.8.7)(express-formidable@1.2.0)(express-session@1.17.3)(express@4.18.2)(tslib@2.6.2): resolution: {integrity: sha512-+mrtDmoAYA9R+/FTYWOLL48g005yrgcAWC2phdwqGzznIxGKSp2YERcfzdTI7Svtnlaal72/QW8Q3OhzJjVLzQ==} peerDependencies: adminjs: '>=6.0.0' @@ -195,10 +198,10 @@ packages: express-formidable: 1.2.0 express-session: 1.17.3 path-to-regexp: 6.2.1 - tslib: 2.6.1 + tslib: 2.6.2 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' @@ -207,7 +210,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: @@ -223,9 +226,8 @@ packages: requiresBuild: true dependencies: '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.378.0 + '@aws-sdk/types': 3.425.0 tslib: 1.14.1 - dev: false optional: true /@aws-crypto/ie11-detection@3.0.0: @@ -233,7 +235,6 @@ packages: requiresBuild: true dependencies: tslib: 1.14.1 - dev: false optional: true /@aws-crypto/sha256-browser@3.0.0: @@ -244,11 +245,10 @@ packages: '@aws-crypto/sha256-js': 3.0.0 '@aws-crypto/supports-web-crypto': 3.0.0 '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.378.0 + '@aws-sdk/types': 3.425.0 '@aws-sdk/util-locate-window': 3.310.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 - dev: false optional: true /@aws-crypto/sha256-js@3.0.0: @@ -256,9 +256,8 @@ packages: requiresBuild: true dependencies: '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.378.0 + '@aws-sdk/types': 3.425.0 tslib: 1.14.1 - dev: false optional: true /@aws-crypto/supports-web-crypto@3.0.0: @@ -266,396 +265,437 @@ packages: requiresBuild: true dependencies: tslib: 1.14.1 - dev: false optional: true /@aws-crypto/util@3.0.0: resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 + '@aws-sdk/types': 3.425.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 - dev: false optional: true - /@aws-sdk/client-cognito-identity@3.385.0: - resolution: {integrity: sha512-fRXZhxvBBeK/Jxb+sLPhyQmcduNSugSKJDz474A/wLK5UIuDOnKhDTjsa0OXMpY5DkqwdYLwDcGZtxUbEZ8DCQ==} + /@aws-sdk/client-cognito-identity@3.427.0: + resolution: {integrity: sha512-9brRaNnl6haE7R3R43A5CSNw0k1YtB3xjuArbMg/p6NDUpvRSRgOVNWu2R02Yjh/j2ZuaLOCPLuCipb+PHQPKQ==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.385.0 - '@aws-sdk/credential-provider-node': 3.385.0 - '@aws-sdk/middleware-host-header': 3.379.1 - '@aws-sdk/middleware-logger': 3.378.0 - '@aws-sdk/middleware-recursion-detection': 3.378.0 - '@aws-sdk/middleware-signing': 3.379.1 - '@aws-sdk/middleware-user-agent': 3.382.0 - '@aws-sdk/types': 3.378.0 - '@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/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 + '@aws-sdk/client-sts': 3.427.0 + '@aws-sdk/credential-provider-node': 3.427.0 + '@aws-sdk/middleware-host-header': 3.425.0 + '@aws-sdk/middleware-logger': 3.425.0 + '@aws-sdk/middleware-recursion-detection': 3.425.0 + '@aws-sdk/middleware-signing': 3.425.0 + '@aws-sdk/middleware-user-agent': 3.427.0 + '@aws-sdk/region-config-resolver': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@aws-sdk/util-endpoints': 3.427.0 + '@aws-sdk/util-user-agent-browser': 3.425.0 + '@aws-sdk/util-user-agent-node': 3.425.0 + '@smithy/config-resolver': 2.0.14 + '@smithy/fetch-http-handler': 2.2.2 + '@smithy/hash-node': 2.0.11 + '@smithy/invalid-dependency': 2.0.11 + '@smithy/middleware-content-length': 2.0.13 + '@smithy/middleware-endpoint': 2.0.11 + '@smithy/middleware-retry': 2.0.16 + '@smithy/middleware-serde': 2.0.11 + '@smithy/middleware-stack': 2.0.5 + '@smithy/node-config-provider': 2.1.1 + '@smithy/node-http-handler': 2.1.7 + '@smithy/protocol-http': 3.0.7 + '@smithy/smithy-client': 2.1.10 + '@smithy/types': 2.3.5 + '@smithy/url-parser': 2.0.11 '@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-retry': 2.0.0 + '@smithy/util-body-length-node': 2.1.0 + '@smithy/util-defaults-mode-browser': 2.0.14 + '@smithy/util-defaults-mode-node': 2.0.18 + '@smithy/util-retry': 2.0.4 '@smithy/util-utf8': 2.0.0 - tslib: 2.6.1 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/client-sso@3.382.0: - resolution: {integrity: sha512-ge11t4hJllOF8pBNF0p1X52lLqUsLGAoey24fvk3fyvvczeLpegGYh2kdLG0iwFTDgRxaUqK+kboH5Wy9ux/pw==} + /@aws-sdk/client-sso@3.427.0: + resolution: {integrity: sha512-sFVFEmsQ1rmgYO1SgrOTxE/MTKpeE4hpOkm1WqhLQK7Ij136vXpjCxjH1JYZiHiUzO1wr9t4ex4dlB5J3VS/Xg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/middleware-host-header': 3.379.1 - '@aws-sdk/middleware-logger': 3.378.0 - '@aws-sdk/middleware-recursion-detection': 3.378.0 - '@aws-sdk/middleware-user-agent': 3.382.0 - '@aws-sdk/types': 3.378.0 - '@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/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 + '@aws-sdk/middleware-host-header': 3.425.0 + '@aws-sdk/middleware-logger': 3.425.0 + '@aws-sdk/middleware-recursion-detection': 3.425.0 + '@aws-sdk/middleware-user-agent': 3.427.0 + '@aws-sdk/region-config-resolver': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@aws-sdk/util-endpoints': 3.427.0 + '@aws-sdk/util-user-agent-browser': 3.425.0 + '@aws-sdk/util-user-agent-node': 3.425.0 + '@smithy/config-resolver': 2.0.14 + '@smithy/fetch-http-handler': 2.2.2 + '@smithy/hash-node': 2.0.11 + '@smithy/invalid-dependency': 2.0.11 + '@smithy/middleware-content-length': 2.0.13 + '@smithy/middleware-endpoint': 2.0.11 + '@smithy/middleware-retry': 2.0.16 + '@smithy/middleware-serde': 2.0.11 + '@smithy/middleware-stack': 2.0.5 + '@smithy/node-config-provider': 2.1.1 + '@smithy/node-http-handler': 2.1.7 + '@smithy/protocol-http': 3.0.7 + '@smithy/smithy-client': 2.1.10 + '@smithy/types': 2.3.5 + '@smithy/url-parser': 2.0.11 '@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-retry': 2.0.0 + '@smithy/util-body-length-node': 2.1.0 + '@smithy/util-defaults-mode-browser': 2.0.14 + '@smithy/util-defaults-mode-node': 2.0.18 + '@smithy/util-retry': 2.0.4 '@smithy/util-utf8': 2.0.0 - tslib: 2.6.1 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/client-sts@3.385.0: - resolution: {integrity: sha512-VdSDwICW2cBttbdj1izu6VYflJbZZKu3/FSaJGuGu8SgTvRsa56g6E5xfbUfR/SCstuETObKLusSfQZ6yxUnzA==} + /@aws-sdk/client-sts@3.427.0: + resolution: {integrity: sha512-le2wLJKILyWuRfPz2HbyaNtu5kEki+ojUkTqCU6FPDRrqUvEkaaCBH9Awo/2AtrCfRkiobop8RuTTj6cAnpiJg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/credential-provider-node': 3.385.0 - '@aws-sdk/middleware-host-header': 3.379.1 - '@aws-sdk/middleware-logger': 3.378.0 - '@aws-sdk/middleware-recursion-detection': 3.378.0 - '@aws-sdk/middleware-sdk-sts': 3.379.1 - '@aws-sdk/middleware-signing': 3.379.1 - '@aws-sdk/middleware-user-agent': 3.382.0 - '@aws-sdk/types': 3.378.0 - '@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/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 + '@aws-sdk/credential-provider-node': 3.427.0 + '@aws-sdk/middleware-host-header': 3.425.0 + '@aws-sdk/middleware-logger': 3.425.0 + '@aws-sdk/middleware-recursion-detection': 3.425.0 + '@aws-sdk/middleware-sdk-sts': 3.425.0 + '@aws-sdk/middleware-signing': 3.425.0 + '@aws-sdk/middleware-user-agent': 3.427.0 + '@aws-sdk/region-config-resolver': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@aws-sdk/util-endpoints': 3.427.0 + '@aws-sdk/util-user-agent-browser': 3.425.0 + '@aws-sdk/util-user-agent-node': 3.425.0 + '@smithy/config-resolver': 2.0.14 + '@smithy/fetch-http-handler': 2.2.2 + '@smithy/hash-node': 2.0.11 + '@smithy/invalid-dependency': 2.0.11 + '@smithy/middleware-content-length': 2.0.13 + '@smithy/middleware-endpoint': 2.0.11 + '@smithy/middleware-retry': 2.0.16 + '@smithy/middleware-serde': 2.0.11 + '@smithy/middleware-stack': 2.0.5 + '@smithy/node-config-provider': 2.1.1 + '@smithy/node-http-handler': 2.1.7 + '@smithy/protocol-http': 3.0.7 + '@smithy/smithy-client': 2.1.10 + '@smithy/types': 2.3.5 + '@smithy/url-parser': 2.0.11 '@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-retry': 2.0.0 + '@smithy/util-body-length-node': 2.1.0 + '@smithy/util-defaults-mode-browser': 2.0.14 + '@smithy/util-defaults-mode-node': 2.0.18 + '@smithy/util-retry': 2.0.4 '@smithy/util-utf8': 2.0.0 fast-xml-parser: 4.2.5 - tslib: 2.6.1 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/credential-provider-cognito-identity@3.385.0: - resolution: {integrity: sha512-NeWJgI2XdfO0ZM25KsfNx9CDmLByY3ymVc0ae4Os+bd8pJsFeo1rX3NSkyw8XGryEbOlVJ3Jz5W5huhjo4LvqQ==} + /@aws-sdk/credential-provider-cognito-identity@3.427.0: + resolution: {integrity: sha512-BQNzNrMJlBAfXhYNdAUqaVASpT9Aho5swj7glZKxx4Uds1w5Pih2e14JWgnl8XgUWAZ36pchTrV1aA4JT7N8vw==} engines: {node: '>=14.0.0'} requiresBuild: true 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 - tslib: 2.6.1 + '@aws-sdk/client-cognito-identity': 3.427.0 + '@aws-sdk/types': 3.425.0 + '@smithy/property-provider': 2.0.12 + '@smithy/types': 2.3.5 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/credential-provider-env@3.378.0: - resolution: {integrity: sha512-B2OVdO9kBClDwGgWTBLAQwFV8qYTYGyVujg++1FZFSFMt8ORFdZ5fNpErvJtiSjYiOOQMzyBeSNhKyYNXCiJjQ==} + /@aws-sdk/credential-provider-env@3.425.0: + resolution: {integrity: sha512-J20etnLvMKXRVi5FK4F8yOCNm2RTaQn5psQTGdDEPWJNGxohcSpzzls8U2KcMyUJ+vItlrThr4qwgpHG3i/N0w==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/property-provider': 2.0.12 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/credential-provider-ini@3.385.0: - resolution: {integrity: sha512-WBIR5GdfUzCGzynQYX/TuCXw3KJCkHBk6bVAsO1YmfR68XKVAxWmJPKovlK/rR6LIuV+iwUMNludO+SkmG0efg==} + /@aws-sdk/credential-provider-http@3.425.0: + resolution: {integrity: sha512-aP9nkoVWf+OlNMecrUqe4+RuQrX13nucVbty0HTvuwfwJJj0T6ByWZzle+fo1D+5OxvJmtzTflBWt6jUERdHWA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/credential-provider-env': 3.378.0 - '@aws-sdk/credential-provider-process': 3.378.0 - '@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 - tslib: 2.6.1 + '@aws-sdk/types': 3.425.0 + '@smithy/fetch-http-handler': 2.2.2 + '@smithy/node-http-handler': 2.1.7 + '@smithy/property-provider': 2.0.12 + '@smithy/protocol-http': 3.0.7 + '@smithy/types': 2.3.5 + tslib: 2.6.2 + optional: true + + /@aws-sdk/credential-provider-ini@3.427.0: + resolution: {integrity: sha512-NmH1cO/w98CKMltYec3IrJIIco19wRjATFNiw83c+FGXZ+InJwReqBnruxIOmKTx2KDzd6fwU1HOewS7UjaaaQ==} + engines: {node: '>=14.0.0'} + requiresBuild: true + dependencies: + '@aws-sdk/credential-provider-env': 3.425.0 + '@aws-sdk/credential-provider-process': 3.425.0 + '@aws-sdk/credential-provider-sso': 3.427.0 + '@aws-sdk/credential-provider-web-identity': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@smithy/credential-provider-imds': 2.0.16 + '@smithy/property-provider': 2.0.12 + '@smithy/shared-ini-file-loader': 2.2.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/credential-provider-node@3.385.0: - resolution: {integrity: sha512-Lk8uu6jm/8OkbLX4Qnss8o5bnt0yQa0Tb7Azbh5/5otju5kStVAD2E+zMGrMP++NriGyZV87crduh0J8l4JUTA==} + /@aws-sdk/credential-provider-node@3.427.0: + resolution: {integrity: sha512-wYYbQ57nKL8OfgRbl8k6uXcdnYml+p3LSSfDUAuUEp1HKlQ8lOXFJ3BdLr5qrk7LhpyppSRnWBmh2c3kWa7ANQ==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/credential-provider-env': 3.378.0 - '@aws-sdk/credential-provider-ini': 3.385.0 - '@aws-sdk/credential-provider-process': 3.378.0 - '@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 - tslib: 2.6.1 + '@aws-sdk/credential-provider-env': 3.425.0 + '@aws-sdk/credential-provider-ini': 3.427.0 + '@aws-sdk/credential-provider-process': 3.425.0 + '@aws-sdk/credential-provider-sso': 3.427.0 + '@aws-sdk/credential-provider-web-identity': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@smithy/credential-provider-imds': 2.0.16 + '@smithy/property-provider': 2.0.12 + '@smithy/shared-ini-file-loader': 2.2.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/credential-provider-process@3.378.0: - resolution: {integrity: sha512-KFTIy7u+wXj3eDua4rgS0tODzMnXtXhAm1RxzCW9FL5JLBBrd82ymCj1Dp72217Sw5Do6NjCnDTTNkCHZMA77w==} + /@aws-sdk/credential-provider-process@3.425.0: + resolution: {integrity: sha512-YY6tkLdvtb1Fgofp3b1UWO+5vwS14LJ/smGmuGpSba0V7gFJRdcrJ9bcb9vVgAGuMdjzRJ+bUKlLLtqXkaykEw==} engines: {node: '>=14.0.0'} requiresBuild: true 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 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/property-provider': 2.0.12 + '@smithy/shared-ini-file-loader': 2.2.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/credential-provider-sso@3.385.0: - resolution: {integrity: sha512-ETFnS+4ZKTAgT8boVpIpRuXA9wWGpNqOcI1RXtjsaIgQ9s8uNn2JPa8l71gZh861mzBC8Hadp1EpNu+43w4lkg==} + /@aws-sdk/credential-provider-sso@3.427.0: + resolution: {integrity: sha512-c+tXyS/i49erHs4bAp6vKNYeYlyQ0VNMBgoco0LCn1rL0REtHbfhWMnqDLF6c2n3yIWDOTrQu0D73Idnpy16eA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@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 - tslib: 2.6.1 + '@aws-sdk/client-sso': 3.427.0 + '@aws-sdk/token-providers': 3.427.0 + '@aws-sdk/types': 3.425.0 + '@smithy/property-provider': 2.0.12 + '@smithy/shared-ini-file-loader': 2.2.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/credential-provider-web-identity@3.378.0: - resolution: {integrity: sha512-GWjydOszhc4xDF8xuPtBvboglXQr0gwCW1oHAvmLcOT38+Hd6qnKywnMSeoXYRPgoKfF9TkWQgW1jxplzCG0UA==} + /@aws-sdk/credential-provider-web-identity@3.425.0: + resolution: {integrity: sha512-/0R65TgRzL01JU3SzloivWNwdkbIhr06uY/F5pBHf/DynQqaspKNfdHn6AiozgSVDfwRHFjKBTUy6wvf3QFkuA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/property-provider': 2.0.12 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/credential-providers@3.385.0: - resolution: {integrity: sha512-II4WAFMk061Ud6n1Pux+5T3FQe6gLIwmpF+QgMH97TxJZWFiKyhmJ1Z0VArjo1wwcEPMyIN21Ij91ayop8agwQ==} + /@aws-sdk/credential-providers@3.427.0: + resolution: {integrity: sha512-rKKohSHju462vo+uQnPjcEZPBAfAMgGH6K1XyyCNpuOC0yYLkG87PYpvAQeb8riTrkHPX0dYUHuTHZ6zQgMGjA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/client-cognito-identity': 3.385.0 - '@aws-sdk/client-sso': 3.382.0 - '@aws-sdk/client-sts': 3.385.0 - '@aws-sdk/credential-provider-cognito-identity': 3.385.0 - '@aws-sdk/credential-provider-env': 3.378.0 - '@aws-sdk/credential-provider-ini': 3.385.0 - '@aws-sdk/credential-provider-node': 3.385.0 - '@aws-sdk/credential-provider-process': 3.378.0 - '@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 - tslib: 2.6.1 + '@aws-sdk/client-cognito-identity': 3.427.0 + '@aws-sdk/client-sso': 3.427.0 + '@aws-sdk/client-sts': 3.427.0 + '@aws-sdk/credential-provider-cognito-identity': 3.427.0 + '@aws-sdk/credential-provider-env': 3.425.0 + '@aws-sdk/credential-provider-http': 3.425.0 + '@aws-sdk/credential-provider-ini': 3.427.0 + '@aws-sdk/credential-provider-node': 3.427.0 + '@aws-sdk/credential-provider-process': 3.425.0 + '@aws-sdk/credential-provider-sso': 3.427.0 + '@aws-sdk/credential-provider-web-identity': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@smithy/credential-provider-imds': 2.0.16 + '@smithy/property-provider': 2.0.12 + '@smithy/types': 2.3.5 + tslib: 2.6.2 transitivePeerDependencies: - aws-crt - dev: false optional: true - /@aws-sdk/middleware-host-header@3.379.1: - resolution: {integrity: sha512-LI4KpAFWNWVr2aH2vRVblr0Y8tvDz23lj8LOmbDmCrzd5M21nxuocI/8nEAQj55LiTIf9Zs+dHCdsyegnFXdrA==} + /@aws-sdk/middleware-host-header@3.425.0: + resolution: {integrity: sha512-E5Gt41LObQ+cr8QnLthwsH3MtVSNXy1AKJMowDr85h0vzqA/FHUkgHyOGntgozzjXT5M0MaSRYxS0xwTR5D4Ew==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/protocol-http': 3.0.7 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/middleware-logger@3.378.0: - resolution: {integrity: sha512-l1DyaDLm3KeBMNMuANI3scWh8Xvu248x+vw6Z7ExWOhGXFmQ1MW7YvASg/SdxWkhlF9HmkkTif1LdMB22x6QDA==} + /@aws-sdk/middleware-logger@3.425.0: + resolution: {integrity: sha512-INE9XWRXx2f4a/r2vOU0tAmgctVp7nEaEasemNtVBYhqbKLZvr9ndLBSgKGgJ8LIcXAoISipaMuFiqIGkFsm7A==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/middleware-recursion-detection@3.378.0: - resolution: {integrity: sha512-mUMfHAz0oGNIWiTZHTVJb+I515Hqs2zx1j36Le4MMiiaMkPW1SRUF1FIwGuc1wh6E8jB5q+XfEMriDjRi4TZRA==} + /@aws-sdk/middleware-recursion-detection@3.425.0: + resolution: {integrity: sha512-77gnzJ5b91bgD75L/ugpOyerx6lR3oyS4080X1YI58EzdyBMkDrHM4FbMcY2RynETi3lwXCFzLRyZjWXY1mRlw==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/protocol-http': 3.0.7 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/middleware-sdk-sts@3.379.1: - resolution: {integrity: sha512-SK3gSyT0XbLiY12+AjLFYL9YngxOXHnZF3Z33Cdd4a+AUYrVBV7JBEEGD1Nlwrcmko+3XgaKlmgUaR5s91MYvg==} + /@aws-sdk/middleware-sdk-sts@3.425.0: + resolution: {integrity: sha512-JFojrg76oKAoBknnr9EL5N2aJ1mRCtBqXoZYST58GSx8uYdFQ89qS65VNQ8JviBXzsrCNAn4vDhZ5Ch5E6TxGQ==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/middleware-signing': 3.379.1 - '@aws-sdk/types': 3.378.0 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/middleware-signing': 3.425.0 + '@aws-sdk/types': 3.425.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/middleware-signing@3.379.1: - resolution: {integrity: sha512-kBk2ZUvR84EM4fICjr8K+Ykpf8SI1UzzPp2/UVYZ0X+4H/ZCjfSqohGRwHykMqeplne9qHSL7/rGJs1H3l3gPg==} + /@aws-sdk/middleware-signing@3.425.0: + resolution: {integrity: sha512-ZpOfgJHk7ovQ0sSwg3tU4NxFOnz53lJlkJRf7S+wxQALHM0P2MJ6LYBrZaFMVsKiJxNIdZBXD6jclgHg72ZW6Q==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/property-provider': 2.0.1 - '@smithy/protocol-http': 2.0.1 - '@smithy/signature-v4': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/util-middleware': 2.0.0 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/property-provider': 2.0.12 + '@smithy/protocol-http': 3.0.7 + '@smithy/signature-v4': 2.0.11 + '@smithy/types': 2.3.5 + '@smithy/util-middleware': 2.0.4 + tslib: 2.6.2 optional: true - /@aws-sdk/middleware-user-agent@3.382.0: - resolution: {integrity: sha512-LFRW1jmXOrOAd3911ktn6oaYmuurNnulbdRMOUdwz99GGdLVFipQhOi9idKswb8IOhPa4jEVQt25Kcv7ctvu0A==} + /@aws-sdk/middleware-user-agent@3.427.0: + resolution: {integrity: sha512-y9HxYsNvnA3KqDl8w1jHeCwz4P9CuBEtu/G+KYffLeAMBsMZmh4SIkFFCO9wE/dyYg6+yo07rYcnnIfy7WA0bw==} engines: {node: '>=14.0.0'} requiresBuild: true 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 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@aws-sdk/util-endpoints': 3.427.0 + '@smithy/protocol-http': 3.0.7 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@aws-sdk/token-providers@3.385.0: - resolution: {integrity: sha512-2A2Y7/bU5EaxQwLwLy7ojs+Wy5VOBkIlGPH7ZcpPaoQ1Hscwn3Wvx/DZmOvbyYfZ1CbIFutoHJlVxh6KZldUDw==} + /@aws-sdk/region-config-resolver@3.425.0: + resolution: {integrity: sha512-u7uv/iUOapIJdRgRkO3wnpYsUgV6ponsZJQgVg/8L+n+Vo5PQL5gAcIuAOwcYSKQPFaeK+KbmByI4SyOK203Vw==} engines: {node: '>=14.0.0'} requiresBuild: true 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 - tslib: 2.6.1 - dev: false + '@smithy/node-config-provider': 2.1.1 + '@smithy/types': 2.3.5 + '@smithy/util-config-provider': 2.0.0 + '@smithy/util-middleware': 2.0.4 + tslib: 2.6.2 optional: true - /@aws-sdk/types@3.378.0: - resolution: {integrity: sha512-qP0CvR/ItgktmN8YXpGQglzzR/6s0nrsQ4zIfx3HMwpsBTwuouYahcCtF1Vr82P4NFcoDA412EJahJ2pIqEd+w==} + /@aws-sdk/token-providers@3.427.0: + resolution: {integrity: sha512-4E5E+4p8lJ69PBY400dJXF06LUHYx5lkKzBEsYqWWhoZcoftrvi24ltIhUDoGVLkrLcTHZIWSdFAWSos4hXqeg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-crypto/sha256-browser': 3.0.0 + '@aws-crypto/sha256-js': 3.0.0 + '@aws-sdk/middleware-host-header': 3.425.0 + '@aws-sdk/middleware-logger': 3.425.0 + '@aws-sdk/middleware-recursion-detection': 3.425.0 + '@aws-sdk/middleware-user-agent': 3.427.0 + '@aws-sdk/types': 3.425.0 + '@aws-sdk/util-endpoints': 3.427.0 + '@aws-sdk/util-user-agent-browser': 3.425.0 + '@aws-sdk/util-user-agent-node': 3.425.0 + '@smithy/config-resolver': 2.0.14 + '@smithy/fetch-http-handler': 2.2.2 + '@smithy/hash-node': 2.0.11 + '@smithy/invalid-dependency': 2.0.11 + '@smithy/middleware-content-length': 2.0.13 + '@smithy/middleware-endpoint': 2.0.11 + '@smithy/middleware-retry': 2.0.16 + '@smithy/middleware-serde': 2.0.11 + '@smithy/middleware-stack': 2.0.5 + '@smithy/node-config-provider': 2.1.1 + '@smithy/node-http-handler': 2.1.7 + '@smithy/property-provider': 2.0.12 + '@smithy/protocol-http': 3.0.7 + '@smithy/shared-ini-file-loader': 2.2.0 + '@smithy/smithy-client': 2.1.10 + '@smithy/types': 2.3.5 + '@smithy/url-parser': 2.0.11 + '@smithy/util-base64': 2.0.0 + '@smithy/util-body-length-browser': 2.0.0 + '@smithy/util-body-length-node': 2.1.0 + '@smithy/util-defaults-mode-browser': 2.0.14 + '@smithy/util-defaults-mode-node': 2.0.18 + '@smithy/util-retry': 2.0.4 + '@smithy/util-utf8': 2.0.0 + tslib: 2.6.2 + transitivePeerDependencies: + - aws-crt optional: true - /@aws-sdk/util-endpoints@3.382.0: - resolution: {integrity: sha512-flajPyjmjNG67fXk7l4GoTB/7J11VBqtFZXuuAZKhKU07Ia3IQupsFqNf5lV8D44ZgjnKH0fTGnv3dUALjW7Wg==} + /@aws-sdk/types@3.425.0: + resolution: {integrity: sha512-6lqbmorwerN4v+J5dqbHPAsjynI0mkEF+blf+69QTaKKGaxBBVaXgqoqul9RXYcK5MMrrYRbQIMd0zYOoy90kA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 + optional: true + + /@aws-sdk/util-endpoints@3.427.0: + resolution: {integrity: sha512-rSyiAIFF/EVvity/+LWUqoTMJ0a25RAc9iqx0WZ4tf1UjuEXRRXxZEb+jEZg1bk+pY84gdLdx9z5E+MSJCZxNQ==} + engines: {node: '>=14.0.0'} + requiresBuild: true + dependencies: + '@aws-sdk/types': 3.425.0 + '@smithy/node-config-provider': 2.1.1 + tslib: 2.6.2 optional: true /@aws-sdk/util-locate-window@3.310.0: @@ -663,23 +703,21 @@ packages: engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@aws-sdk/util-user-agent-browser@3.378.0: - resolution: {integrity: sha512-FSCpagzftK1W+m7Ar6lpX7/Gr9y5P56nhFYz8U4EYQ4PkufS6czWX9YW+/FA5OYV0vlQ/SvPqMnzoHIPUNhZrQ==} + /@aws-sdk/util-user-agent-browser@3.425.0: + resolution: {integrity: sha512-22Y9iMtjGcFjGILR6/xdp1qRezlHVLyXtnpEsbuPTiernRCPk6zfAnK/ATH77r02MUjU057tdxVkd5umUBTn9Q==} requiresBuild: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/types': 2.0.2 + '@aws-sdk/types': 3.425.0 + '@smithy/types': 2.3.5 bowser: 2.11.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@aws-sdk/util-user-agent-node@3.378.0: - resolution: {integrity: sha512-IdwVJV0E96MkJeFte4dlWqvB+oiqCiZ5lOlheY3W9NynTuuX0GGYNC8Y9yIsV8Oava1+ujpJq0ww6qXdYxmO4A==} + /@aws-sdk/util-user-agent-node@3.425.0: + resolution: {integrity: sha512-SIR4F5uQeeVAi8lv4OgRirtdtNi5zeyogTuQgGi9su8F/WP1N6JqxofcwpUY5f8/oJ2UlXr/tx1f09UHfJJzvA==} engines: {node: '>=14.0.0'} requiresBuild: true peerDependencies: @@ -688,19 +726,17 @@ packages: aws-crt: optional: true dependencies: - '@aws-sdk/types': 3.378.0 - '@smithy/node-config-provider': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@aws-sdk/types': 3.425.0 + '@smithy/node-config-provider': 2.1.1 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true /@aws-sdk/util-utf8-browser@3.259.0: resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true /@babel/code-frame@7.22.5: @@ -2208,7 +2244,7 @@ packages: resolution: {integrity: sha512-rLMyrXuO9jcAUCaQXCMjCMUsWrba5fzHlNK24xz5j2W6A/SRmK8mZJ/hn7V0fViLbxC0lPMtrK1eYzk6Fg03jA==} dependencies: '@firebase/util': 1.9.3 - tslib: 2.6.1 + tslib: 2.6.2 dev: false /@firebase/database-compat@0.3.4: @@ -2219,7 +2255,7 @@ packages: '@firebase/database-types': 0.10.4 '@firebase/logger': 0.4.0 '@firebase/util': 1.9.3 - tslib: 2.6.1 + tslib: 2.6.2 dev: false /@firebase/database-types@0.10.4: @@ -2237,19 +2273,19 @@ packages: '@firebase/logger': 0.4.0 '@firebase/util': 1.9.3 faye-websocket: 0.11.4 - tslib: 2.6.1 + tslib: 2.6.2 dev: false /@firebase/logger@0.4.0: resolution: {integrity: sha512-eRKSeykumZ5+cJPdxxJRgAC3G5NknY2GwEbKfymdnXtnT0Ucm4pspfR6GT4MUQEDuJwRVbVcSx85kgJulMoFFA==} dependencies: - tslib: 2.6.1 + tslib: 2.6.2 dev: false /@firebase/util@1.9.3: resolution: {integrity: sha512-DY02CRhOZwpzO36fHpuVysz6JZrscPiBXD0fXp6qSrL9oNOx5KWICKdR95C0lSITzxp0TZosVyHqzatE8JbcjA==} dependencies: - tslib: 2.6.1 + tslib: 2.6.2 dev: false /@floating-ui/core@1.4.1: @@ -2269,15 +2305,15 @@ packages: resolution: {integrity: sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==} dev: false - /@google-cloud/firestore@6.7.0: - resolution: {integrity: sha512-bkH2jb5KkQSUa+NAvpip9HQ+rpYhi77IaqHovWuN07adVmvNXX08gPpvPWEzoXYa/wDjEVI7LiAtCWkJJEYTNg==} + /@google-cloud/firestore@6.8.0: + resolution: {integrity: sha512-JRpk06SmZXLGz0pNx1x7yU3YhkUXheKgH5hbDZ4kMsdhtfV5qPLJLRI4wv69K0cZorIk+zTMOwptue7hizo0eA==} engines: {node: '>=12.0.0'} requiresBuild: true dependencies: fast-deep-equal: 3.1.3 functional-red-black-tree: 1.0.1 google-gax: 3.6.1 - protobufjs: 7.2.4 + protobufjs: 7.2.5 transitivePeerDependencies: - encoding - supports-color @@ -2356,7 +2392,7 @@ packages: '@types/long': 4.0.2 lodash.camelcase: 4.3.0 long: 4.0.0 - protobufjs: 7.2.4 + protobufjs: 7.2.5 yargs: 17.7.2 dev: false optional: true @@ -2460,6 +2496,13 @@ packages: dev: false optional: true + /@mongodb-js/saslprep@1.1.0: + resolution: {integrity: sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==} + requiresBuild: true + dependencies: + sparse-bitfield: 3.0.3 + optional: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2706,83 +2749,77 @@ packages: rollup: 2.79.1 dev: false - /@smithy/abort-controller@2.0.1: - resolution: {integrity: sha512-0s7XjIbsTwZyUW9OwXQ8J6x1UiA1TNCh60Vaw56nHahL7kUZsLhmTlWiaxfLkFtO2Utkj8YewcpHTYpxaTzO+w==} + /@smithy/abort-controller@2.0.11: + resolution: {integrity: sha512-MSzE1qR2JNyb7ot3blIOT3O3H0Jn06iNDEgHRaqZUwBgx5EG+VIx24Y21tlKofzYryIOcWpIohLrIIyocD6LMA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/config-resolver@2.0.1: - resolution: {integrity: sha512-l83Pm7hV+8CBQOCmBRopWDtF+CURUJol7NsuPYvimiDhkC2F8Ba9T1imSFE+pD1UIJ9jlsDPAnZfPJT5cjnuEw==} + /@smithy/config-resolver@2.0.14: + resolution: {integrity: sha512-K1K+FuWQoy8j/G7lAmK85o03O89s2Vvh6kMFmzEmiHUoQCRH1rzbDtMnGNiaMHeSeYJ6y79IyTusdRG+LuWwtg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 + '@smithy/node-config-provider': 2.1.1 + '@smithy/types': 2.3.5 '@smithy/util-config-provider': 2.0.0 - '@smithy/util-middleware': 2.0.0 - tslib: 2.6.1 - dev: false + '@smithy/util-middleware': 2.0.4 + tslib: 2.6.2 optional: true - /@smithy/credential-provider-imds@2.0.1: - resolution: {integrity: sha512-8VxriuRINNEfVZjEFKBY75y9ZWAx73DZ5K/u+3LmB6r8WR2h3NaFxFKMlwlq0uzNdGhD1ouKBn9XWEGYHKiPLw==} + /@smithy/credential-provider-imds@2.0.16: + resolution: {integrity: sha512-tKa2xF+69TvGxJT+lnJpGrKxUuAZDLYXFhqnPEgnHz+psTpkpcB4QRjHj63+uj83KaeFJdTfW201eLZeRn6FfA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/node-config-provider': 2.0.1 - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 - '@smithy/url-parser': 2.0.1 - tslib: 2.6.1 - dev: false + '@smithy/node-config-provider': 2.1.1 + '@smithy/property-provider': 2.0.12 + '@smithy/types': 2.3.5 + '@smithy/url-parser': 2.0.11 + tslib: 2.6.2 optional: true - /@smithy/eventstream-codec@2.0.1: - resolution: {integrity: sha512-/IiNB7gQM2y2ZC/GAWOWDa8+iXfhr1g9Xe5979cQEOdCWDISvrAiv18cn3OtIQUhbYOR3gm7QtCpkq1to2takQ==} + /@smithy/eventstream-codec@2.0.11: + resolution: {integrity: sha512-BQCTjxhCYRZIfXapa2LmZSaH8QUBGwMZw7XRN83hrdixbLjIcj+o549zjkedFS07Ve2TlvWUI6BTzP+nv7snBA==} requiresBuild: true dependencies: '@aws-crypto/crc32': 3.0.0 - '@smithy/types': 2.0.2 + '@smithy/types': 2.3.5 '@smithy/util-hex-encoding': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/fetch-http-handler@2.0.1: - resolution: {integrity: sha512-/SoU/ClazgcdOxgE4zA7RX8euiELwpsrKCSvulVQvu9zpmqJRyEJn8ZTWYFV17/eHOBdHTs9kqodhNhsNT+cUw==} + /@smithy/fetch-http-handler@2.2.2: + resolution: {integrity: sha512-K7aRtRuaBjzlk+jWWeyfDTLAmRRvmA4fU8eHUXtjsuEDgi3f356ZE32VD2ssxIH13RCLVZbXMt5h7wHzYiSuVA==} requiresBuild: true dependencies: - '@smithy/protocol-http': 2.0.1 - '@smithy/querystring-builder': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/protocol-http': 3.0.7 + '@smithy/querystring-builder': 2.0.11 + '@smithy/types': 2.3.5 '@smithy/util-base64': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/hash-node@2.0.1: - resolution: {integrity: sha512-oTKYimQdF4psX54ZonpcIE+MXjMUWFxLCNosjPkJPFQ9whRX0K/PFX/+JZGRQh3zO9RlEOEUIbhy9NO+Wha6hw==} + /@smithy/hash-node@2.0.11: + resolution: {integrity: sha512-PbleVugN2tbhl1ZoNWVrZ1oTFFas/Hq+s6zGO8B9bv4w/StTriTKA9W+xZJACOj9X7zwfoTLbscM+avCB1KqOQ==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 + '@smithy/types': 2.3.5 '@smithy/util-buffer-from': 2.0.0 '@smithy/util-utf8': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/invalid-dependency@2.0.1: - resolution: {integrity: sha512-2q/Eb0AE662zwyMV+z+TL7deBwcHCgaZZGc0RItamBE8kak3MzCi/EZCNoFWoBfxgQ4jfR12wm8KKsSXhJzJtQ==} + /@smithy/invalid-dependency@2.0.11: + resolution: {integrity: sha512-zazq99ujxYv/NOf9zh7xXbNgzoVLsqE0wle8P/1zU/XdhPi/0zohTPKWUzIxjGdqb5hkkwfBkNkl5H+LE0mvgw==} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true /@smithy/is-array-buffer@2.0.0: @@ -2790,196 +2827,182 @@ packages: engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/middleware-content-length@2.0.1: - resolution: {integrity: sha512-IZhRSk5GkVBcrKaqPXddBS2uKhaqwBgaSgbBb1OJyGsKe7SxRFbclWS0LqOR9fKUkDl+3lL8E2ffpo6EQg0igw==} + /@smithy/middleware-content-length@2.0.13: + resolution: {integrity: sha512-Md2kxWpaec3bXp1oERFPQPBhOXCkGSAF7uc1E+4rkwjgw3/tqAXRtbjbggu67HJdwaif76As8AV6XxbD1HzqTQ==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/protocol-http': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/protocol-http': 3.0.7 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/middleware-endpoint@2.0.1: - resolution: {integrity: sha512-uz/KI1MBd9WHrrkVFZO4L4Wyv24raf0oR4EsOYEeG5jPJO5U+C7MZGLcMxX8gWERDn1sycBDqmGv8fjUMLxT6w==} + /@smithy/middleware-endpoint@2.0.11: + resolution: {integrity: sha512-mCugsvB15up6fqpzUEpMT4CuJmFkEI+KcozA7QMzYguXCaIilyMKsyxgamwmr+o7lo3QdjN0//XLQ9bWFL129g==} engines: {node: '>=14.0.0'} requiresBuild: true 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 + '@smithy/middleware-serde': 2.0.11 + '@smithy/types': 2.3.5 + '@smithy/url-parser': 2.0.11 + '@smithy/util-middleware': 2.0.4 + tslib: 2.6.2 optional: true - /@smithy/middleware-retry@2.0.1: - resolution: {integrity: sha512-NKHF4i0gjSyjO6C0ZyjEpNqzGgIu7s8HOK6oT/1Jqws2Q1GynR1xV8XTUs1gKXeaNRzbzKQRewHHmfPwZjOtHA==} + /@smithy/middleware-retry@2.0.16: + resolution: {integrity: sha512-Br5+0yoiMS0ugiOAfJxregzMMGIRCbX4PYo1kDHtLgvkA/d++aHbnHB819m5zOIAMPvPE7AThZgcsoK+WOsUTA==} engines: {node: '>=14.0.0'} requiresBuild: true 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 + '@smithy/node-config-provider': 2.1.1 + '@smithy/protocol-http': 3.0.7 + '@smithy/service-error-classification': 2.0.4 + '@smithy/types': 2.3.5 + '@smithy/util-middleware': 2.0.4 + '@smithy/util-retry': 2.0.4 + tslib: 2.6.2 uuid: 8.3.2 - dev: false optional: true - /@smithy/middleware-serde@2.0.1: - resolution: {integrity: sha512-uKxPaC6ItH9ZXdpdqNtf8sda7GcU4SPMp0tomq/5lUg9oiMa/Q7+kD35MUrpKaX3IVXVrwEtkjCU9dogZ/RAUA==} + /@smithy/middleware-serde@2.0.11: + resolution: {integrity: sha512-NuxnjMyf4zQqhwwdh0OTj5RqpnuT6HcH5Xg5GrPijPcKzc2REXVEVK4Yyk8ckj8ez1XSj/bCmJ+oNjmqB02GWA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/middleware-stack@2.0.0: - resolution: {integrity: sha512-31XC1xNF65nlbc16yuh3wwTudmqs6qy4EseQUGF8A/p2m/5wdd/cnXJqpniy/XvXVwkHPz/GwV36HqzHtIKATQ==} + /@smithy/middleware-stack@2.0.5: + resolution: {integrity: sha512-bVQU/rZzBY7CbSxIrDTGZYnBWKtIw+PL/cRc9B7etZk1IKSOe0NvKMJyWllfhfhrTeMF6eleCzOihIQympAvPw==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/node-config-provider@2.0.1: - resolution: {integrity: sha512-Zoel4CPkKRTQ2XxmozZUfqBYqjPKL53/SvTDhJHj+VBSiJy6MXRav1iDCyFPS92t40Uh+Yi+Km5Ch3hQ+c/zSA==} + /@smithy/node-config-provider@2.1.1: + resolution: {integrity: sha512-1lF6s1YWBi1LBu2O30tD3jyTgMtuvk/Z1twzXM4GPYe4dmZix4nNREPJIPOcfFikNU2o0eTYP80+izx5F2jIJA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/property-provider': 2.0.1 - '@smithy/shared-ini-file-loader': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/property-provider': 2.0.12 + '@smithy/shared-ini-file-loader': 2.2.0 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/node-http-handler@2.0.1: - resolution: {integrity: sha512-Zv3fxk3p9tsmPT2CKMsbuwbbxnq2gzLDIulxv+yI6aE+02WPYorObbbe9gh7SW3weadMODL1vTfOoJ9yFypDzg==} + /@smithy/node-http-handler@2.1.7: + resolution: {integrity: sha512-PQIKZXlp3awCDn/xNlCSTFE7aYG/5Tx33M05NfQmWYeB5yV1GZZOSz4dXpwiNJYTXb9jPqjl+ueXXkwtEluFFA==} engines: {node: '>=14.0.0'} requiresBuild: true 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 + '@smithy/abort-controller': 2.0.11 + '@smithy/protocol-http': 3.0.7 + '@smithy/querystring-builder': 2.0.11 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/property-provider@2.0.1: - resolution: {integrity: sha512-pmJRyY9SF6sutWIktIhe+bUdSQDxv/qZ4mYr3/u+u45riTPN7nmRxPo+e4sjWVoM0caKFjRSlj3tf5teRFy0Vg==} + /@smithy/property-provider@2.0.12: + resolution: {integrity: sha512-Un/OvvuQ1Kg8WYtoMCicfsFFuHb/TKL3pCA6ZIo/WvNTJTR94RtoRnL7mY4XkkUAoFMyf6KjcQJ76y1FX7S5rw==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/protocol-http@2.0.1: - resolution: {integrity: sha512-mrkMAp0wtaDEIkgRObWYxI1Kun1tm6Iu6rK+X4utb6Ah7Uc3Kk4VIWwK/rBHdYGReiLIrxFCB1rq4a2gyZnSgg==} + /@smithy/protocol-http@3.0.7: + resolution: {integrity: sha512-HnZW8y+r66ntYueCDbLqKwWcMNWW8o3eVpSrHNluwtBJ/EUWfQHRKSiu6vZZtc6PGfPQWgVfucoCE/C3QufMAA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/querystring-builder@2.0.1: - resolution: {integrity: sha512-bp+93WFzx1FojVEIeFPtG0A1pKsFdCUcZvVdZdRlmNooOUrz9Mm9bneRd8hDwAQ37pxiZkCOxopSXXRQN10mYw==} + /@smithy/querystring-builder@2.0.11: + resolution: {integrity: sha512-b4kEbVMxpmfv2VWUITn2otckTi7GlMteZQxi+jlwedoATOGEyrCJPfRcYQJjbCi3fZ2QTfh3PcORvB27+j38Yg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 + '@smithy/types': 2.3.5 '@smithy/util-uri-escape': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/querystring-parser@2.0.1: - resolution: {integrity: sha512-h+e7k1z+IvI2sSbUBG9Aq46JsgLl4UqIUl6aigAlRBj+P6ocNXpM6Yn1vMBw5ijtXeZbYpd1YvCxwDgdw3jhmg==} + /@smithy/querystring-parser@2.0.11: + resolution: {integrity: sha512-YXe7jhi7s3dQ0Fu9dLoY/gLu6NCyy8tBWJL/v2c9i7/RLpHgKT+uT96/OqZkHizCJ4kr0ZD46tzMjql/o60KLg==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/service-error-classification@2.0.0: - resolution: {integrity: sha512-2z5Nafy1O0cTf69wKyNjGW/sNVMiqDnb4jgwfMG8ye8KnFJ5qmJpDccwIbJNhXIfbsxTg9SEec2oe1cexhMJvw==} + /@smithy/service-error-classification@2.0.4: + resolution: {integrity: sha512-77506l12I5gxTZqBkx3Wb0RqMG81bMYLaVQ+EqIWFwQDJRs5UFeXogKxSKojCmz1wLUziHZQXm03MBzPQiumQw==} engines: {node: '>=14.0.0'} requiresBuild: true - dev: false + dependencies: + '@smithy/types': 2.3.5 optional: true - /@smithy/shared-ini-file-loader@2.0.1: - resolution: {integrity: sha512-a463YiZrPGvM+F336rIF8pLfQsHAdCRAn/BiI/EWzg5xLoxbC7GSxIgliDDXrOu0z8gT3nhVsif85eU6jyct3A==} + /@smithy/shared-ini-file-loader@2.2.0: + resolution: {integrity: sha512-xFXqs4vAb5BdkzHSRrTapFoaqS4/3m/CGZzdw46fBjYZ0paYuLAoMY60ICCn1FfGirG+PiJ3eWcqJNe4/SkfyA==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/signature-v4@2.0.1: - resolution: {integrity: sha512-jztv5Mirca42ilxmMDjzLdXcoAmRhZskGafGL49sRo5u7swEZcToEFrq6vtX5YMbSyTVrE9Teog5EFexY5Ff2Q==} + /@smithy/signature-v4@2.0.11: + resolution: {integrity: sha512-EFVU1dT+2s8xi227l1A9O27edT/GNKvyAK6lZnIZ0zhIHq/jSLznvkk15aonGAM1kmhmZBVGpI7Tt0odueZK9A==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/eventstream-codec': 2.0.1 + '@smithy/eventstream-codec': 2.0.11 '@smithy/is-array-buffer': 2.0.0 - '@smithy/types': 2.0.2 + '@smithy/types': 2.3.5 '@smithy/util-hex-encoding': 2.0.0 - '@smithy/util-middleware': 2.0.0 + '@smithy/util-middleware': 2.0.4 '@smithy/util-uri-escape': 2.0.0 '@smithy/util-utf8': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/smithy-client@2.0.1: - resolution: {integrity: sha512-LHC5m6tYpEu1iNbONfvMbwtErboyTZJfEIPoD78Ei5MVr36vZQCaCla5mvo36+q/a2NAk2//fA5Rx3I1Kf7+lQ==} + /@smithy/smithy-client@2.1.10: + resolution: {integrity: sha512-2OEmZDiW1Z196QHuQZ5M6cBE8FCSG0H2HADP1G+DY8P3agsvb0YJyfhyKuJbxIQy15tr3eDAK6FOrlbxgKOOew==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/middleware-stack': 2.0.0 - '@smithy/types': 2.0.2 - '@smithy/util-stream': 2.0.1 - tslib: 2.6.1 - dev: false + '@smithy/middleware-stack': 2.0.5 + '@smithy/types': 2.3.5 + '@smithy/util-stream': 2.0.15 + tslib: 2.6.2 optional: true - /@smithy/types@2.0.2: - resolution: {integrity: sha512-wcymEjIXQ9+NEfE5Yt5TInAqe1o4n+Nh+rh00AwoazppmUt8tdo6URhc5gkDcOYrcvlDVAZE7uG69nDpEGUKxw==} + /@smithy/types@2.3.5: + resolution: {integrity: sha512-ehyDt8M9hehyxrLQGoA1BGPou8Js1Ocoh5M0ngDhJMqbFmNK5N6Xhr9/ZExWkyIW8XcGkiMPq3ZUEE0ScrhbuQ==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/url-parser@2.0.1: - resolution: {integrity: sha512-NpHVOAwddo+OyyIoujDL9zGL96piHWrTNXqltWmBvlUoWgt1HPyBuKs6oHjioyFnNZXUqveTOkEEq0U5w6Uv8A==} + /@smithy/url-parser@2.0.11: + resolution: {integrity: sha512-h89yXMCCF+S5k9XIoKltMIWTYj+FcEkU/IIFZ6RtE222fskOTL4Iak6ZRG+ehSvZDt8yKEcxqheTDq7JvvtK3g==} requiresBuild: true dependencies: - '@smithy/querystring-parser': 2.0.1 - '@smithy/types': 2.0.2 - tslib: 2.6.1 - dev: false + '@smithy/querystring-parser': 2.0.11 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true /@smithy/util-base64@2.0.0: @@ -2988,25 +3011,22 @@ packages: requiresBuild: true dependencies: '@smithy/util-buffer-from': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true /@smithy/util-body-length-browser@2.0.0: resolution: {integrity: sha512-JdDuS4ircJt+FDnaQj88TzZY3+njZ6O+D3uakS32f2VNnDo3vyEuNdBOh/oFd8Df1zSZOuH1HEChk2AOYDezZg==} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/util-body-length-node@2.0.0: - resolution: {integrity: sha512-ZV7Z/WHTMxHJe/xL/56qZwSUcl63/5aaPAGjkfynJm4poILjdD4GmFI+V+YWabh2WJIjwTKZ5PNsuvPQKt93Mg==} + /@smithy/util-body-length-node@2.1.0: + resolution: {integrity: sha512-/li0/kj/y3fQ3vyzn36NTLGmUwAICb7Jbe/CsWCktW363gh1MOcpEcSO3mJ344Gv2dqz8YJCLQpb6hju/0qOWw==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true /@smithy/util-buffer-from@2.0.0: @@ -3015,8 +3035,7 @@ packages: requiresBuild: true dependencies: '@smithy/is-array-buffer': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true /@smithy/util-config-provider@2.0.0: @@ -3024,34 +3043,33 @@ packages: engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/util-defaults-mode-browser@2.0.1: - resolution: {integrity: sha512-w72Qwsb+IaEYEFtYICn0Do42eFju78hTaBzzJfT107lFOPdbjWjKnFutV+6GL/nZd5HWXY7ccAKka++C3NrjHw==} + /@smithy/util-defaults-mode-browser@2.0.14: + resolution: {integrity: sha512-NupG7SWUucm3vJrvlpt9jG1XeoPJphjcivgcUUXhDJbUPy4F04LhlTiAhWSzwlCNcF8OJsMvZ/DWbpYD3pselw==} engines: {node: '>= 10.0.0'} requiresBuild: true dependencies: - '@smithy/property-provider': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/property-provider': 2.0.12 + '@smithy/smithy-client': 2.1.10 + '@smithy/types': 2.3.5 bowser: 2.11.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/util-defaults-mode-node@2.0.1: - resolution: {integrity: sha512-dNF45caelEBambo0SgkzQ0v76m4YM+aFKZNTtSafy7P5dVF8TbjZuR2UX1A5gJABD9XK6lzN+v/9Yfzj/EDgGg==} + /@smithy/util-defaults-mode-node@2.0.18: + resolution: {integrity: sha512-+3jMom/b/Cdp21tDnY4vKu249Al+G/P0HbRbct7/aSZDlROzv1tksaYukon6UUv7uoHn+/McqnsvqZHLlqvQ0g==} engines: {node: '>= 10.0.0'} requiresBuild: true 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 + '@smithy/config-resolver': 2.0.14 + '@smithy/credential-provider-imds': 2.0.16 + '@smithy/node-config-provider': 2.1.1 + '@smithy/property-provider': 2.0.12 + '@smithy/smithy-client': 2.1.10 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true /@smithy/util-hex-encoding@2.0.0: @@ -3059,43 +3077,41 @@ packages: engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true - /@smithy/util-middleware@2.0.0: - resolution: {integrity: sha512-eCWX4ECuDHn1wuyyDdGdUWnT4OGyIzV0LN1xRttBFMPI9Ff/4heSHVxneyiMtOB//zpXWCha1/SWHJOZstG7kA==} + /@smithy/util-middleware@2.0.4: + resolution: {integrity: sha512-Pbu6P4MBwRcjrLgdTR1O4Y3c0sTZn2JdOiJNcgL7EcIStcQodj+6ZTXtbyU/WTEU3MV2NMA10LxFc3AWHZ3+4A==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/util-retry@2.0.0: - resolution: {integrity: sha512-/dvJ8afrElasuiiIttRJeoS2sy8YXpksQwiM/TcepqdRVp7u4ejd9C4IQURHNjlfPUT7Y6lCDSa2zQJbdHhVTg==} + /@smithy/util-retry@2.0.4: + resolution: {integrity: sha512-b+n1jBBKc77C1E/zfBe1Zo7S9OXGBiGn55N0apfhZHxPUP/fMH5AhFUUcWaJh7NAnah284M5lGkBKuhnr3yK5w==} engines: {node: '>= 14.0.0'} requiresBuild: true dependencies: - '@smithy/service-error-classification': 2.0.0 - tslib: 2.6.1 - dev: false + '@smithy/service-error-classification': 2.0.4 + '@smithy/types': 2.3.5 + tslib: 2.6.2 optional: true - /@smithy/util-stream@2.0.1: - resolution: {integrity: sha512-2a0IOtwIKC46EEo7E7cxDN8u2jwOiYYJqcFKA6rd5rdXqKakHT2Gc+AqHWngr0IEHUfW92zX12wRQKwyoqZf2Q==} + /@smithy/util-stream@2.0.15: + resolution: {integrity: sha512-A/hkYJPH2N5MCWYvky4tTpQihpYAEzqnUfxDyG3L/yMndy/2sLvxnyQal9Opuj1e9FiKSTeMyjnU9xxZGs0mRw==} engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - '@smithy/fetch-http-handler': 2.0.1 - '@smithy/node-http-handler': 2.0.1 - '@smithy/types': 2.0.2 + '@smithy/fetch-http-handler': 2.2.2 + '@smithy/node-http-handler': 2.1.7 + '@smithy/types': 2.3.5 '@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 + tslib: 2.6.2 optional: true /@smithy/util-uri-escape@2.0.0: @@ -3103,8 +3119,7 @@ packages: engines: {node: '>=14.0.0'} requiresBuild: true dependencies: - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true /@smithy/util-utf8@2.0.0: @@ -3113,8 +3128,7 @@ packages: requiresBuild: true dependencies: '@smithy/util-buffer-from': 2.0.0 - tslib: 2.6.1 - dev: false + tslib: 2.6.2 optional: true /@socket.io/component-emitter@3.1.0: @@ -3726,7 +3740,6 @@ packages: /@types/node@20.4.7: resolution: {integrity: sha512-bUBrPjEry2QUTsnuEjzjbS7voGWCc30W0qzgMf90GPeDGFRakvrz47ju+oqDAKCXLUCe39u57/ORMl/O/04/9g==} - dev: false /@types/object.omit@3.0.0: resolution: {integrity: sha512-I27IoPpH250TUzc9FzXd0P1BV/BMJuzqD3jOz98ehf9dQqGkxlq+hO1bIqZGWqCg5bVOy0g4AUVJtnxe0klDmw==} @@ -3814,14 +3827,12 @@ packages: /@types/webidl-conversions@7.0.0: resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==} - dev: false /@types/whatwg-url@8.2.2: resolution: {integrity: sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==} dependencies: '@types/node': 20.4.7 '@types/webidl-conversions': 7.0.0 - dev: false /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -3919,25 +3930,6 @@ packages: dev: false optional: true - /ajv-errors@3.0.0(ajv@8.12.0): - resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} - peerDependencies: - ajv: ^8.0.1 - dependencies: - ajv: 8.12.0 - dev: false - - /ajv-formats@2.1.1(ajv@8.12.0): - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - dependencies: - ajv: 8.12.0 - dev: false - /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -3946,15 +3938,6 @@ packages: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - /ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - dev: false - /ansi-colors@4.1.1: resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} engines: {node: '>=6'} @@ -4131,7 +4114,6 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: false /base64id@2.0.0: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} @@ -4190,7 +4172,6 @@ packages: /bowser@2.11.0: resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} requiresBuild: true - dev: false optional: true /brace-expansion@1.1.11: @@ -4230,7 +4211,6 @@ packages: engines: {node: '>=6.9.0'} dependencies: buffer: 5.7.1 - dev: false /buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -4253,7 +4233,6 @@ packages: dependencies: base64-js: 1.5.1 ieee754: 1.1.13 - dev: false /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} @@ -4302,14 +4281,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 @@ -4331,8 +4310,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: @@ -4347,7 +4328,7 @@ packages: normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /ci@2.2.0: @@ -4484,7 +4465,7 @@ packages: /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - /connect-mongo@4.6.0(express-session@1.17.3)(mongodb@4.16.0): + /connect-mongo@4.6.0(express-session@1.17.3)(mongodb@4.17.1): resolution: {integrity: sha512-8new4Z7NLP3CGP65Aw6ls3xDBeKVvHRSh39CXuDZTQsvpeeU9oNMzfFgvqmHqZ6gWpxIl663RyoVEmCAGf1yOg==} engines: {node: '>=10'} peerDependencies: @@ -4494,7 +4475,7 @@ packages: debug: 4.3.4 express-session: 1.17.3 kruptein: 3.0.6 - mongodb: 4.16.0 + mongodb: 4.17.1 transitivePeerDependencies: - supports-color dev: false @@ -5088,9 +5069,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: @@ -5201,7 +5182,6 @@ packages: requiresBuild: true dependencies: strnum: 1.0.5 - dev: false optional: true /fast-xml-parser@4.2.7: @@ -5297,12 +5277,12 @@ 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 optionalDependencies: - '@google-cloud/firestore': 6.7.0 + '@google-cloud/firestore': 6.8.0 '@google-cloud/storage': 6.12.0 transitivePeerDependencies: - encoding @@ -5378,8 +5358,8 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -5433,8 +5413,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: @@ -5709,7 +5689,6 @@ packages: /ieee754@1.1.13: resolution: {integrity: sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==} - dev: false /ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} @@ -5741,7 +5720,6 @@ packages: /ip@2.0.0: resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==} - dev: false /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} @@ -5968,10 +5946,6 @@ packages: /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - /json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - dev: false - /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5981,9 +5955,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 @@ -5994,16 +5968,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 @@ -6246,7 +6210,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: @@ -6353,7 +6317,6 @@ packages: /memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} requiresBuild: true - dev: false optional: true /merge-descriptors@1.0.1: @@ -6490,29 +6453,27 @@ packages: dependencies: '@types/whatwg-url': 8.2.2 whatwg-url: 11.0.0 - dev: false - /mongodb@4.16.0: - resolution: {integrity: sha512-0EB113Fsucaq1wsY0dOhi1fmZOwFtLOtteQkiqOXGklvWMnSH3g2QS53f0KTP+/6qOkuoXE2JksubSZNmxeI+g==} + /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 - saslprep: 1.0.3 + '@aws-sdk/credential-providers': 3.427.0 + '@mongodb-js/saslprep': 1.1.0 transitivePeerDependencies: - aws-crt - dev: false - /mongoose@6.11.5: - resolution: {integrity: sha512-ZarPe1rCHG4aVb78xLuok4BBIm0HMz/Y/CjxYXCk3Qz1mEhS7bPMy6ZhSX2/Dng//R7ei8719j6K87UVM/1b3g==} + /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 @@ -6606,9 +6567,14 @@ 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'} + /nodemailer@6.9.9: + resolution: {integrity: sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==} + engines: {node: '>=6.0.0'} + dev: false + + /nodemon@3.0.1: + resolution: {integrity: sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==} + engines: {node: '>=10'} hasBin: true dependencies: chokidar: 3.5.3 @@ -6616,8 +6582,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 @@ -7035,7 +7001,7 @@ packages: engines: {node: '>=12.0.0'} requiresBuild: true dependencies: - protobufjs: 7.2.4 + protobufjs: 7.2.5 dev: false optional: true @@ -7081,6 +7047,26 @@ packages: dev: false optional: true + /protobufjs@7.2.5: + resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==} + engines: {node: '>=12.0.0'} + requiresBuild: true + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 20.4.7 + long: 5.2.3 + dev: false + optional: true + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -7453,11 +7439,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - /require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - dev: false - /requizzle@0.2.4: resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} requiresBuild: true @@ -7542,7 +7523,7 @@ packages: engines: {node: '>=10.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: false /rope-sequence@1.3.4: @@ -7566,15 +7547,6 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false - /saslprep@1.0.3: - resolution: {integrity: sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==} - engines: {node: '>=6'} - requiresBuild: true - dependencies: - sparse-bitfield: 3.0.3 - dev: false - optional: true - /sax@1.2.1: resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} dev: false @@ -7588,17 +7560,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'} @@ -7697,11 +7665,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: @@ -7711,7 +7679,6 @@ packages: /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - dev: false /socket.io-adapter@2.5.2: resolution: {integrity: sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==} @@ -7755,7 +7722,6 @@ packages: dependencies: ip: 2.0.0 smart-buffer: 4.2.0 - dev: false /source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -7784,7 +7750,6 @@ packages: requiresBuild: true dependencies: memory-pager: 1.5.0 - dev: false optional: true /stack-trace@0.0.10: @@ -7837,7 +7802,6 @@ packages: /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} requiresBuild: true - dev: false optional: true /stubs@3.0.0: @@ -8057,7 +8021,6 @@ packages: engines: {node: '>=12'} dependencies: punycode: 2.3.0 - dev: false /triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} @@ -8067,12 +8030,10 @@ packages: /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} requiresBuild: true - dev: false optional: true - /tslib@2.6.1: - resolution: {integrity: sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==} - dev: false + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} /type-check@0.3.2: resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} @@ -8247,7 +8208,6 @@ packages: /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true - dev: false /uuid@9.0.0: resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} @@ -8297,7 +8257,6 @@ packages: /webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} - dev: false /websocket-driver@0.7.4: resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} @@ -8319,7 +8278,6 @@ packages: dependencies: tr46: 3.0.0 webidl-conversions: 7.0.0 - dev: false /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8524,3 +8482,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} requiresBuild: true + + /zod-to-json-schema@3.22.4(zod@3.22.4): + resolution: {integrity: sha512-2Ed5dJ+n/O3cU383xSY28cuVi0BCQhF8nYqWU5paEpl7fVdqdAmiLdqLyfblbNdfOFwFfi/mqU4O1pwc60iBhQ==} + peerDependencies: + zod: ^3.22.4 + dependencies: + zod: 3.22.4 + dev: false + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + dev: false diff --git a/sampleGenerator b/sampleGenerator deleted file mode 160000 index bd4329c1..00000000 --- a/sampleGenerator +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bd4329c15405a09c94e7b78e19ff296b4c2d0fb3 diff --git a/scripts/profileImageUrlUpdater.js b/scripts/profileImageUrlUpdater.js new file mode 100644 index 00000000..78ebe778 --- /dev/null +++ b/scripts/profileImageUrlUpdater.js @@ -0,0 +1,36 @@ +// Issue #173을 해결하기 위한 DB 마이그레이션 스크립트입니다. +// https://github.com/sparcs-kaist/taxi-back/issues/173 + +const { MongoClient } = require("mongodb"); +const { mongo: mongoUrl, aws: awsEnv } = require("../loadenv"); + +const time = Date.now(); + +const client = new MongoClient(mongoUrl); +const db = client.db("taxi"); +const users = db.collection("users"); + +async function run() { + try { + for await (const doc of users.find()) { + // 이미 변환이 완료된 경우에는 Pass합니다. + if (doc.profileImageUrl.startsWith(awsEnv.s3Url)) continue; + + await users.findOneAndUpdate( + { _id: doc._id }, + { + $set: { + profileImageUrl: `${awsEnv.s3Url}/profile-img/${doc.profileImageUrl}?token=${time}`, + }, + } + ); + } + } catch (err) { + console.log(err); + } finally { + await client.close(); + } +} +run().then(() => { + console.log("Done!"); +}); diff --git a/src/lottery/index.js b/src/lottery/index.js new file mode 100644 index 00000000..d485dfe1 --- /dev/null +++ b/src/lottery/index.js @@ -0,0 +1,51 @@ +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"); +const contracts = eventConfig && require("./modules/contracts"); + +// [Routes] 기존 docs 라우터의 docs extend +eventConfig && require("./routes/docs")(); + +// [Schedule] 스케줄러 시작 +eventConfig && require("./schedules")(); + +const lotteryRouter = express.Router(); + +// [Middleware] 모든 API 요청에 대하여 origin 검증 +lotteryRouter.use(require("../middlewares/originValidator")); + +// [Router] APIs +lotteryRouter.use("/globalState", require("./routes/globalState")); +lotteryRouter.use("/invite", require("./routes/invite")); +lotteryRouter.use("/transactions", require("./routes/transactions")); +lotteryRouter.use("/items", require("./routes/items")); +lotteryRouter.use("/publicNotice", require("./routes/publicNotice")); +lotteryRouter.use("/quests", require("./routes/quests")); + +// [AdminJS] AdminJS에 표시할 Resource 생성 +const resources = + (eventConfig && [ + buildResource()(eventStatusModel), + buildResource()(questModel), + buildResource([addOneItemStockAction, addFiveItemStockAction])(itemModel), + buildResource()(transactionModel), + ]) || + []; + +module.exports = { + lotteryRouter, + contracts, + resources, +}; 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..5df2973c --- /dev/null +++ b/src/lottery/middlewares/timestampValidator.js @@ -0,0 +1,19 @@ +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.period.startAt), + endAt: new Date(eventConfig.period.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.js b/src/lottery/modules/contracts.js new file mode 100644 index 00000000..87a24a84 --- /dev/null +++ b/src/lottery/modules/contracts.js @@ -0,0 +1,297 @@ +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.period.startAt), + endAt: new Date(eventConfig.period.endAt), +}; + +/** 전체 퀘스트 목록입니다. */ +const quests = buildQuests({ + firstLogin: { + name: "첫 발걸음", + description: + "로그인만 해도 넙죽코인을 얻을 수 있다고?? 이벤트 기간에 처음으로 SPARCS Taxi 서비스에 로그인하여 넙죽코인을 받아보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_firstLogin.png", + reward: 50, + }, + payingAndSending: { + name: "함께하는 택시의 여정", + description: + "2명 이상과 함께 택시를 타고 정산/송금까지 완료해보세요. 최대 3번까지 넙죽코인을 받을 수 있어요. 정산/송금 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_payingAndSending.png", + reward: 150, + maxCount: 0, + }, + firstRoomCreation: { + name: "첫 방 개설", + description: + "원하는 택시팟을 찾을 수 없다면? 원하는 조건으로 방 개설 페이지에서 방을 직접 개설해보세요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_firstRoomCreation.png", + reward: 50, + }, + roomSharing: { + name: "너 T야? Taxi", + description: + "방을 공유해 친구들을 택시에 초대해보세요. 채팅창 상단의 햄버거(☰) 버튼을 누르면 공유하기 버튼을 찾을 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_roomSharing.png", + reward: 50, + isApiRequired: true, + }, + paying: { + name: "정산해요 택시의 숲", + description: + "2명 이상과 함께 택시를 타고 택시비를 결제한 후 정산하기를 요청해보세요. 정산하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_paying.png", + reward: 100, + maxCount: 0, + }, + sending: { + name: "송금 완료면 I am 신뢰에요", + description: + "2명 이상과 함께 택시를 타고 택시비를 결제한 분께 송금해주세요. 송금하기 버튼은 채팅 페이지 좌측 하단의 +버튼을 눌러 확인할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_sending.png", + reward: 50, + maxCount: 0, + }, + nicknameChanging: { + name: "닉네임 폼 미쳤다", + description: + "닉네임을 변경하여 자신을 표현하세요. 마이페이지수정하기 버튼을 눌러 닉네임을 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_nicknameChanging.png", + reward: 50, + }, + accountChanging: { + name: "계좌 등록을 해야 능률이 올라갑니다", + description: + "정산하기 기능을 더욱 빠르고 이용할 수 있다고? 계좌번호를 등록하면 정산하기를 할 때 계좌가 자동으로 입력돼요. 마이페이지수정하기 버튼을 눌러 계좌번호를 등록 또는 수정할 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_accountChanging.png", + reward: 50, + }, + adPushAgreement: { + name: "Taxi의 소울메이트", + description: + "Taxi 서비스를 잊지 않도록 가끔 찾아갈게요! 광고성 푸시 알림 수신 동의를 해주시면 방이 많이 모이는 시즌, 주변에 택시앱 사용자가 있을 때 알려드릴 수 있어요.", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_adPushAgreement.png", + reward: 50, + }, + eventSharing: { + name: "너 나랑 ㅌ태태택 (1명)", + description: + "내가 초대한 사람이 Taxi에 가입하여 이벤트에 참여하면 넙죽코인을 드려요. 내가 초대한 사람도 넙죽코인을 받아요. 이벤트 안내 페이지의 이벤트 공유하기 버튼을 통해 카카오톡으로 초대 문자를 보낼 수 있어요!", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_eventSharing.png", + reward: 50, + maxCount: 0, + }, + eventSharing5: { + name: "너 나랑 ㅌ태태택 (5명)", + description: + "내가 초대한 사람이 5명이 Taxi에 가입하여 이벤트에 참여하면 넙죽코인을 드려요. 내가 초대한 사람도 넙죽코인을 받아요. 이벤트 안내 페이지의 이벤트 공유하기 버튼을 통해 카카오톡으로 초대 문자를 보낼 수 있어요!", + imageUrl: + "https://sparcs-taxi-prod.s3.ap-northeast-2.amazonaws.com/assets/event-2024spring/quest_eventSharing.png", + reward: 250, + maxCount: 0, + }, +}); + +/** + * 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 ( + !eventPeriod || + 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 ( + !eventPeriod || + 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 ( + !eventPeriod || + 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); +}; + +/** + * eventSharing, eventSharing5 퀘스트의 완료를 요청합니다. + * @param {string|mongoose.Types.ObjectId} userId - 퀘스트를 완료한 사용자의 ObjectId입니다. + * @param {number|Date} timestamp - 퀘스트 완료를 요청한 시각입니다. + * @returns {Promise} + * @description 초대 링크를 통해 사용자가 이벤트에 참여할 때마다, 초대한 사용자 및 초대받은 사용자에 대해 각각 호출해 주세요. + */ +const completeEventSharingQuest = async (userId, timestamp) => { + const eventSharingResult = await completeQuest( + userId, + timestamp, + quests.eventSharing + ); + if (!eventSharingResult || eventSharingResult.questCount % 5 !== 0) + return [eventSharingResult, null]; + + const eventSharing5Result = await completeQuest( + userId, + timestamp, + quests.eventSharing5 + ); + return [eventSharingResult, eventSharing5Result]; +}; + +module.exports = { + quests, + completeFirstLoginQuest, + completePayingAndSendingQuest, + completeFirstRoomCreationQuest, + completePayingQuest, + completeSendingQuest, + completeNicknameChangingQuest, + completeAccountChangingQuest, + completeAdPushAgreementQuest, + completeEventSharingQuest, +}; 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..04c6cd4c --- /dev/null +++ b/src/lottery/modules/quests.js @@ -0,0 +1,172 @@ +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.period.startAt), + endAt: new Date(eventConfig.period.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 ( + !eventPeriod || + timestamp >= eventPeriod.endAt || + timestamp < eventPeriod.startAt + ) { + logger.info( + `User ${userId} failed to complete auto-disabled ${quest.id}Quest` + ); + return null; + } + + // 3단계: 유저의 퀘스트 완료 횟수를 확인합니다. + // maxCount가 0인 경우, 무제한으로 퀘스트를 완료할 수 있습니다. + const questCount = eventStatus.completedQuests.filter( + (completedQuestId) => completedQuestId === quest.id + ).length; + if (quest.maxCount > 0 && 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}" 퀘스트를 완료해 ${eventConfig?.credit.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, + questCount: questCount + 1, + 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/slackNotification.js b/src/lottery/modules/slackNotification.js new file mode 100644 index 00000000..46a34342 --- /dev/null +++ b/src/lottery/modules/slackNotification.js @@ -0,0 +1,62 @@ +const { sendTextToReportChannel } = require("../../modules/slackNotification"); + +const generateContent = (name, userIds, roomIds = []) => { + if (userIds.length === 0) return ""; + + const strUserIds = userIds.join(", "); + const strRoomIds = + roomIds.length > 0 ? ` (관련된 방: ${roomIds.join(", ")})` : ""; + return `\n ${name}: ${strUserIds}${strRoomIds}`; +}; + +const notifyAbuseDetectionResultToReportChannel = ( + abusingUserIds, + reportedUserIds, + multiplePartRooms, + multiplePartUserIds, + lessChatRooms, + lessChatUserIds +) => { + const title = `어제의 활동을 기준으로, ${abusingUserIds.length}명의 어뷰징 의심 사용자를 감지하였습니다.`; + + if (abusingUserIds.length === 0) { + sendTextToReportChannel(title); + return; + } + + const strAbusingUsers = generateContent( + "전체 어뷰징 의심 사용자", + abusingUserIds + ); + const strReportedUsers = generateContent( + '"기타 사유"로 신고받은 사용자', + reportedUserIds + ); + const strMultiplePartUsers = generateContent( + "하루에 탑승 기록이 많은 사용자", + multiplePartUserIds, + multiplePartRooms.reduce( + (array, rooms) => array.concat(rooms.map((room) => room._id)), + [] + ) + ); + const strLessChatUsers = generateContent( + "채팅 개수가 5개 미만인 방에 속한 사용자", + lessChatUserIds, + lessChatRooms.reduce( + (array, room) => (room ? array.concat([room.roomId]) : array), + [] + ) + ); + const contents = strAbusingUsers.concat( + strReportedUsers, + strMultiplePartUsers, + strLessChatUsers + ); + + sendTextToReportChannel(`${title}\n${contents}`); +}; + +module.exports = { + notifyAbuseDetectionResultToReportChannel, +}; diff --git a/src/lottery/modules/stores/mongo.js b/src/lottery/modules/stores/mongo.js new file mode 100644 index 00000000..600c99ec --- /dev/null +++ b/src/lottery/modules/stores/mongo.js @@ -0,0 +1,171 @@ +const mongoose = require("mongoose"); +const Schema = mongoose.Schema; + +// 이벤트마다 사용된 모델을 구분하기 위해 이름에 Prefix를 붙입니다. +const { eventConfig } = require("../../../../loadenv"); +const modelNamePrefix = eventConfig?.mode ?? ""; + +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, + default: false, + }, + group: { + type: Number, + required: true, + min: 1, + validate: integerValidator, + }, // 소속된 새터반 + inviter: { + type: Schema.Types.ObjectId, + ref: "User", + }, // 이 사용자를 초대한 사용자 + isEnabledInviteUrl: { + type: Boolean, + default: false, + }, // 초대 링크 활성화 여부 +}); + +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: `${modelNamePrefix}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( + `${modelNamePrefix}EventStatus`, + eventStatusSchema + ), + questModel: mongoose.model(`${modelNamePrefix}Quest`, questSchema), + itemModel: mongoose.model(`${modelNamePrefix}Item`, itemSchema), + transactionModel: mongoose.model( + `${modelNamePrefix}Transaction`, + transactionSchema + ), +}; diff --git a/src/lottery/routes/docs/globalState.js b/src/lottery/routes/docs/globalState.js new file mode 100644 index 00000000..4af3493e --- /dev/null +++ b/src/lottery/routes/docs/globalState.js @@ -0,0 +1,176 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig?.mode}/globalState`; + +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", + "isEligible", + "creditAmount", + "groupCreditAmount", + "completedQuests", + "group", + "quests", + ], + properties: { + isAgreeOnTermsOfEvent: { + type: "boolean", + description: "유저의 이벤트 참여 동의 여부", + example: true, + }, + isEligible: { + type: "boolean", + description: "유저의 이벤트 참여 가능 여부", + example: true, + }, + creditAmount: { + type: "number", + description: "재화 개수. 0 이상입니다.", + example: 1000, + }, + groupCreditAmount: { + type: "number", + description: "소속 새터반에 소속된 유저의 전체 재화 개수", + example: 35000, + }, + completedQuests: { + type: "array", + description: + "유저가 완료한 퀘스트의 배열. 여러 번 완료할 수 있는 퀘스트의 경우 배열 내에 같은 퀘스트가 여러 번 포함됩니다.", + items: { + type: "string", + description: "Quest의 Id", + example: "QUEST ID", + }, + }, + isBanned: { + type: "boolean", + description: "해당 유저 제재 대상 여부", + example: false, + }, + group: { + type: "number", + description: "유저의 소속 새터반", + example: 16, + }, + 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"], + properties: { + credit: { + type: "number", + description: "완료 보상 중 재화의 개수입니다.", + example: 100, + }, + }, + }, + maxCount: { + type: "number", + description: "최대 완료 가능 횟수", + example: 1, + }, + isApiRequired: { + type: "boolean", + description: `/events/${eventConfig?.mode}/quests/complete/:questId API를 통해 퀘스트 완료를 요청할 수 있는지 여부`, + 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/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/invite.js b/src/lottery/routes/docs/invite.js new file mode 100644 index 00000000..3a3972da --- /dev/null +++ b/src/lottery/routes/docs/invite.js @@ -0,0 +1,75 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig?.mode}/invite`; + +const inviteDocs = {}; +inviteDocs[`${apiPrefix}/search/:inviter`] = { + get: { + tags: [`${apiPrefix}`], + summary: "초대자 정보 조회", + description: "초대자의 정보를 조회합니다.", + requestBody: { + description: "", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/searchInviterHandler", + }, + }, + }, + }, + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["nickname", "profileImageUrl"], + properties: { + nickname: { + type: "string", + description: "초대자의 닉네임", + example: "asdf", + }, + profileImageUrl: { + type: "string", + description: "초대자의 프로필 이미지 URL", + example: "IMAGE URL", + }, + }, + }, + }, + }, + }, + }, + }, +}; +inviteDocs[`${apiPrefix}/create`] = { + post: { + tags: [`${apiPrefix}`], + summary: "초대 링크 생성", + description: "초대 링크를 생성합니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["inviteUrl"], + properties: { + inviteUrl: { + type: "string", + description: "초대 링크", + example: "INVITE URL", + }, + }, + }, + }, + }, + }, + }, + }, +}; + +module.exports = inviteDocs; diff --git a/src/lottery/routes/docs/items.js b/src/lottery/routes/docs/items.js new file mode 100644 index 00000000..b08aeaf7 --- /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/publicNotice.js b/src/lottery/routes/docs/publicNotice.js new file mode 100644 index 00000000..23a410b2 --- /dev/null +++ b/src/lottery/routes/docs/publicNotice.js @@ -0,0 +1,109 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig?.mode}/publicNotice`; + +const publicNoticeDocs = {}; +// 다음 Endpoint는 2024 봄학기 이벤트에서 사용되지 않습니다. +// +// 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: + "새터반 별 재화 개수 기준의 리더보드와 관련된 정보를 가져옵니다.", + responses: { + 200: { + description: "", + content: { + "application/json": { + schema: { + type: "object", + required: ["leaderboard"], + properties: { + leaderboard: { + type: "array", + description: "이벤트에 참여한 새터반 전체가 포함된 리더보드", + items: { + type: "object", + required: [ + "group", + "creditAmount", + "mvpNickname", + "mvpProfileImageUrl", + ], + properties: { + group: { + type: "number", + description: "새터반", + example: 16, + }, + creditAmount: { + type: "number", + description: "새터반에 소속된 유저의 전체 재화 개수", + example: 3000, + }, + mvpNickname: { + type: "string", + description: + "MVP(새터반 내에서 가장 많은 재화를 가진 유저)의 닉네임", + example: "asdf", + }, + mvpProfileImageUrl: { + type: "string", + description: "MVP의 프로필 이미지 URL", + example: "IMAGE URL", + }, + }, + }, + }, + group: { + type: "number", + description: "유저의 소속 새터반", + example: 16, + }, + rank: { + type: "number", + description: + "유저의 소속 새터반의 리더보드 순위. 1부터 시작합니다.", + example: 1, + }, + }, + }, + }, + }, + }, + }, + }, +}; + +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..14694f3e --- /dev/null +++ b/src/lottery/routes/docs/quests.js @@ -0,0 +1,62 @@ +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig?.mode}/quests`; + +const questsDocs = {}; +questsDocs[`${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 = questsDocs; diff --git a/src/lottery/routes/docs/schemas/globalStateSchema.js b/src/lottery/routes/docs/schemas/globalStateSchema.js new file mode 100644 index 00000000..0c3c55e6 --- /dev/null +++ b/src/lottery/routes/docs/schemas/globalStateSchema.js @@ -0,0 +1,17 @@ +const { z } = require("zod"); +const { zodToSchemaObject } = require("../../../../routes/docs/utils"); +const { objectId, user } = require("../../../../modules/patterns"); + +const globalStateZod = { + createUserGlobalStateHandler: z + .object({ + phoneNumber: z.string().regex(user.phoneNumber), + group: z.number().gte(1).lte(26), + inviter: z.string().regex(objectId), + }) + .partial({ inviter: true }), +}; + +const globalStateSchema = zodToSchemaObject(globalStateZod); + +module.exports = { globalStateZod, globalStateSchema }; diff --git a/src/lottery/routes/docs/schemas/inviteSchema.js b/src/lottery/routes/docs/schemas/inviteSchema.js new file mode 100644 index 00000000..e3016557 --- /dev/null +++ b/src/lottery/routes/docs/schemas/inviteSchema.js @@ -0,0 +1,13 @@ +const { z } = require("zod"); +const { zodToSchemaObject } = require("../../../../routes/docs/utils"); +const { objectId } = require("../../../../modules/patterns"); + +const inviteZod = { + searchInviterHandler: z.object({ + inviter: z.string().regex(objectId), + }), +}; + +const inviteSchema = zodToSchemaObject(inviteZod); + +module.exports = { inviteSchema, inviteZod }; diff --git a/src/lottery/routes/docs/schemas/itemsSchema.js b/src/lottery/routes/docs/schemas/itemsSchema.js new file mode 100644 index 00000000..80912cfb --- /dev/null +++ b/src/lottery/routes/docs/schemas/itemsSchema.js @@ -0,0 +1,98 @@ +/* Item에 대한 기본적인 프로퍼티를 갖고 있는 스키마입니다. + * TODO: 추후 코드 재사용시 상황에 맞춰 zod로 이전이 필요합니다. + */ +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/schemas/questsSchema.js b/src/lottery/routes/docs/schemas/questsSchema.js new file mode 100644 index 00000000..2efd11cd --- /dev/null +++ b/src/lottery/routes/docs/schemas/questsSchema.js @@ -0,0 +1,10 @@ +const { z } = require("zod"); +const { zodToSchemaObject } = require("../../../../routes/docs/utils"); + +const questsZod = { + completeHandler: z.object({ questId: z.enum(["roomSharing"]) }), +}; + +const questsSchema = zodToSchemaObject(questsZod); + +module.exports = { questsZod, questsSchema }; diff --git a/src/lottery/routes/docs/swaggerDocs.js b/src/lottery/routes/docs/swaggerDocs.js new file mode 100644 index 00000000..38ca8298 --- /dev/null +++ b/src/lottery/routes/docs/swaggerDocs.js @@ -0,0 +1,63 @@ +const globalStateDocs = require("./globalState"); +const inviteDocs = require("./invite"); +const itemsDocs = require("./items"); +const publicNoticeDocs = require("./publicNotice"); +const questsDocs = require("./quests"); +const transactionsDocs = require("./transactions"); + +const { globalStateSchema } = require("./schemas/globalStateSchema"); +const { inviteSchema } = require("./schemas/inviteSchema"); +const itemsSchema = require("./schemas/itemsSchema"); +const { questsSchema } = require("./schemas/questsSchema"); + +const { eventConfig } = require("../../../../loadenv"); +const apiPrefix = `/events/${eventConfig?.mode}`; + +const eventSwaggerDocs = { + tags: [ + { + name: `${apiPrefix}/globalState`, + description: "이벤트 - Global State 관련 API", + }, + { + name: `${apiPrefix}/invite`, + description: "이벤트 - 초대 링크 관련 API", + }, + // 이 태그는 2024 봄학기 이벤트에서 사용되지 않습니다. + // + // { + // name: `${apiPrefix}/items`, + // description: "이벤트 - 아이템 관련 API", + // }, + { + name: `${apiPrefix}/publicNotice`, + description: "이벤트 - 아이템 구매, 뽑기, 획득 공지 관련 API", + }, + { + name: `${apiPrefix}/quests`, + description: "이벤트 - 퀘스트 관련 API", + }, + { + name: `${apiPrefix}/transactions`, + description: "이벤트 - 입출금 내역 관련 API", + }, + ], + paths: { + ...globalStateDocs, + ...inviteDocs, + //...itemsDocs, + ...publicNoticeDocs, + ...questsDocs, + ...transactionsDocs, + }, + components: { + schemas: { + ...globalStateSchema, + ...inviteSchema, + //...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..fa78238b --- /dev/null +++ b/src/lottery/routes/docs/transactions.js @@ -0,0 +1,70 @@ +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", + }, + 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..1f2b4327 --- /dev/null +++ b/src/lottery/routes/globalState.js @@ -0,0 +1,19 @@ +const express = require("express"); +const { validateBody } = require("../../middlewares/zod"); +const { globalStateZod } = require("./docs/schemas/globalStateSchema"); +const router = express.Router(); +const globalStateHandlers = require("../services/globalState"); + +router.get("/", globalStateHandlers.getUserGlobalStateHandler); + +// 아래의 Endpoint 접근 시 로그인 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/create", + validateBody(globalStateZod.createUserGlobalStateHandler), + globalStateHandlers.createUserGlobalStateHandler +); + +module.exports = router; diff --git a/src/lottery/routes/invite.js b/src/lottery/routes/invite.js new file mode 100644 index 00000000..eafa09cb --- /dev/null +++ b/src/lottery/routes/invite.js @@ -0,0 +1,20 @@ +const express = require("express"); +const { validateParams } = require("../../middlewares/zod"); +const { inviteZod } = require("./docs/schemas/inviteSchema"); +const router = express.Router(); +const inviteHandlers = require("../services/invite"); + +router.get( + "/search/:inviter", + validateParams(inviteZod.searchInviterHandler), + inviteHandlers.searchInviterHandler +); + +// 아래의 Endpoint 접근 시 로그인, 차단 여부 체크 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/checkBanned")); +router.use(require("../middlewares/timestampValidator")); + +router.post("/create", inviteHandlers.createInviteUrlHandler); + +module.exports = router; diff --git a/src/lottery/routes/items.js b/src/lottery/routes/items.js new file mode 100644 index 00000000..5cdf98a8 --- /dev/null +++ b/src/lottery/routes/items.js @@ -0,0 +1,23 @@ +const express = require("express"); + +const router = express.Router(); +// TODO: 추후 코드 재사용시 상황에 맞춰 zod로 이전이 필요합니다. +const itemsHandlers = require("../services/items"); +const itemsSchema = require("./docs/schemas/itemsSchema"); + +// 아래의 Endpoint는 2024 봄학기 이벤트에서 사용되지 않습니다. +// +// 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..4698a193 --- /dev/null +++ b/src/lottery/routes/publicNotice.js @@ -0,0 +1,15 @@ +const express = require("express"); + +const router = express.Router(); +const publicNoticeHandlers = require("../services/publicNotice"); + +router.get("/leaderboard", publicNoticeHandlers.getGroupLeaderboardHandler); + +// 아래의 Endpoint는 2024 봄학기 이벤트에서 사용되지 않습니다. +// +// router.get( +// "/recentTransactions", +// publicNoticeHandlers.getRecentPurchaceItemListHandler +// ); + +module.exports = router; diff --git a/src/lottery/routes/quests.js b/src/lottery/routes/quests.js new file mode 100644 index 00000000..4941c8d2 --- /dev/null +++ b/src/lottery/routes/quests.js @@ -0,0 +1,18 @@ +const express = require("express"); +const { validateParams } = require("../../middlewares/zod"); +const { questsZod } = require("./docs/schemas/questsSchema"); +const router = express.Router(); +const questsHandlers = require("../services/quests"); + +// 아래의 Endpoint 접근 시 로그인, 차단 여부 체크 및 시각 체크 필요 +router.use(require("../../middlewares/auth")); +router.use(require("../middlewares/checkBanned")); +router.use(require("../middlewares/timestampValidator")); + +router.post( + "/complete/:questId", + validateParams(questsZod.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..aee05d90 --- /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"); + +// 아래의 Endpoint 접근 시 로그인 필요 +router.use(require("../../middlewares/auth")); + +router.get("/", transactionsHandlers.getUserTransactionsHandler); + +module.exports = router; diff --git a/src/lottery/schedules/detectAbusingUsers.js b/src/lottery/schedules/detectAbusingUsers.js new file mode 100644 index 00000000..e5b910aa --- /dev/null +++ b/src/lottery/schedules/detectAbusingUsers.js @@ -0,0 +1,245 @@ +const { eventStatusModel } = require("../modules/stores/mongo"); +const { + roomModel, + chatModel, + reportModel, +} = require("../../modules/stores/mongo"); +const { + notifyAbuseDetectionResultToReportChannel, +} = require("../modules/slackNotification"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.period.startAt), + endAt: new Date(eventConfig.period.endAt), +}; + +/** + * 매일 새벽 4시에 어뷰징 사용자를 감지하고, Slack을 통해 관리자에게 알림을 전송합니다. + * Original Idea by chlehdwon + * + * 성능면에서 상당히 죄책감이 드는 코드이지만, 새벽에 동작하니 괜찮을 것 같습니다... :( + */ + +// 두 ObjectId가 같은지 비교하는 함수 +const equalsObjectId = (a) => (b) => a.equals(b); + +// ObjectId의 배열에서 중복을 제거하는 함수 +const removeObjectIdDuplicates = (array) => { + return array.filter( + (element, index) => array.findIndex(equalsObjectId(element)) === index + ); +}; + +// 기준 1. "기타 사유"로 신고받은 사용자 +const detectReportedUsers = async (period, candidateUserIds) => { + const reports = await reportModel.aggregate([ + { + $match: { + reportedId: { $in: candidateUserIds }, + type: "etc-reason", + time: { $gte: period.startAt, $lt: period.endAt }, + }, + }, + ]); + const reportedUserIds = removeObjectIdDuplicates( + reports.map((report) => report.reportedId) + ); + + return { reports, reportedUserIds }; +}; + +// 기준 2. 하루에 탑승 기록이 많은 사용자 +const detectMultiplePartUsers = async (period, candidateUserIds) => { + const rooms = await roomModel.aggregate([ + { + $match: { + part: { $elemMatch: { user: { $in: candidateUserIds } } }, // 방 참여자 중 후보자가 존재 + "part.1": { $exists: true }, // 방 참여자가 2명 이상 + time: { $gte: period.startAt, $lt: period.endAt }, + settlementTotal: { $gt: 0 }, + }, + }, + { + $group: { + _id: { + $dateToString: { + date: "$time", + format: "%Y-%m-%d", + timezone: "+09:00", + }, + }, + roomIds: { $push: "$_id" }, + users: { $push: "$part.user" }, + }, + }, // 후보 방들을 날짜별로 그룹화 + { + $project: { + roomIds: true, + users: { + $reduce: { + input: "$users", + initialValue: [], + in: { $concatArrays: ["$$value", "$$this"] }, + }, + }, + }, + }, // 날짜별로 방 참여자들의 목록을 병합 + ]); + const multiplePartUserIdsByDay = rooms.map( + ({ users }) => + removeObjectIdDuplicates(users) + .filter((userId) => candidateUserIds.some(equalsObjectId(userId))) // 후보자 + .filter( + (userId) => + users.findIndex(equalsObjectId(userId)) !== + users.findLastIndex(equalsObjectId(userId)) // 두 값이 다르면 중복된 값이 존재 + ) // 하루에 2번 이상 탑승한 사용자 + ); // 날짜별로 하루에 2번 이상 탑승한 후보자만 필터링 + const multiplePartRooms = await Promise.all( + rooms.map( + async ({ roomIds }, index) => + await roomModel.find({ + _id: { + $in: roomIds, + }, + part: { + $elemMatch: { user: { $in: multiplePartUserIdsByDay[index] } }, + }, + }) + ) + ); // 날짜별로 하루에 2번 이상 탑승한 후보자가 참여한 방들을 필터링 + const multiplePartUserIds = removeObjectIdDuplicates( + multiplePartUserIdsByDay.reduce( + (array, userIds) => array.concat(userIds), + [] + ) + ); + + return { multiplePartRooms, multiplePartUserIds }; +}; + +// 기준 3. 채팅 개수가 5개 미만인 방에 속한 사용자 +const detectLessChatUsers = async (period, candidateUserIds) => { + const chats = await chatModel.aggregate([ + { + $match: { + time: { $gte: period.startAt, $lt: period.endAt }, + }, + }, + { + $group: { + _id: "$roomId", + count: { + $sum: { + $cond: [{ $eq: ["$type", "text"] }, 1, 0], // type이 text인 경우만 count + }, + }, + }, + }, // 채팅들을 방별로 그룹화 + { + $match: { + count: { $lt: 5 }, + }, + }, + ]); + const lessChatRooms = await Promise.all( + chats.map(async ({ _id: roomId, count }) => { + const room = await roomModel.findById(roomId).lean(); + if ( + period.startAt > room.time || + period.endAt <= room.time || + room.part.length < 2 || + room.settlementTotal === 0 + ) + return null; + + const parts = room.part + .map((part) => part.user) + .filter((userId) => candidateUserIds.some(equalsObjectId(userId))); + if (parts.length === 0) return null; + + return { + roomId, + parts, + }; + }) + ); // 방 정보에 기반하여 추가적으로 필터링 + const lessChatUserIds = removeObjectIdDuplicates( + lessChatRooms.reduce( + (array, day) => (day ? array.concat(day.parts) : array), + [] + ) + ); + + return { lessChatRooms, lessChatUserIds }; +}; + +module.exports = async () => { + try { + // 오늘 자정(0시) + const todayMidnight = new Date(); + todayMidnight.setHours(0, 0, 0, 0); + + // 어제 자정 + const yesterdayMidnight = new Date(); + yesterdayMidnight.setDate(yesterdayMidnight.getDate() - 1); + yesterdayMidnight.setHours(0, 0, 0, 0); + + // 이벤트 기간이 아니면 종료 + if ( + !eventPeriod || + yesterdayMidnight >= eventPeriod.endAt || + todayMidnight <= eventPeriod.startAt + ) + return; + + logger.info("Abusing user detection started"); + + // 어제 있었던 활동을 기준으로 감지 + const period = { + startAt: yesterdayMidnight, + endAt: todayMidnight, + }; + + const candidateUsers = await eventStatusModel.find({}, "userId").lean(); + const candidateUserIds = candidateUsers.map((user) => user.userId); + + // 기준 1 ~ 기준 3에 각각 해당되는 사용자 목록 + const { reportedUserIds } = await detectReportedUsers( + period, + candidateUserIds + ); + const { multiplePartRooms, multiplePartUserIds } = + await detectMultiplePartUsers(period, candidateUserIds); + const { lessChatRooms, lessChatUserIds } = await detectLessChatUsers( + period, + candidateUserIds + ); + + // 기준 1 ~ 기준 3 중 하나라도 해당되는 사용자 목록 + const abusingUserIds = removeObjectIdDuplicates( + reportedUserIds.concat(multiplePartUserIds, lessChatUserIds) + ); + + logger.info( + `Total ${abusingUserIds.length} users detected! Refer to Slack for more information` + ); + + // Slack으로 알림 전송 + notifyAbuseDetectionResultToReportChannel( + abusingUserIds, + reportedUserIds, + multiplePartRooms, + multiplePartUserIds, + lessChatRooms, + lessChatUserIds + ); + + logger.info("Abusing user detection successfully finished"); + } catch (err) { + logger.error(err); + logger.error("Abusing user detection failed"); + } +}; diff --git a/src/lottery/schedules/index.js b/src/lottery/schedules/index.js new file mode 100644 index 00000000..09665fef --- /dev/null +++ b/src/lottery/schedules/index.js @@ -0,0 +1,7 @@ +const cron = require("node-cron"); + +const registerSchedules = () => { + cron.schedule("0 4 * * *", require("./detectAbusingUsers")); +}; + +module.exports = registerSchedules; diff --git a/src/lottery/services/globalState.js b/src/lottery/services/globalState.js new file mode 100644 index 00000000..5459f851 --- /dev/null +++ b/src/lottery/services/globalState.js @@ -0,0 +1,151 @@ +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 { nodeEnv } = require("../../../loadenv"); + +const { eventConfig } = require("../../../loadenv"); +const contracts = require("../modules/contracts"); +const quests = Object.values(contracts.quests); + +// 유저가 이벤트에 참여할 수 있는지 확인하는 함수입니다. +const checkIsUserEligible = (user) => { + // production 환경이 아닌 경우 테스트를 위해 참여 조건을 확인하지 않습니다. + if (nodeEnv !== "production") return true; + + const kaistId = parseInt(user?.subinfo?.kaist || "0"); + return 20240001 <= kaistId && kaistId <= 20241500; +}; + +const getUserGlobalStateHandler = async (req, res) => { + try { + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + const user = userId && (await userModel.findOne({ _id: userId }).lean()); + + const eventStatus = + userId && + (await eventStatusModel + .findOne({ userId }, "completedQuests creditAmount isBanned group") + .lean()); + if (!eventStatus) + return res.json({ + isAgreeOnTermsOfEvent: false, + isEligible: checkIsUserEligible(user) || !!user?.isAdmin, // 테스트를 위해 관리자인 경우 true로 설정합니다. 하지만 관리자이더라도 이벤트에 참여할 수 없습니다. + completedQuests: [], + creditAmount: 0, + group: 0, + groupCreditAmount: 0, + quests, + }); + + // group이 eventStatus.group과 같은 사용자들의 creditAmount를 합산합니다. + const groupCreditAmount = await eventStatusModel.aggregate([ + { + $match: { + group: eventStatus.group, + }, + }, + { + $group: { + _id: null, + creditAmount: { $sum: "$creditAmount" }, + }, + }, + ]); + const groupCreditAmountReal = groupCreditAmount?.[0].creditAmount; + if (!groupCreditAmountReal && groupCreditAmountReal !== 0) + return res + .status(500) + .json({ error: "GlobalState/ : internal server error" }); + + return res.json({ + isAgreeOnTermsOfEvent: true, + isEligible: true, + ...eventStatus, + groupCreditAmount: groupCreditAmountReal, + 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" }); + + /* Request의 inviter 필드가 설정되어 있는데, + 1. 해당되는 유저가 이벤트에 참여하지 않았거나, + 2. 해당되는 유저의 이벤트 참여가 제한된 상태이거나, + 3. 해당되는 유저의 초대 링크가 활성화되지 않았으면, + 에러를 발생시킵니다. 개인정보 보호를 위해 오류 메세지는 하나로 통일하였습니다. */ + const inviterStatus = + req.body.inviter && + (await eventStatusModel.findOne({ _id: req.body.inviter }).lean()); + if ( + req.body.inviter && + (!inviterStatus || + inviterStatus.isBanned || + !inviterStatus.isEnabledInviteUrl) + ) + return res.status(400).json({ + error: "GlobalState/Create : inviter did not participate in the event", + }); + + const user = await userModel.findOne({ _id: req.userOid }); + if (!user) + return res + .status(500) + .json({ error: "GlobalState/Create : internal server error" }); + + // 유저가 이벤트에 참여할 수 있는지 확인합니다. + const isEligible = checkIsUserEligible(user); + if (!isEligible) + return res.status(400).json({ + error: "GlobalState/Create : not eligible to participate in the event", + }); + + // 수집한 전화번호를 User Document에 저장합니다. + // 다른 이벤트 참여 과정에서 문제가 생길 수 있으므로, 이벤트 참여 자격이 있는 경우에만 저장합니다. + user.phoneNumber = req.body.phoneNumber; + await user.save(); + + // EventStatus Document를 생성합니다. + eventStatus = new eventStatusModel({ + userId: req.userOid, + creditAmount: eventConfig?.credit.initialAmount ?? 0, + group: req.body.group, + inviter: req.body.inviter, + }); + await eventStatus.save(); + + await contracts.completeFirstLoginQuest(req.userOid, req.timestamp); + + if (req.body.inviter) { + await contracts.completeEventSharingQuest(req.userOid, req.timestamp); + await contracts.completeEventSharingQuest( + inviterStatus.userId, + req.timestamp + ); + } + + return 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/invite.js b/src/lottery/services/invite.js new file mode 100644 index 00000000..c7871273 --- /dev/null +++ b/src/lottery/services/invite.js @@ -0,0 +1,66 @@ +const { eventStatusModel } = require("../modules/stores/mongo"); +const { userModel } = require("../../modules/stores/mongo"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); + +const searchInviterHandler = async (req, res) => { + try { + const { inviter } = req.params; + const inviterStatus = await eventStatusModel.findOne({ _id: inviter }); + if ( + !inviterStatus || + !inviterStatus.isEnabledInviteUrl || + inviterStatus.isBanned + ) + return res.status(400).json({ error: "Invite/Search : invalid inviter" }); + + const inviterInfo = await userModel.findOne({ _id: inviterStatus.userId }); + if (!inviterInfo) + return res + .status(500) + .json({ error: "Invite/Search : internal server error" }); + + return res.json({ + nickname: inviterInfo.nickname, + profileImageUrl: inviterInfo.profileImageUrl, + }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Invite/Search : internal server error" }); + } +}; + +const createInviteUrlHandler = async (req, res) => { + try { + const inviteUrl = `${req.origin}/event/${eventConfig?.mode}-invite/${req.eventStatus._id}`; + + if (req.eventStatus.isEnabledInviteUrl) return res.json({ inviteUrl }); + + const eventStatus = await eventStatusModel + .findOneAndUpdate( + { + _id: req.eventStatus._id, + isEnabledInviteUrl: false, + }, + { + isEnabledInviteUrl: true, + } + ) + .lean(); + if (!eventStatus) + return res + .status(500) + .json({ error: "Invite/Create : internal server error" }); + + return res.json({ inviteUrl }); + } catch (err) { + logger.error(err); + res.status(500).json({ error: "Invite/Create : internal server error" }); + } +}; + +module.exports = { + searchInviterHandler, + createInviteUrlHandler, +}; diff --git a/src/lottery/services/items.js b/src/lottery/services/items.js new file mode 100644 index 00000000..9189bae4 --- /dev/null +++ b/src/lottery/services/items.js @@ -0,0 +1,220 @@ +const { + eventStatusModel, + itemModel, + transactionModel, +} = require("../modules/stores/mongo"); +const logger = require("../../modules/logger"); + +const { eventConfig } = require("../../../loadenv"); + +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: `${eventConfig?.credit.name} ${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..dd3dc91b --- /dev/null +++ b/src/lottery/services/publicNotice.js @@ -0,0 +1,248 @@ +const { + eventStatusModel, + transactionModel, +} = 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"); + +const { eventConfig } = require("../../../loadenv"); + +/** + * 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(eventConfig?.credit.name) + ? "을(를) 구입하셨습니다." + : 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); +}; + +// 2023 가을 이벤트를 위한 리더보드 API 핸들러입니다. +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" }); + } +}; + +// 2024 봄 이벤트를 위한 리더보드 API 핸들러입니다. +const getGroupLeaderboardHandler = async (req, res) => { + try { + const leaderboardWithoutMvp = await eventStatusModel.aggregate([ + { + $group: { + _id: "$group", + creditAmount: { $sum: "$creditAmount" }, + }, + }, // group을 기준으로 사용자들의 creditAmount를 합산합니다. + { + $project: { + _id: false, + group: "$_id", + creditAmount: true, + }, + }, // _id 필드의 이름을 group으로 변경합니다. + { + $sort: { + creditAmount: -1, + group: 1, + }, + }, // creditAmount를 기준으로 내림차순 정렬합니다. creditAmount가 같을 경우 group을 기준으로 오름차순 정렬합니다. + ]); + const leaderboard = await Promise.all( + leaderboardWithoutMvp.map(async (group) => { + const mvp = await eventStatusModel + .find({ group: group.group }) + .sort({ creditAmount: -1 }) + .limit(1) // Aggreation을 사용하는 것보다, sort와 limit을 바로 붙여 사용하는 것이 더 효율적입니다. + .lean(); + if (mvp?.length !== 1) + throw new Error(`Fail to find MVP in group ${group.group}`); + + const mvpInfo = await userModel.findOne({ _id: mvp[0].userId }).lean(); + if (!mvpInfo) throw new Error(`Fail to find user ${mvp[0].userId}`); + + return { + ...group, + mvpNickname: mvpInfo.nickname, + mvpProfileImageUrl: mvpInfo.profileImageUrl, + }; + }) + ); + + const userId = isLogin(req) ? getLoginInfo(req).oid : null; + const user = userId && (await eventStatusModel.findOne({ userId }).lean()); + if (user) { + res.json({ + leaderboard, + group: user.group, + rank: leaderboard.findIndex((group) => group.group === user.group) + 1, + }); + } else { + res.json({ leaderboard }); + } + } catch (err) { + logger.error(err); + res + .status(500) + .json({ error: "PublicNotice/Leaderboard : internal server error" }); + } +}; + +module.exports = { + getRecentPurchaceItemListHandler, + getTicketLeaderboardHandler, + getGroupLeaderboardHandler, +}; diff --git a/src/lottery/services/quests.js b/src/lottery/services/quests.js new file mode 100644 index 00000000..ae1472bc --- /dev/null +++ b/src/lottery/services/quests.js @@ -0,0 +1,22 @@ +const { completeQuest } = require("../modules/quests"); +const logger = require("../../modules/logger"); + +const contracts = require("../modules/contracts"); + +const completeHandler = async (req, res) => { + try { + const quest = contracts.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..1d920870 --- /dev/null +++ b/src/lottery/services/transactions.js @@ -0,0 +1,31 @@ +const { transactionModel } = require("../modules/stores/mongo"); +const logger = require("../../modules/logger"); + +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 }, "_id type amount questId comment createAt") + .lean(); + if (transactions) + res.json({ + transactions, + }); + 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/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/session.js b/src/middlewares/session.js index c4bdec63..5412ba1c 100644 --- a/src/middlewares/session.js +++ b/src/middlewares/session.js @@ -1,30 +1,14 @@ const expressSession = require("express-session"); -const redis = require("redis"); -const MongoStore = require("connect-mongo"); -const RedisStore = require("connect-redis")(expressSession); -const { - redis: redisUrl, - mongo: mongoUrl, - session: sessionSecret, -} = require("../../loadenv"); -const logger = require("../modules/logger"); - -// 환경변수 REDIS_PATH 유무에 따라 session 저장 방식이 변경됩니다. -let sessionStore = null; -if (redisUrl) { - const client = redis.createClient({ - url: redisUrl, - legacyMode: true, - }); - client.connect().catch(logger.error); - sessionStore = new RedisStore({ client }); -} else { - sessionStore = MongoStore.create({ mongoUrl }); -} +const { nodeEnv, session: sessionConfig } = require("../../loadenv"); +const sessionStore = require("../modules/stores/sessionStore"); module.exports = expressSession({ - secret: sessionSecret, + secret: sessionConfig.secret, resave: false, saveUninitialized: false, store: sessionStore, + cookie: { + maxAge: sessionConfig.expiry, + secure: nodeEnv === "production", + }, }); diff --git a/src/middlewares/ajv.js b/src/middlewares/zod.js similarity index 55% rename from src/middlewares/ajv.js rename to src/middlewares/zod.js index 0c8a8a3b..63f5668a 100644 --- a/src/middlewares/ajv.js +++ b/src/middlewares/zod.js @@ -1,22 +1,21 @@ -const Ajv = require("ajv"); -const ajvErrors = require("ajv-errors"); -const { default: addFormats } = require("ajv-formats"); +const logger = require("../modules/logger"); -const ajv = new Ajv({ verbose: true, allErrors: true }); -addFormats(ajv); -ajvErrors(ajv); - -const parseAjvErrors = (errors, res) => { +const parseZodErrors = (statusCode, errors, res) => { const error_message = errors; - res.status(400).send(error_message); + res.status(statusCode).send(error_message); }; const validate = (schema, req, res) => { - const validate = ajv.compile(schema); - if (validate(req)) { - return true; - } else { - parseAjvErrors(validate.errors[0].message, res); + try { + const result = schema.safeParse(req); + if (result.success) { + return true; + } else { + parseZodErrors(400, result.error.issues[0].message, res); + } + } catch (err) { + logger.error(err); + parseZodErrors(400, err, res); } }; 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..9c72434f 100644 --- a/src/modules/auths/login.js +++ b/src/modules/auths/login.js @@ -1,14 +1,15 @@ +const { session: sessionConfig } = require("../../../loadenv"); const logger = require("../logger"); const getLoginInfo = (req) => { if (req.session.loginInfo) { const { id, sid, oid, name, time } = req.session.loginInfo; const timeFlow = Date.now() - time; - if (timeFlow > 14 * 24 * 3600 * 1000 /* 14일 */) { - // if (timeFlow > 1 * 3600 * 1000 /* 1시간 */) { + // 14일이 지난 세션에 대해서는 로그인 정보를 반환하지 않습니다. + // 세션은 새로운 요청 시 갱신되지 않습니다. + if (timeFlow > sessionConfig.expiry) { return { id: undefined, sid: undefined, oid: undefined, name: undefined }; } - req.session.loginInfo.time = Date.now(); return { id, sid, oid, name }; } return { id: undefined, sid: undefined, oid: undefined, name: undefined }; @@ -27,7 +28,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/email.js b/src/modules/email.js new file mode 100644 index 00000000..2ccfce8c --- /dev/null +++ b/src/modules/email.js @@ -0,0 +1,144 @@ +const nodemailer = require("nodemailer"); +const logger = require("./logger"); +const { nodeEnv } = require("../../loadenv"); + +/** + * production 환경에서 메일을 전송하기 위해 사용되는 agent입니다. + */ +class NodemailerTransport { + /** 메일 전송을 위한 agent 객체로, private 필드입니다. */ + #transporter; + + constructor() { + this.#transporter = nodemailer.createTransport({ + name: "sparcs.org", + host: "smtp-relay.gmail.com", + secure: false, + port: 587, + tls: { + rejectUnauthorized: false, + }, + }); + } + + /** + * 이메일을 전송합니다. + * @param {nodemailer.SendMailOptions} mailOptions - 메일 전송에 필요한 주소, 제목, 본문 등 정보입니다. + * @return {Promise} 이메일 전송에 성공하면 true를, 실패하면 false를 반환합니다. + */ + async sendMail(mailOptions) { + try { + await this.#transporter.sendMail(mailOptions); + return true; + } catch (err) { + logger.error(`Failed to send email: ${err}`); + return false; + } + } +} + +/** + * development, test 환경에서 메일을 전송하기 위해 사용되는 agent입니다. + * + * Gmail relay 서버를 사용한 이메일 전송은 production 서버의 ip에서만 허용됩니다. + * 그 외 환경에서는 Ethereal mock 서버를 사용해 이메일 전송을 테스트합니다. + */ +class MockNodemailerTransport { + /** 메일 전송을 위한 agent 객체를 생성하는 Promise로, private 필드입니다. */ + #transporterPromise; + + constructor() { + this.#transporterPromise = null; + } + + /** + * 이메일 전송을 위한 agent 객체를 생성합니다. + * mock 이메일 전송을 위한 테스트 계정을 생성하기 위해 비동기 함수로 작성되었습니다. + * @return {Promise} agent 객체를 생성하는 Promise입니다. + * @throws {Error} 이메일 전송을 위한 agent 객체 생성에 실패하면 에러를 반환합니다. + */ + async getTransporter() { + if (!this.#transporterPromise) { + this.#transporterPromise = nodemailer + .createTestAccount() + .then((account) => { + return nodemailer.createTransport({ + host: "smtp.ethereal.email", + port: 587, + secure: false, + auth: { + user: account.user, + pass: account.pass, + }, + }); + }) + .catch((err) => { + // 네트워크 오류 등으로 mock 메일 전송을 위한 agent 객체 생성에 실패했을 때 에러를 반환합니다. + // sendMail 메서드가 다시 호출될 때 새로운 transporterPromise를 생성하기 위해 null로 초기화합니다. + logger.error("Failed to create agent object for sending mock mail."); + this.#transporterPromise = null; + throw err; + }); + return this.#transporterPromise; + } else { + // 이미 다른 caller에서 transporterPromise를 생성했다면 해당 Promise를 반환합니다. + return this.#transporterPromise; + } + } + + /** + * 이메일을 전송합니다. + * @param {nodemailer.SendMailOptions} mailOptions - 메일 전송에 필요한 주소, 제목, 본문 등 정보입니다. + * @return {Promise} 이메일 전송에 성공하면 true를, 실패하면 false를 반환합니다. + */ + async sendMail(mailOptions) { + try { + const transporter = await this.getTransporter(); + const response = await transporter.sendMail(mailOptions); + logger.info( + `Mock mail sent successfully. Preview url: ${nodemailer.getTestMessageUrl( + response + )}` + ); + return true; + } catch (err) { + logger.error(`Failed to send email: ${err}`); + return false; + } + } +} + +/** 메일 전송을 위한 agent 객체입니다. */ +const transporter = + nodeEnv === "production" + ? new NodemailerTransport() + : new MockNodemailerTransport(); + +/** + * 이메일을 전송합니다. + * @param {string} reportedEmail - 신고를 받은 사용자의 이메일입니다. + * @param {object} report - 신고 내용입니다. + * @param {string} report.type - 신고 유형입니다. reportTypeMap의 키여야 합니다. + * @param {string} html - HTML 형식의 이메일 본문입니다. + * @return {Promise} 이메일 전송에 성공하면 true를, 실패하면 false를 반환합니다. + */ +const sendReportEmail = async (reportedEmail, report, html) => { + const reportTypeMap = { + "no-settlement": "정산을 하지 않음", + "no-show": "택시에 동승하지 않음", + "etc-reason": "기타 사유", + }; + + return transporter.sendMail({ + from: "taxi@sparcs.org", + to: reportedEmail, + subject: `[SPARCS TAXI] 신고가 접수되었습니다 (사유: ${ + reportTypeMap[report.type] + })`, + html, + }); +}; + +module.exports = { + sendReportEmail, +}; diff --git a/src/modules/fcm.js b/src/modules/fcm.js index cf6a0433..a19bd004 100644 --- a/src/modules/fcm.js +++ b/src/modules/fcm.js @@ -18,7 +18,7 @@ const initializeApp = () => { }); } else { logger.error( - "Firebase 관련 credential이 존재하지 않습니다. FCM 관련 기능을 사용할 수 없습니다." + "There is no credential for Firebase. FCM functions are disabled." ); } }; @@ -111,7 +111,7 @@ const removeExpiredTokens = async (deviceTokens, fcmResponses) => { } return false; } catch (err) { - logger.info(err); + logger.error(err); return false; } }) 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/modifyProfile.js b/src/modules/modifyProfile.js index 0d15bd58..e8702f98 100755 --- a/src/modules/modifyProfile.js +++ b/src/modules/modifyProfile.js @@ -1,4 +1,5 @@ const crypto = require("crypto"); +const aws = require("./stores/aws"); const nouns = [ "재료역학", @@ -81,7 +82,7 @@ const generateNickname = (id) => { // 기존 프로필 사진의 URI 중 하나를 무작위로 선택해 반환합니다. const generateProfileImageUrl = () => { const ridx = crypto.randomInt(defaultProfile.length); - return `default/${defaultProfile[ridx]}`; + return aws.getS3Url(`/profile-img/default/${defaultProfile[ridx]}`); }; // 사용자의 이름과 성을 받아, 한글인지 영어인지에 따라 전체 이름을 반환합니다. diff --git a/src/modules/patterns.js b/src/modules/patterns.js index 7111ec1a..2fd1e4dc 100644 --- a/src/modules/patterns.js +++ b/src/modules/patterns.js @@ -1,16 +1,16 @@ module.exports = { + objectId: RegExp("^[a-fA-F\\d]{24}$"), room: { name: RegExp( "^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ,.?! _~/#'\\\\@=\"\\-\\^()+*<>{}[\\]]{1,50}$" // ,.?/#'\@="-^()+*<>{}[] 허용 ), - from: RegExp("^[A-Za-z0-9가-힣 -]{1,20}$"), - to: RegExp("^[A-Za-z0-9가-힣 -]{1,20}$"), }, user: { nickname: RegExp("^[A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ0-9-_ ]{3,25}$"), - allowedEmployeeTypes: RegExp("^([PEUR]|[SA]|[PEUR][SA])$"), + allowedEmployeeTypes: RegExp("^([PEUR]|[SAGC]|[PEUR][SAGC])$"), profileImgType: RegExp("^(image/png|image/jpg|image/jpeg)$"), account: RegExp("^[A-Za-z가-힣]{2,7} [0-9]{10,14}$|^$"), + phoneNumber: RegExp("^010-?([0-9]{3,4})-?([0-9]{4})$"), }, 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/slackNotification.js b/src/modules/slackNotification.js index dc00e4a0..544e8a31 100644 --- a/src/modules/slackNotification.js +++ b/src/modules/slackNotification.js @@ -1,19 +1,12 @@ -const { slackWebhookUrl: slackUrl } = require("../../loadenv"); +const { nodeEnv, slackWebhookUrl: slackUrl } = require("../../loadenv"); const axios = require("axios"); const logger = require("../modules/logger"); -module.exports.notifyToReportChannel = (reportUser, report) => { +const sendTextToReportChannel = (text) => { if (!slackUrl.report) return; const data = { - text: `${reportUser}님으로부터 신고가 접수되었습니다. - - 신고자 ID: ${report.creatorId} - 신고 ID: ${report.reportedId} - 방 ID: ${report.roomId ?? ""} - 사유: ${report.type} - 기타: ${report.etcDetail} - `, + text: nodeEnv === "production" ? text : `(${nodeEnv}) ${text}`, // Production 환경이 아닌 경우, 환경 이름을 붙여서 전송합니다. }; const config = { "Content-Type": "application/json" }; @@ -26,3 +19,37 @@ module.exports.notifyToReportChannel = (reportUser, report) => { logger.error("Fail to send slack webhook", err); }); }; + +const notifyReportToReportChannel = (reportUser, report) => { + sendTextToReportChannel( + `${reportUser}님으로부터 신고가 접수되었습니다. + + 신고자 ID: ${report.creatorId} + 신고 ID: ${report.reportedId} + 방 ID: ${report.roomId ?? ""} + 사유: ${report.type} + 기타: ${report.etcDetail}` + ); +}; + +const notifyRoomCreationAbuseToReportChannel = ( + abusingUser, + abusingUserNickname, + { from, to, time, maxPartLength } +) => { + sendTextToReportChannel( + `${abusingUserNickname}님이 어뷰징이 의심되는 방을 생성하려고 시도했습니다. + + 사용자 ID: ${abusingUser} + 출발지: ${from} + 도착지: ${to} + 출발 시간: ${time} + 최대 참여 가능 인원: ${maxPartLength}명` + ); +}; + +module.exports = { + sendTextToReportChannel, + notifyReportToReportChannel, + notifyRoomCreationAbuseToReportChannel, +}; diff --git a/src/modules/socket.js b/src/modules/socket.js index 3da4370e..c238ce64 100644 --- a/src/modules/socket.js +++ b/src/modules/socket.js @@ -4,7 +4,6 @@ const sessionMiddleware = require("../middlewares/session"); const logger = require("./logger"); const { getLoginInfo } = require("./auths/login"); const { roomModel, userModel, chatModel } = require("./stores/mongo"); -const { getS3Url } = require("./stores/aws"); const { getTokensOfUsers, sendMessageByTokens } = require("./fcm"); const { corsWhiteList } = require("../../loadenv"); @@ -195,7 +194,7 @@ const emitChatEvent = async (io, chat) => { type, name, getMessageBody(type, nickname, content), - getS3Url(`/profile-img/${profileImageUrl}`), + profileImageUrl, `/myroom/${roomId}` ); @@ -206,46 +205,50 @@ const emitChatEvent = async (io, chat) => { } }; +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); + return false; + } +}; + // https://socket.io/how-to/use-with-express-session 참고 const startSocketServer = (server) => { const io = new Server(server, { - allowRequest: (req, callback) => { - const fakeRes = { - getHeader() { - return []; - }, - setHeader(key, values) { - req.cookieHolder = values[0]; - }, - writeHead() {}, - }; - sessionMiddleware(req, fakeRes, () => { - if (req.session) { - fakeRes.writeHead(); - req.session.save(); - } - callback(null, true); - }); - }, cors: { origin: corsWhiteList, methods: ["GET", "POST"], credentials: true, }, }); - - io.engine.on("initial_headers", (headers, req) => { - if (req.cookieHolder) { - headers["set-cookie"] = req.cookieHolder; - delete req.cookieHolder; - } - }); + io.engine.use(sessionMiddleware); io.on("connection", (socket) => { try { const req = socket.request; req.session.reload((err) => { - if (err) throw err; + if (err) { + return socket.disconnect(); + } const { oid: userOid } = getLoginInfo(req); if (!userOid) return; @@ -266,5 +269,6 @@ const startSocketServer = (server) => { module.exports = { transformChatsForRoom, emitChatEvent, + emitUpdateEvent, startSocketServer, }; diff --git a/src/modules/stores/aws.js b/src/modules/stores/aws.js index bade704b..60939ae3 100644 --- a/src/modules/stores/aws.js +++ b/src/modules/stores/aws.js @@ -83,37 +83,3 @@ module.exports.foundObject = (filePath, cb) => { module.exports.getS3Url = (filePath) => { return `${awsEnv.s3Url}${filePath}`; }; - -module.exports.sendReportEmail = (reportedEmail, report, html) => { - const reportTypeMap = { - "no-settlement": "정산을 하지 않음", - "no-show": "택시에 동승하지 않음", - "etc-reason": "기타 사유", - }; - - const params = { - Destination: { - ToAddresses: [reportedEmail], - }, - Message: { - Body: { - Html: { - Data: html, - }, - }, - Subject: { - Charset: "UTF-8", - Data: `[SPARCS TAXI] 신고가 접수되었습니다 (사유: ${reportTypeMap[report.type]})`, - }, - }, - Source: "taxi.sparcs@gmail.com", - }; - - ses.sendEmail(params, (err, data) => { - if (err) { - logger.error("Fail to send email", err); - } else { - logger.info("Email sent successfully"); - } - }); -}; diff --git a/src/modules/stores/mongo.js b/src/modules/stores/mongo.js index bfa94da9..bf2a8c53 100755 --- a/src/modules/stores/mongo.js +++ b/src/modules/stores/mongo.js @@ -1,7 +1,6 @@ const mongoose = require("mongoose"); const Schema = mongoose.Schema; -const { mongo: mongoUrl } = require("../../../loadenv"); const logger = require("../logger"); const userSchema = Schema({ @@ -12,6 +11,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 +34,7 @@ const participantSchema = Schema({ enum: ["not-departed", "paid", "send-required", "sent"], default: "not-departed", }, + readAt: { type: Date }, }); const deviceTokenSchema = Schema({ @@ -117,6 +118,7 @@ const locationSchema = Schema({ latitude: { type: Number }, // 이후 required: true 로 수정 필요 longitude: { type: Number }, // 이후 required: true 로 수정 필요 }); + const chatSchema = Schema({ roomId: { type: Schema.Types.ObjectId, ref: "Room", required: true }, type: { @@ -170,32 +172,40 @@ const adminLogSchema = Schema({ }, // 수행 업무 }); +mongoose.set("strictQuery", true); + const database = mongoose.connection; database.on("error", console.error.bind(console, "mongoose connection error.")); database.on("open", () => { - logger.info("데이터베이스와 연결되었습니다."); + logger.info("Connected to database"); }); database.on("error", function (err) { - logger.error("데이터베이스 연결 에러 발생: " + err); + logger.error("Database connection error occurred: " + err); mongoose.disconnect(); }); -database.on("disconnected", function () { - // 데이터베이스 연결이 끊어지면 5초 후 재연결을 시도합니다. - logger.error("데이터베이스와 연결이 끊어졌습니다!"); - setTimeout(() => { - mongoose.connect(mongoUrl, { - useNewUrlParser: true, - useUnifiedTopology: true, - }); - }, 5000); -}); -mongoose.connect(mongoUrl, { - useNewUrlParser: true, - useUnifiedTopology: true, -}); +const connectDatabase = (mongoUrl) => { + database.on("disconnected", function () { + // 데이터베이스 연결이 끊어지면 5초 후 재연결을 시도합니다. + logger.error("Disconnected from database!"); + setTimeout(() => { + mongoose.connect(mongoUrl, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + }, 5000); + }); + + mongoose.connect(mongoUrl, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + return database; +}; module.exports = { + connectDatabase, userModel: mongoose.model("User", userSchema), deviceTokenModel: mongoose.model("DeviceToken", deviceTokenSchema), notificationOptionModel: mongoose.model( diff --git a/src/modules/stores/sessionStore.js b/src/modules/stores/sessionStore.js new file mode 100644 index 00000000..fca4da55 --- /dev/null +++ b/src/modules/stores/sessionStore.js @@ -0,0 +1,37 @@ +const expressSession = require("express-session"); +const redis = require("redis"); +const MongoStore = require("connect-mongo"); +const RedisStore = require("connect-redis")(expressSession); +const { + redis: redisUrl, + mongo: mongoUrl, + session: sessionConfig, +} = require("../../../loadenv"); +const logger = require("../logger"); + +const getSessionStore = (redisUrl) => { + // 환경변수 REDIS_PATH 유무에 따라 session 저장 방식이 변경됩니다. + if (redisUrl) { + const client = redis.createClient({ + url: redisUrl, + legacyMode: true, + }); + + // redis client 연결 성공 시 로그를 출력합니다. + client.on("ready", () => { + logger.info("Redis session store is connected!"); + }); + + // redis client 에러 발생 시 1초에 두 번 재연결을 시도합니다. + client.on("error", (err) => { + logger.error(err); + }); + + client.connect().catch(logger.error); + return new RedisStore({ client, ttl: sessionConfig.expiry }); + } else { + return MongoStore.create({ mongoUrl }); + } +}; + +module.exports = getSessionStore(redisUrl); diff --git a/src/routes/admin.js b/src/routes/admin.js index 363e1282..da20d60d 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,7 @@ const { deviceTokenModel, notificationOptionModel, } = require("../modules/stores/mongo"); +const { buildResource } = require("../modules/adminResource"); const router = express.Router(); @@ -24,72 +24,22 @@ 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 resources = [ + userModel, + roomModel, + locationModel, + chatModel, + reportModel, + adminIPWhitelistModel, + adminLogModel, + deviceTokenModel, + notificationOptionModel, +] + .map(buildResource()) + .concat(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/README.md b/src/routes/docs/README.md deleted file mode 100755 index e8fecb5d..00000000 --- a/src/routes/docs/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Backend Route documentation - -## Routes - -### [/auth](auth.md) - -### [/auth](auth.replace.md) **(for dev)** - -### [/chats](chats.md) - -### [/rooms](rooms.md) - -### [/static](static.md) - -### [/users](users.md) - -### [/locations](locations.md) diff --git a/src/routes/docs/auth.js b/src/routes/docs/auth.js new file mode 100644 index 00000000..eeef25aa --- /dev/null +++ b/src/routes/docs/auth.js @@ -0,0 +1,452 @@ +const tag = "auth"; +const apiPrefix = "/auth"; + +const authDocs = {}; +authDocs[`${apiPrefix}/sparcssso`] = { + get: { + tags: [tag], + summary: "SPARCS SSO 로그인 페이지로 리다이렉트", + description: "SSO 로그인 페이지로 리다이렉트합니다.", + parameters: [ + { + in: "query", + name: "redirect", + schema: { + type: "string", + }, + description: "리다이렉트 URI", + }, + { + in: "query", + name: "isApp", + schema: { + type: "boolean", + }, + description: "앱인지 여부", + }, + ], + responses: { + 302: { + description: "SPARCS SSO 로그인 페이지로 리다이렉트", + headers: { + Location: { + type: "string", + description: "SPARCS SSO 로그인 페이지", + format: "uri", + }, + }, + }, + }, + }, +}; + +authDocs[`${apiPrefix}/sparcssso/callback`] = { + get: { + tags: [tag], + summary: "SPARCS SSO 로그인 페이지에서 다시 리다이렉트를 처리", + description: + "SPARCS SSO 로그인 페이지로부터 프론트로 다시 리다이렉트되었을 때 로그인을 시도.", + parameters: [ + { + in: "query", + name: "code", + schema: { + type: "string", + }, + description: "SSO server에서 부여한 유저 정보를 위한 code", + }, + { + in: "query", + name: "state", + schema: { + type: "string", + }, + description: "login 성공 여부 확인을 위한 state", + }, + ], + responses: { + 302: { + description: + "로그인 성공 후 페이지 URI로, 혹은 로그인 실패 URI로 리다이렉트", + headers: { + Location: { + type: "string", + description: "로그인 성공 후 페이지 URI, 혹은 로그인 실패 URI", + format: "uri", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Auth/sparcssso/callback : invalid request", + }, + }, + }, + }, + }, +}; + +authDocs[`${apiPrefix}/login/replace`] = { + get: { + tags: [tag], + summary: "replace 로그인 시도", + description: + "개발용으로 만들어진 replace 로그인을 시도합니다. Prod 환경일 경우 error를 반환합니다.", + responses: { + 400: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + example: "Auth/login/replace : Bad Request", + }, + }, + }, + }, + }, + }, + }, + }, +}; + +authDocs[`${apiPrefix}/logout`] = { + get: { + tags: [tag], + summary: "세션 삭제 및 사용자 로그아웃", + description: "세션 삭제 및 사용자 로그아웃", + parameters: [ + { + in: "query", + name: "redirect", + schema: { + type: "string", + }, + description: "로그아웃 후 리다이렉트 URI", + }, + ], + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + ssoLogoutUrl: { + type: "string", + description: "SSO 로그아웃 URL", + }, + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Auth/logout : internal server error", + }, + }, + }, + }, + }, +}; + +authDocs[`${apiPrefix}/app/token/login`] = { + get: { + tags: [tag], + summary: "Access token을 사용하여 로그인", + description: "앱에서 Access Token을 사용하여 로그인 시도", + parameters: [ + { + in: "query", + name: "accessToken", + schema: { + type: "string", + }, + description: "만료 되지 않은 유효한 JWT Access Token", + }, + { + in: "query", + name: "deviceToken", + schema: { + type: "string", + }, + description: "Device Token", + }, + ], + responses: { + 200: { + description: "성공 메세지", + content: { + "text/html": { + example: "success", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "invalid request", + }, + }, + }, + 401: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { + type: "string", + }, + }, + }, + examples: { + "Invalid token": { + value: { + message: "Invalid token", + }, + }, + "Expired token": { + value: { + message: "Expired token", + }, + }, + "Not Access token": { + value: { + message: "Not Access token", + }, + }, + "No corresponding user": { + value: { + message: "No corresponding user", + }, + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "server error", + }, + }, + }, + }, + }, +}; + +authDocs[`${apiPrefix}/app/token/refresh`] = { + get: { + tags: [tag], + summary: "만료된 Access Token 갱신", + description: "앱에서 Access Token을 Refresh Token을 활용하여 갱신", + parameters: [ + { + in: "query", + name: "accessToken", + schema: { + type: "string", + }, + description: "만료된 유효한 JWT Access Token", + }, + { + in: "query", + name: "refreshToken", + schema: { + type: "string", + }, + description: "만료되지 않은 유효한 JWT Refresh Token", + }, + ], + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + accessToken: { + type: "string", + description: "새로운 JWT Access Token", + }, + refreshToken: { + type: "string", + description: "새로운 Refresh Token", + }, + }, + }, + }, + }, + }, + 400: { + "text/html": { + example: "invalid request", + }, + }, + 401: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { + type: "string", + }, + }, + }, + examples: { + "Invalid access token": { + value: { + message: "Invalid access token", + }, + }, + "Invalid token": { + value: { + message: "Invalid token", + }, + }, + "Expired token": { + value: { + message: "Expired token", + }, + }, + "Not Refresh token": { + value: { + message: "Not Refresh token", + }, + }, + }, + }, + }, + }, + 501: { + content: { + "text/html": { + example: "server error", + }, + }, + }, + }, + }, +}; + +authDocs[`${apiPrefix}/app/device`] = { + post: { + tags: [tag], + summary: "기기의 Device Token을 데이터베이스에 등록", + description: "App 기기의 Device Token을 데이터베이스에 등록", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + accessToken: { + type: "string", + description: "만료 되지 않은 유효한 JWT Access Token", + }, + deviceToken: { + type: "string", + description: "Firebase 라이브러리에서 제공해주는 Device Token", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "성공 메세지", + content: { + "text/html": { + example: "success", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "invalid request", + }, + }, + }, + 401: { + content: { + "text/html": { + example: "unauthorized", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "server error", + }, + }, + }, + }, + }, + delete: { + tags: [tag], + summary: "기기의 Device Token을 데이터베이스에서 삭제", + description: "App 기기의 Device Token을 데이터베이스에 삭제", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + accessToken: { + type: "string", + description: "만료 되지 않은 유효한 JWT accessToken", + }, + deviceToken: { + type: "string", + description: "Firebase 라이브러리에서 제공해주는 DeviceToken", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "성공 메세지", + content: { + "text/html": { + example: "success", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "invalid request", + }, + }, + }, + 401: { + content: { + "text/html": { + example: "unauthorized", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "server error", + }, + }, + }, + }, + }, +}; + +module.exports = authDocs; diff --git a/src/routes/docs/auth.md b/src/routes/docs/auth.md deleted file mode 100644 index 799ddd01..00000000 --- a/src/routes/docs/auth.md +++ /dev/null @@ -1,180 +0,0 @@ -## `/auth` **(for production)** - -- 사용자 생성, 로그인, 로그아웃 등 사용자 상태 관리를 지원하는 API. -- SPARCS SSO를 사용하는 프로덕션 용 API. - -### `/sparcssso` **(GET)** - -- SPARCS SSO 로그인 페이지로 리다이렉트. - -#### URL Parameters - -- 없음 - -#### Response - -- SPARCS SSO 로그인 페이지로 리다이렉트. - -#### Errors - -- 없음 - -### `/sparcssso/callback` **(GET)** - -- SPARCS SSO 로그인 페이지로부터 다시 리다이렉트되었을 때 로그인을 시도함 - -#### URL Parameters - -- state -- code - -#### Response - -- DB에 존재하는 id면 로그인 진행 후 프론트엔드의 첫 페이지로 리다이렉트 -- DB에 존재하지 않는 id면 새로운 사용자를 만들고 로그인을 진행한 후 프론트엔드의 첫 페이지로 리다이렉트 - -#### Errors - -- 없음 - -### `/logout` **(GET)** - -- 세션을 삭제하여 사용자를 로그아웃시킴 - -#### URL Parameters - -- 없음 - -#### Response - -```javascript -{ - ssoLogoutUrl: String, // sso 로그아웃 url -} -``` - -#### Errors - -- 500 / "internal server error" - -### `/getToken` **(GET)** - -- 세션의 로그인 정보를 토큰으로 만들어 반환 - -#### URL Parameters - -- 없음 - -#### Response - -```javascript -{ - status: 200, - data: String, //JSON Web Token -} -``` - -#### Errors - -- 403 "not logged in" - -### `/app/token/generate` **(GET)** - -- SPARCSSSO로 로그인을 진행하고 로그인 정보를 담아 ACCESSTOKEN, REFRESHTOKEN을 반환 - -#### URL Parameters - -- None - -#### Response - -app's deep link -형식 APP_URI_SCHEME + ://login?accessToken=[ACCESSTOKEN]&refreshToken=[REFRESHTOKEN] - -#### Errors - -- 없음 - -### `/app/token/refresh` **(GET)** - -- 만료된 access token을 refresh token을 활용하여 갱신 - -#### URL Parameters - -- accessToken / 만료된 유효한 JWT Access Token이어야 함 -- refreshToken / 만료되지 않은 유효한 JWT Refresh Token 이어야 함. - -#### Response - -```javascript -{ - accessToken: [newAccessToken], // JSON Web Token - refreshToken: [newRefreshToken], //JSON Web Token -} -``` - -#### Errors - -- 401 / Invalid Access Token -- 401 / Invalid Token -- 401 / Expired Token -- 401 / Not Refresh Token -- 501 / Server Error - -### `/app/token/login` **(GET)** - -- access token을 사용하여 로그인 - -#### URL Parameters - -- accessToken / 만료 되지 않은 유효한 JWT accessToken 이어야 함 - -#### Response - -None / 세션 기록 - -#### Errors - -- 401 / Invalid Access Token -- 401 / Invalid Token -- 401 / Expired Token -- 401 / Not Refresh Token -- 501 / Server Error - -### `/app/device` **(POST)** - -- 기기의 deviceToken을 데이터베이스에 등록 - -#### URL Parameters - -- accessToken / 만료 되지 않은 유효한 JWT accessToken 이어야 함 -- deviceToken / Firebase 라이브러리에서 제공해주는 DeviceToken 이어야 함 - -#### Response - -None - -#### Errors - -- 400 / invalid request ( URL Parameters가 누락되어 있음 ) -- 401 / unauthorized ( 토큰이 유효하지 않음 ) -- 500 / server error - -### `/app/device` **(DELETE)** - -- 기기의 deviceToken을 데이터베이스에서 삭제 - -#### URL Parameters - -- accessToken / 만료 되지 않은 유효한 JWT accessToken 이어야 함 -- deviceToken / Firebase 라이브러리에서 제공해주는 DeviceToken 이어야 함 - -#### Response - -None - -#### Errors - -- 400 / invalid request ( URL Parameters가 누락되어 있음 ) -- 401 / unauthorized ( 토큰이 유효하지 않음 ) -- 500 / server error diff --git a/src/routes/docs/auth.replace.js b/src/routes/docs/auth.replace.js new file mode 100644 index 00000000..8246836b --- /dev/null +++ b/src/routes/docs/auth.replace.js @@ -0,0 +1,105 @@ +const loginReplacePage = require("../../views/loginReplacePage"); +const tag = "auth"; +const apiPrefix = "/auth(dev)"; + +const authReplaceDocs = {}; +authReplaceDocs[`${apiPrefix}/sparcssso`] = { + get: { + tags: [tag], + summary: "자체 로그인 페이지의 html 소스 반환", + description: `Dev 환경에서만 사용할 수 있는 API입니다.
+ SSO를 사용하지 않기 위해 자체 제작된 replace 페이지로 리다이렉트합니다.`, + responses: { + 200: { + description: "자체 로그인 페이지의 html 소스", + content: { + "text/plain": { + type: "string", + example: loginReplacePage, + }, + }, + }, + }, + }, +}; + +authReplaceDocs[`${apiPrefix}/login/replace`] = { + post: { + tags: [tag], + summary: "요청받은 정보로 로그인 시도", + description: `Dev 환경에서만 사용할 수 있는 API입니다.
+ DB에 존재하는 아이디라면 로그인 진행 후, 이전 페이지로 리다이렉트 합니다.
+ DB에 존재하지 않는 아이디일 경우, 새로운 사용자를 만들고 로그인 진행 후, 이전 페이지로 리다이렉트 합니다.`, + requestBody: { + description: "로그인을 할 사용자의 아이디", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { + type: "string", + }, + }, + }, + }, + }, + }, + responses: { + 302: { + description: + "로그인 성공 후 페이지 URI로, 혹은 로그인 실패 URI로 리다이렉트", + headers: { + Location: { + type: "string", + description: "로그인 성공 후 페이지 URI, 혹은 로그인 실패 URI", + format: "uri", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Auth/login/replace : invalid request", + }, + }, + }, + }, + }, +}; + +authReplaceDocs[`${apiPrefix}/logout`] = { + get: { + tags: [tag], + summary: "세션 삭제 및 사용자 로그아웃", + description: `Dev 환경에서만 사용할 수 있는 API입니다.
+ 세션을 삭제하여 사용자를 로그아웃 시킵니다.`, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + ssoLogoutUrl: { + type: "string", + description: "SSO 로그아웃 URL", + format: "uri", + }, + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Auth/logout : internal server error", + }, + }, + }, + }, + }, +}; + +module.exports = authReplaceDocs; diff --git a/src/routes/docs/auth.replace.md b/src/routes/docs/auth.replace.md deleted file mode 100644 index 1bca6957..00000000 --- a/src/routes/docs/auth.replace.md +++ /dev/null @@ -1,83 +0,0 @@ -## `/auth` **(for dev)** - -- 사용자 생성, 로그인, 로그아웃 등 사용자 상태 관리를 지원하는 API. -- SPARCS SSO 대신 자체 로그인 페이지를 사용하는 개발 전용 API. - -### `/sparcssso` **(GET)** - -- 자체 로그인 페이지의 html 소스를 반환. - -#### URL Parameters - -- 없음 - -#### Response - -- 로그인 페이지의 html 소스 - -#### Errors - -- 없음 - -### `/login/replace` **(POST)** - -- 요청받은 정보로 로그인을 시도 - -#### POST request form - -```javascript -{ - id: String, // 로그인을 할 사용자의 id -} -``` - -#### Response - -- DB에 존재하는 id면 로그인 진행 후 프론트엔드의 첫 페이지로 리다이렉트 -- DB에 존재하지 않는 id면 새로운 사용자를 만들고 로그인을 진행한 후 프론트엔드의 첫 페이지로 리다이렉트 - -#### Errors - -- 없음 - -### `/logout` **(GET)** - -- 세션을 삭제하여 사용자를 로그아웃시킴 - -#### URL Parameters - -- 없음 - -#### Response - -```javascript -{ - status: 200, - data: "logged out successfully", -} -``` - -#### Errors - -- 없음 - -### `/getToken` **(GET)** - -- 세션의 로그인 정보를 토큰으로 만들어 반환 - -#### URL Parameters - -- 없음 - -#### Response - -```javascript -{ - status: 200, - data: String, //JSON Web Token -} -``` - -#### Errors - -- 403 "not logged in" diff --git a/src/routes/docs/chats.js b/src/routes/docs/chats.js new file mode 100644 index 00000000..0aaa1c6d --- /dev/null +++ b/src/routes/docs/chats.js @@ -0,0 +1,513 @@ +const { objectId } = require("../../modules/patterns"); + +const tag = "chats"; +const apiPrefix = "/chats"; + +const chatsDocs = {}; +chatsDocs[`${apiPrefix}`] = { + post: { + tags: [tag], + summary: "가장 최근 채팅 가져오기", + description: "가장 최근에 도착한 60개의 채팅을 가져옵니다.", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅을 보내는 방의 id", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + value: true, + }, + }, + }, + }, + }, + }, + 403: { + content: { + "text/html": { + examples: { + "소켓 연결 오류": { value: "Chat/ : socket did not connected" }, + "유저가 방에 참여하지 않음": { + value: "Chat/ : user did not participated in the room", + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Chat/ : internal server error", + }, + }, + }, + }, + }, +}; + +chatsDocs[`${apiPrefix}/load/before`] = { + post: { + tags: [tag], + summary: "특정 시점 이전의 채팅 가져오기", + description: "lastMsgDate 이전에 도착한 60개의 채팅을 가져옵니다.", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅을 보내는 방의 id", + }, + lastMsgDate: { + type: "string", + format: "date-time", + description: "이전 채팅을 가져올 특정 시점", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + value: true, + }, + }, + }, + }, + }, + }, + 403: { + content: { + "text/html": { + examples: { + "소켓 연결 오류": { + value: "Chat/load/before : socket did not connected", + }, + "유저가 방에 참여하지 않음": { + value: + "Chat/load/before : user did not participated in the room", + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Chat/load/before : internal server error", + }, + }, + }, + }, + }, +}; + +chatsDocs[`${apiPrefix}/load/after`] = { + post: { + tags: [tag], + summary: "특정 시점 이후 채팅 가져오기", + description: "lastMsgDate 이후에 도착한 60개의 채팅을 가져옵니다.", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅을 보내는 방의 id", + }, + lastMsgDate: { + type: "string", + format: "date-time", + description: "이전 채팅을 가져올 특정 시점", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + value: true, + }, + }, + }, + }, + }, + }, + 403: { + content: { + "text/html": { + examples: { + "소켓 연결 오류": { + value: "Chat/load/after : socket did not connected", + }, + "유저가 방에 참여하지 않음": { + value: + "Chat/load/after : user did not participated in the room", + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Chat/load/after : internal server error", + }, + }, + }, + }, + }, +}; + +chatsDocs[`${apiPrefix}/send`] = { + post: { + tags: [tag], + summary: "채팅 요청 처리", + description: `채팅 요청을 처리합니다.
+ socker 통신을 통하여 같은 방에 있는 user들에게 이 채팅을 전송합니다.
+
+ 채팅 기록은 아래와 같이 구성됩니다.
+ + Chat { + roomId: ObjectId, //방의 objectId + type: String, // 메시지 종류 ("text": 일반 메시지, "s3img": S3에 업로드된 이미지, "in": 입장 메시지, "out": 퇴장 메시지, "payment": 결제 메시지, "settlement": 정산 완료 메시지, "account": 계좌 전송 메시지) + authorId: ObejctId, //작성자의 objectId + content: String, // 메시지 내용 (메시지 종류에 따라 포맷이 상이함) + time: String(ISO 8601), // ex) 2024-01-08T01:52:00.000Z + isValid: Boolean, // 클라이언트가 보낸 메시지가 유효한 지 여부. 클라이언트가 이미지를 업로드했을 때, 해당 이미지가 제대로 업로드됐는지 확인하기 전까지 이미지를 보여주지 않기 위해 사용됨. + } + `, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅을 보내는 방의 id", + }, + type: { + type: "string", + enum: [ + "text", + "s3img", + "in", + "out", + "payment", + "settlement", + "account", + "departure", + "arrival", + ], + description: `채팅 메시지의 유형
+ 일반 text의 경우 *text*, 사진의 경우 *s3img*
+ 기타 종류의 채팅의 경우(입장, 퇴장 메시지 등) 정해진 type의 채팅을 사용`, + }, + content: { + type: "string", + example: "안녕하세요~!", + description: "채팅 메세지의 본문", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + value: true, + }, + }, + }, + }, + }, + }, + 403: { + content: { + "text/html": { + examples: { + "소켓 연결 오류": { + value: "Chat/send : socket did not connected", + }, + "유저가 방에 참여하지 않음": { + value: "Chat/send : user did not participated in the room", + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Chat/send : internal server error", + }, + }, + }, + }, + }, +}; + +chatsDocs[`${apiPrefix}/read`] = { + post: { + tags: [tag], + summary: "채팅 읽은 시각 업데이트 요청", + description: `채팅 읽은 시각의 업데이트 요청을 처리합니다.
+ socket 통신을 통하여 같은 방에 있는 user들에게 업데이트를 요청합니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅을 보내는 방의 id", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + value: true, + }, + }, + }, + }, + }, + }, + 403: { + content: { + "text/html": { + example: "Chat/read : socket did not connected", + }, + }, + }, + 404: { + content: { + "text/html": { + example: "Chat/read : cannot find room info", + }, + }, + }, + 500: { + content: { + "text/html": { + examples: { + "소켓 이벤트 전송 오류": { + value: "Chat/read : failed to emit socket events", + }, + "기타 서버 오류": { + value: "Chat/read : internal server error", + }, + }, + }, + }, + }, + }, + }, +}; + +chatsDocs[`${apiPrefix}/uploadChatImg/getPUrl`] = { + post: { + tags: [tag], + summary: "채팅 이미지를 업로드할 수 있는 Presigned-url을 발급", + description: `채팅 이미지를 업로드 하기 위한 Presigned-url을 발급합니다.
+ 이미지 전송을 위해 \`s3img\` 형식의 chat document를 생성 후 저장하며,
+ presigned-url은 aws S3 api를 통해 생성됩니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅 이미지를 보내는 방의 id", + }, + type: { + type: "string", + enum: ["image/png", "image/jpg", "image/jpeg"], + description: "채팅 이미지의 파일 형식", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { + type: "string", + pattern: objectId.source, + description: "생성된 chat Document의 object id", + }, + url: { + type: "string", + example: "https://s3.{region}.amazonaws.com/{bucket-name}", + description: "taxi s3 url 주소", + }, + fields: { + type: "object", + properties: { + bucket: { + type: "string", + example: "bucket-name", + }, + "Content-Type": { + type: "string", + enum: ["image/png", "image/jpg", "image/jpeg"], + }, + key: { + type: "string", + pattern: `^chat-img/[a-fA-F\d]{24}$`, + }, + }, + description: "image의 key, type, bucket와 같은 정보", + }, + }, + }, + }, + }, + }, + 403: { + content: { + "text/html": { + example: "Chat/uploadChatImg/getPUrl : did not joined the chatting", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Chat/uploadChatImg/getPUrl : internal server error", + }, + }, + }, + }, + }, +}; + +chatsDocs[`${apiPrefix}/uploadChatImg/done`] = { + post: { + tags: [tag], + summary: "채팅 이미지 업로드 완료 여부 확인", + description: `채팅 이미지가 제대로 업로드 되었는지 확인합니다.
+ 이미지가 제대로 업로드 되었다면, socket 통신을 통해 채팅 이미지를 전송합니다.
+ 이때 채팅의 \`content\`에는 s3에 저장된 url을 나타내는 채팅의 object id를 넣어줍니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + description: "채팅 이미지를 보내는 방의 id", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + value: true, + }, + }, + }, + }, + }, + }, + 404: { + content: { + "text/html": { + example: "Chat/uploadChatImg/done : no corresponding chat", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Chat/uploadChatImg/getPUrl : internal server error", + }, + }, + }, + }, + }, +}; + +module.exports = chatsDocs; diff --git a/src/routes/docs/chats.md b/src/routes/docs/chats.md deleted file mode 100644 index 307d2def..00000000 --- a/src/routes/docs/chats.md +++ /dev/null @@ -1,99 +0,0 @@ -## `chats`: 채팅 시 발생하는 이벤트 정리 - -Taxi의 채팅 기능은 Socket.IO 라이브러리를 이용해 구현되어 있습니다. -클라이언트에서의 일반적인 Socket.IO 사용법은 [공식 문서](https://socket.io/docs/v4/client-socket-instance/)를 참조해주세요. -아래와 같은 채팅 이벤트들이 구현되어 있습니다. - -- `chats-join` -- `chats-receive` -- `chats-send` -- `chats-load` -- `chats-disconnected` (삭제해야 함) - -서버로부터 받은 모든 채팅 기록은 아래와 같은 자료형으로 구성되어 있습니다. - -```javascript -Chat { - roomId: ObjectId, //방의 objectId - type: String, // 메시지 종류("text": 일반 메시지, "in": 입장 메시지, "out": 퇴장 메시지, "s3img": S3에 업로드된 이미지, "payment": 결제 메시지, "settlement": 정산 완료 메시지) - authorId: ObejctId, //작성자의 objectId - content: String, // 메시지 내용(메시지 종류에 따라 포맷이 상이하며, 하단 참조) - time: String(ISO 8601), // ex) '2022-01-12T13:58:20.180Z' - isValid: Boolean, // 클라이언트가 보낸 메시지가 유효한 지 여부. 클라이언트가 이미지를 업로드했을 때, 해당 이미지가 제대로 업로드됐는지 확인하기 전까지 이미지를 보여주지 않기 위해 사용됨. -} -``` - -### 1. `chats-join` - -방에 새 사용자가 참여할 때 이 이벤트를 발생시키세요. -필요한 인자는 `roomId`입니다. - -- `roomId`: 방의 ObjectID (`String`), Socket.IO 서버와 연결을 시도할 때 사용자는 로그인이 되어 있어야 하며, 들어가려는 채팅방에 참여자로 참여하고 있어야 합니다. - -```javascript -const socket = io(server_address, { withCredentials: true }); -socket.emit("chats-join", roomId); -``` - -채팅방 접속이 정상적으로 완료되면, Socket.io 서버는 최근 30개의 메시지들(`Chat` 배열)을 전송합니다. - -```javascript -socket.on("chats-join", (chats) => { - // 최근 30개의 채팅 메시지 출력 - console.log(chats); -}); -``` - -### 2. `chats-send` - -채팅 메시지를 보낼 때 이 이벤트를 발생시키세요. -필요한 인자는 `roomId`와 `content`입니다. - -- `roomId`: 참여중인 방의 ObjectID(`String`) -- `content`: 보낼 텍스트(`String`) - -```javascript -socket.emit("chats-send", { roomId, content }); -``` - -메시지 전송이 성공/실패하면, Socket.IO 서버도 `chats-send` 이벤트를 발생시킵니다. - -```javascript -socket.on("chats-send", (response) => { - // 최근 30개의 채팅 메시지 출력 - console.log(response); -}); -``` - -`response`는 전송이 성공했을 경우 `{done: true}`, 실패했을 경우 `{err: true}`입니다. - -### 3. `chats-receive` - -이 이벤트는 서버나 다른 사용자가 채팅 메시지를 전송했을 때 발생합니다. 아래와 같이 `chat`에 접근하여 해당 메시지의 내용을 확인할 수 있습니다. - -```javascript -socket.on("chats-receive", (chat) => { - // 새로운 메시지 출력 - console.log(chat); -}); -``` - -### 4. `chats-load` - -과거 대화 목록을 더 불러오려면 이 이벤트를 발생시키세요. 필요한 인자는 `lastDate`와 `amount`(선택 사항) 입니다. - -- `lastDate`: 현재 클라이언트에서 불러온 채팅들 중 가장 오래된 것의 생성 시각. 서버는 이보다 먼저 생성된 메시지들을 반환합니다. ISO8601을 만족하는 `String`이어야 합니다. e.g.) `"2022-03-15T13:57:04.732Z"` -- `amount` (선택 사항): 불러올 과거 메시지의 수. 1~50의 자연수여야 하며, 입력하지 않은 경우 30개의 메시지를 가져옵니다. - -```javascript -socket.emit("chats-load", { lastDate: "2022-03-15T13:57:04.732Z", amount: 30 }); -``` - -`chats-load` 이벤트가 발생하면 서버는 클라이언트에 다시 `chats-load` 이벤트를 발생시켜 과거 채팅들(`Chat` 배열)을 보냅니다. - -```javascript -socket.on("chats-load", (chats) => { - // 과거 메시지들 출력 - console.log(chats); -}); -``` diff --git a/src/routes/docs/locations.js b/src/routes/docs/locations.js index 530788eb..34773482 100644 --- a/src/routes/docs/locations.js +++ b/src/routes/docs/locations.js @@ -1,52 +1,53 @@ -const locationsDocs = { - "/locations": { - get: { - tags: ["locations"], - summary: "출발지/도착지 정보 반환", - description: - "출발지/도착지로 사용 가능한 장소 목록 조회 및 요청 처리 당시 서버 시각 반환
\n (로그인된 상태에서만 접근 가능)", - responses: { - 200: { - description: - "서버에 저장된 location이 없을 경우, locations은 빈 배열", - content: { - "application/json": { - schema: { - type: "object", - properties: { - locations: { - type: "array", - description: "출발지/도착지로 사용 가능한 장소 목록", - items: { - type: "object", - properties: { - priority: { - type: "number", - }, - isValid: { - type: "boolean", - }, - _id: { - type: "string", - }, - koName: { - type: "string", - description: "장소의 한국어 명칭", - example: "택시승강장", - }, - enName: { - type: "string", - description: "장소의 영어 명칭", - example: "Taxi Stand", - }, +const tag = "locations"; +const apiPrefix = "/locations"; + +const locationsDocs = {}; +locationsDocs[`${apiPrefix}`] = { + get: { + tags: [tag], + summary: "출발지/도착지 정보 반환", + description: `출발지/도착지로 사용 가능한 장소 목록 조회 및 요청 처리 당시 서버 시각 반환
+ (로그인된 상태에서만 접근 가능)`, + responses: { + 200: { + description: "서버에 저장된 location이 없을 경우, locations은 빈 배열", + content: { + "application/json": { + schema: { + type: "object", + properties: { + locations: { + type: "array", + description: "출발지/도착지로 사용 가능한 장소 목록", + items: { + type: "object", + properties: { + priority: { + type: "number", + }, + isValid: { + type: "boolean", + }, + _id: { + type: "string", + }, + koName: { + type: "string", + description: "장소의 한국어 명칭", + example: "택시승강장", + }, + enName: { + type: "string", + description: "장소의 영어 명칭", + example: "Taxi Stand", }, }, }, - serverTime: { - type: "string", - format: "date-time", - description: "요청 처리 당시 서버 시각", - }, + }, + serverTime: { + type: "string", + format: "date-time", + description: "요청 처리 당시 서버 시각", }, }, }, diff --git a/src/routes/docs/logininfo.js b/src/routes/docs/logininfo.js index ec424f63..7c447760 100644 --- a/src/routes/docs/logininfo.js +++ b/src/routes/docs/logininfo.js @@ -1,85 +1,94 @@ -const logininfoDocs = { - "/logininfo": { - get: { - tags: ["logininfo"], - summary: "사용자 정보 반환", - description: "로그인되어 있는 사용자의 정보를 반환", - responses: { - 200: { - description: - "사용자의 로그인 세션이 유효한 경우, 현재 로그인된 사용자의 정보를 반환,
\n 세션이 유효하지 않은 경우, 빈 오브젝트를 반환", - content: { - "application/json": { - schema: { - type: "object", - properties: { - oid: { - type: "string", - }, - id: { - type: "string", - description: "사용자 id", - }, - name: { - type: "string", - description: "사용자 이름", - }, - nickname: { - type: "string", - }, - withdraw: { - type: "boolean", - }, - ban: { - type: "boolean", - }, - joinat: { - type: "string", - format: "date-time", - }, - agreeOnTermsOfService: { - type: "boolean", - }, - subinfio: { - type: "object", - properties: { - kaist: { - type: "string", - description: "KAIST 학번(8자리)", - minLength: 8, - maxLength: 8, - example: "20190052", - }, - sparcs: { - type: "string", - }, - facebook: { - type: "string", - }, - twitter: { - type: "string", - }, +const { objectId } = require("../../modules/patterns"); + +const tag = "logininfo"; +const apiPrefix = "/logininfo"; + +const logininfoDocs = {}; +logininfoDocs[`${apiPrefix}`] = { + get: { + tags: [tag], + summary: "사용자 정보 반환", + description: "로그인되어 있는 사용자의 정보를 반환", + responses: { + 200: { + description: + "사용자의 로그인 세션이 유효한 경우, 현재 로그인된 사용자의 정보를 반환,
\n 세션이 유효하지 않은 경우, 빈 오브젝트를 반환", + content: { + "application/json": { + schema: { + type: "object", + properties: { + oid: { + type: "string", + pattern: objectId.source, + }, + id: { + type: "string", + description: "사용자 id", + }, + name: { + type: "string", + description: "사용자 이름", + }, + nickname: { + type: "string", + }, + withdraw: { + type: "boolean", + }, + phoneNumber: { + type: "string", + description: "사용자 전화번호", + }, + ban: { + type: "boolean", + }, + joinat: { + type: "string", + format: "date-time", + }, + agreeOnTermsOfService: { + type: "boolean", + }, + subinfo: { + type: "object", + properties: { + kaist: { + type: "string", + description: "KAIST 학번(8자리)", + minLength: 8, + maxLength: 8, + example: "20190052", + }, + sparcs: { + type: "string", + }, + facebook: { + type: "string", + }, + twitter: { + type: "string", }, }, - email: { - type: "string", - example: "geon6757@kaist.ac.kr", - }, - profileImgUrl: { - type: "string", - }, - account: { - type: "string", - }, - deviceToken: { - type: "string", - description: - "클라이언트의 디바이스 토큰, 세션에 저장되어 있지 않은 경우 undefined", - }, - deviceType: { - type: "string", - enum: ["web", "app"], - }, + }, + email: { + type: "string", + example: "geon6757@kaist.ac.kr", + }, + profileImgUrl: { + type: "string", + }, + account: { + type: "string", + }, + deviceToken: { + type: "string", + description: + "클라이언트의 디바이스 토큰, 세션에 저장되어 있지 않은 경우 undefined", + }, + deviceType: { + type: "string", + enum: ["web", "app"], }, }, }, diff --git a/src/routes/docs/reports.js b/src/routes/docs/reports.js index 0210197e..a11933ee 100644 --- a/src/routes/docs/reports.js +++ b/src/routes/docs/reports.js @@ -1,79 +1,86 @@ -const reportsDocs = { - "/reports/create": { - post: { - tags: ["reports"], - summary: "신고 작성", - description: "주어진 유저를 전달된 사유로 신고함", - requestBody: { - description: "Update an existent user in the store", - content: { - "application/json": { - schema: { - $ref: "#/components/schemas/createHandler", - }, +const { objectId } = require("../../modules/patterns"); + +const tag = "reports"; +const apiPrefix = "/reports"; + +const reportsDocs = {}; +reportsDocs[`${apiPrefix}/create`] = { + post: { + tags: [tag], + summary: "신고 작성", + description: "주어진 유저를 전달된 사유로 신고함", + requestBody: { + description: "Update an existent user in the store", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/createHandler", }, }, }, - responses: { - 200: { - description: "report successful", - content: { - "text/plain": { - schema: { - type: "string", - example: "report successful", - }, + }, + responses: { + 200: { + description: "report successful", + content: { + "text/plain": { + schema: { + type: "string", + example: "report successful", }, }, }, - 500: { - description: "internal server error", - content: { - "text/plain": { - schema: { - type: "string", - example: "internal server error", - }, - }, + }, + 500: { + description: "internal server error", + content: { + "text/html": { + example: "User/report : internal server error", }, }, }, }, }, - "/reports/searchByUser": { - get: { - tags: ["reports"], - summary: "신고 내역 반환", - description: - "로그인된 사용자의 신고한 내역과, 신고받은 내역을 반환한다
1000개의 limit이 있다.", - responses: { - 200: { - description: "신고된 내역과 신고 받은 내역", - content: { - "application/json": { - schema: { - type: "object", - properties: { - reporting: { - type: "array", +}; + +reportsDocs[`${apiPrefix}/searchByUser`] = { + get: { + tags: [tag], + summary: "신고 내역 반환", + description: + "로그인된 사용자의 신고한 내역과, 신고받은 내역을 반환한다
1000개의 limit이 있다.", + responses: { + 200: { + description: "신고된 내역과 신고 받은 내역", + content: { + "application/json": { + schema: { + type: "object", + properties: { + reporting: { + type: "array", + items: { + type: "string", + pattern: objectId.source, }, - reported: { - type: "array", + }, + reported: { + type: "array", + items: { + type: "string", + pattern: objectId.source, }, }, }, }, }, }, - 500: { - description: "internal server error", - content: { - "text/plain": { - schema: { - type: "string", - example: "internal server error", - }, - }, + }, + 500: { + description: "internal server error", + content: { + "text/html": { + example: "report/searchByUser : internal server error", }, }, }, diff --git a/src/routes/docs/reportsSchema.js b/src/routes/docs/reportsSchema.js deleted file mode 100644 index 841918cf..00000000 --- a/src/routes/docs/reportsSchema.js +++ /dev/null @@ -1,32 +0,0 @@ -const reportsSchema = { - createHandler: { - type: "object", - required: ["reportedId", "type", "time", "roomId"], - properties: { - reportedId: { - type: "string", - pattern: "^[a-fA-F\\d]{24}$", - }, - type: { - type: "string", - enum: ["no-settlement", "no-show", "etc-reason"], - }, - etcDetail: { - type: "string", - maxLength: 30, - default: "", - }, - time: { - type: "string", - format: "date-time", - }, - roomId: { - type: "string", - pattern: "^[a-fA-F\\d]{24}$", - }, - }, - errorMessage: "validation: bad request", - }, -}; - -module.exports = reportsSchema; diff --git a/src/routes/docs/rooms.js b/src/routes/docs/rooms.js new file mode 100644 index 00000000..710bf649 --- /dev/null +++ b/src/routes/docs/rooms.js @@ -0,0 +1,835 @@ +const { objectId, room } = require("../../modules/patterns"); + +const tag = "rooms"; +const apiPrefix = "/rooms"; + +const roomsDocs = {}; + +roomsDocs[`${apiPrefix}/create`] = { + post: { + tags: [tag], + summary: "방 생성", + description: `방을 생성합니다. 한 유저당 최대 5개의 진행중인 방에 참여할 수 있습니다.
`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + pattern: room.name.source, + description: `방 이름
+ 1~50 글자로 구성되며 영어 대소문자, 숫자, 한글, 특정 특수기호("-", ",", ".", "?", "!", "_")만 가능`, + example: "함께 타는 택시의 여유", + }, + from: { + type: "string", + pattern: objectId.source, + description: "출발지 location Document의 ObjectId", + }, + to: { + type: "string", + pattern: objectId.source, + description: "도착지 location Document의 ObjectId", + }, + time: { + type: "string", + format: "date-time", + description: "방 출발 시각. 현재 이후여야 함.", + }, + maxPartLength: { + type: "integer", + minimum: 2, + maximum: 4, + description: "방의 최대 인원 수", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "생성 완성된 방 목록", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 400: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + examples: { + "출발지와 도착지가 같음": { + value: { + error: "Rooms/create : locations are same", + }, + }, + "현재로부터 2주일보다 이후의 방을 생성": { + value: { + error: + "Rooms/create : cannot over 2 weeks on the basis of current Date", + }, + }, + "설정된 출발 시각 이후에 방을 생성": { + value: { + error: "Rooms/create : invalid timestamp", + }, + }, + "존재하지 않는 location Document를 입력": { + value: { + error: "Rooms/create : no corresponding locations", + }, + }, + "사용자가 참여하는 진행 중 방이 5개 이상": { + value: { + error: "Rooms/create : participating in too many rooms", + }, + }, + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/create : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/publicInfo`] = { + get: { + tags: [tag], + summary: "정산 정보를 제외한 방 세부 사항 반환", + description: + "특정 id 방의 정산 정보를 제외한 세부사항을 반환합니다. 로그인을 하지 않아도 접근 가능합니다.", + parameters: [ + { + in: "query", + name: "id", + schema: { + type: "string", + pattern: objectId.source, + }, + description: "찾고 싶은 방의 Object id", + }, + ], + responses: { + 200: { + description: "방의 세부 정보가 담긴 room Object", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 404: { + description: "해당 id가 존재하지 않음", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/publicInfo : id does not exist", + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/publicInfo : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/info`] = { + get: { + tags: [tag], + summary: "방 세부 사항 반환", + description: "유저가 참여한 방의 세부사항을 반환합니다.", + parameters: [ + { + in: "query", + name: "id", + schema: { + type: "string", + pattern: objectId.source, + }, + description: "찾고 싶은 방의 Object id", + }, + ], + responses: { + 200: { + description: "방의 세부 정보가 담긴 room Object", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 404: { + description: "해당 id가 존재하지 않음", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/info : id does not exist", + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/info : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/join`] = { + post: { + tags: [tag], + summary: "진행 중인 방에 참여", + description: `room의 ID를 받아 해당 room의 참가자 목록에 요청을 보낸 사용자를 추가합니다.
+ 하나의 User는 최대 5개의 진행중인 방에 참여할 수 있습니다.
+ 아직 정원이 차지 않은 방과 아직 출발하지 않은 방에만 참여할 수 있습니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "방의 세부 정보가 담긴 room Object", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 400: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + examples: { + "사용자가 참여하는 진행 중 방이 5개 이상": { + value: { + error: "Rooms/join : participating in too many rooms", + }, + }, + "입력한 시간의 방이 이미 출발함": { + value: { + error: "Rooms/join : The room has already departed", + }, + }, + "방의 인원이 모두 찼음": { + value: { + error: "Rooms/join : The room is already full", + }, + }, + }, + }, + }, + }, + 404: { + description: "해당 id를 가진 방이 존재하지 않음", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/join : no corresponding room", + }, + }, + }, + }, + 409: { + description: "사용자가 이미 참여중임", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/join : {userID} Already in room", + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "text/html": { + example: "Rooms/join : internal server error", + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/abort`] = { + post: { + tags: [tag], + summary: "참여 중인 방에서 퇴장", + description: `room의 ID를 받아 해당 room의 참가자 목록에서 요청을 보낸 사용자를 삭제합니다.
+ 출발했지만 정산이 완료되지 않은 방에서는 나갈 수 없습니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 400: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + examples: { + "잘못된 userId를 포함한 요청임": { + value: { + error: "Rooms/abort : Bad request", + }, + }, + "정산이 되지 않은 출발한 방은 나갈 수 없음": { + value: { + error: + "Rooms/abort : cannot exit room. Settlement is not done", + }, + }, + }, + }, + }, + }, + 403: { + description: "사용자가 해당 방의 구성원이 아님", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/abort : did not joined the room", + }, + }, + }, + }, + 404: { + description: "해당 id를 가진 방이 존재하지 않음", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/abort : no corresponding room", + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/abort : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/search`] = { + get: { + tags: [tag], + summary: "방 검색", + description: `출발지/도착지/날짜를 받아 조건에 맞는 방을 검색합니다.
+ 조건에 맞는 방이 있을 경우, 방들의 정보를 반환하고 없다면 빈 배열을 반환합니다.
+ 로그인을 하지 않아도 접근 가능합니다.`, + parameters: [ + { + in: "query", + name: "name", + schema: { + type: "string", + }, + description: `검색할 방의 이름
+ 주어진 경우 해당 텍스트가 방의 이름에 포함된 방들만 반환.
+ 주어지지 않은 경우 임의의 이름을 가지는 방들을 검색.`, + }, + { + in: "query", + name: "from", + schema: { + type: "string", + pattern: objectId.source, + }, + description: `출발지 Document의 ObjectId
+ 주어진 경우 출발지가 일치하는 방들만 반환.
+ 주어지지 않은 경우 임의의 출발지를 가지는 방들을 검색.`, + }, + { + in: "query", + name: "to", + schema: { + type: "string", + pattern: objectId.source, + }, + description: `도착지 Document의 ObjectId
+ 주어진 경우 도착지가 일치하는 방들만 반환.
+ 주어지지 않은 경우 임의의 도착지를 가지는 방들을 검색.`, + }, + { + in: "query", + name: "time", + schema: { + type: "string", + format: "date-time", + }, + description: `출발 시각
+ 주어진 경우 주어진 시간부터 주어진 시간부터 그 다음에 찾아오는 오전 5시 전에 출발하는 방들만 반환.
+ 주어지지 않은 경우 현재 시각부터 그 다음으로 찾아오는 오전 5시 전까지의 방들을 반환.`, + }, + { + in: "query", + name: "withTime", + schema: { + type: "boolean", + }, + description: `검색 옵션에 시간 옵션이 포함되어 있는지 여부.
+ false이고 검색하는 날짜가 오늘 이후인 경우 검색하는 시간을 0시 0분 0초로 설정함.`, + }, + { + in: "query", + name: "maxPartLength", + schema: { + type: "integer", + }, + description: ` 방의 최대 인원 수.
+ 주어진 경우 최대 인원 수가 일치하는 방들만 반환.
+ 주어지지 않은 경우 임의의 최대 인원 수를 가지는 방들을 검색.`, + }, + { + in: "query", + name: "isHome", + schema: { + type: "boolean", + }, + description: `홈 페이지 검색인지 여부
+ true인 경우 검색 날짜 범위를 7일로 설정.
+ false인 경우 검색 날짜 범위를 14일로 설정.
`, + }, + ], + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "array", + items: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + }, + 400: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + examples: { + "출발지와 도착지가 같음": { + value: { + error: "Rooms/search : Bad request", + }, + }, + "출발/도착지가 존재하지 않는 장소": { + value: { + error: "Rooms/search : no corresponding locations", + }, + }, + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/search : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/searchByUser`] = { + get: { + tags: [tag], + summary: "사용자가 참여 중인 방 검색", + description: `로그인 된 사용자가 참여 중인 방을 검색합니다.
+ 정산 완료 여부 기준으로 진행 중인 방과 완료된 방을 \`ongoing\`과 \`done\`으로 각각 분리하여 응답을 전송합니다.
+ (\`ongoing\`은 \`isOver\`이 flase인 방, \`done\`은 \`isOver\`이 true인 방을 의미합니다.)`, + parameters: {}, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + ongoing: { + type: "array", + items: { + $ref: "#/components/schemas/room", + }, + }, + done: { + type: "array", + items: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/searchByUser : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/commitPayment`] = { + post: { + tags: [tag], + summary: "방 결제 처리", + description: `해당 방에 요청을 보낸 유저를 결제자로 처리합니다.
+ 이미 출발한 방에 대해서만 요청을 처리합니다.
+ 방의 \`part\` 배열에서 요청을 보낸 유저의 \`isSettlement\` 속성은 \`paid\`로 설정됩니다.
+ 나머지 유저들의 \`isSettlement\` 속성을 \`send-required\`로 설정합니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "결제 정보가 수정된 방의 세부 정보가 담긴 room Object", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 404: { + description: `잘못된 방 요청
+ (사용자가 참여 중인 방이 아니거나, 이미 다른 사람이 결제자이거나, 아직 방이 출발하지 않은 경우)`, + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/:id/commitPayment : cannot find settlement info", + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/:id/commitPayment : internal server error", + }, + }, + }, + }, + }, + }, +}; + +roomsDocs[`${apiPrefix}/commitSettlement`] = { + post: { + tags: [tag], + summary: "방 정산 완료 처리", + description: `해당 방에 요청을 보낸 유저를 정산 완료로 처리합니다.
+ 방의 \`part\` 배열에서 요청을 보낸 유저의 \`isSettlement\` 속성은 \`send-required\`에서 \`sent\`로 변경합니다.
+ 방의 참여한 유저들이 모두 정산완료를 하면 방의 \`isOver\` 속성이 \`true\`로 변경되며, 과거 방으로 취급됩니다.`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + roomId: { + type: "string", + pattern: objectId.source, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "결제 정보가 수정된 방의 세부 정보가 담긴 room Object", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/room", + }, + }, + }, + }, + 404: { + description: `잘못된 방 요청
+ (사용자가 참여 중인 방이 아니거나, 사용자가 결제를 했거나 이미 정산한 경우)`, + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/:id/settlement : cannot find settlement info", + }, + }, + }, + }, + 500: { + description: "내부 서버 오류", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { + type: "string", + }, + }, + }, + example: { + error: "Rooms/:id/settlement : internal server error", + }, + }, + }, + }, + }, + }, +}; + +module.exports = roomsDocs; diff --git a/src/routes/docs/schemas/reportsSchema.js b/src/routes/docs/schemas/reportsSchema.js new file mode 100644 index 00000000..d208dbb7 --- /dev/null +++ b/src/routes/docs/schemas/reportsSchema.js @@ -0,0 +1,19 @@ +const { z } = require("zod"); +const { zodToSchemaObject } = require("../utils"); +const { objectId } = require("../../../modules/patterns"); + +const reportsZod = { + createHandler: z + .object({ + reportedId: z.string().regex(objectId), + type: z.enum(["no-settlement", "no-show", "etc-reason"]), + etcDetail: z.string().max(30).default(""), + time: z.string().datetime(), + roomId: z.string().regex(objectId), + }) + .partial({ etcDetail: true }), +}; + +const reportsSchema = zodToSchemaObject(reportsZod); + +module.exports = { reportsSchema, reportsZod }; diff --git a/src/routes/docs/schemas/roomsSchema.js b/src/routes/docs/schemas/roomsSchema.js new file mode 100644 index 00000000..39dcd8bf --- /dev/null +++ b/src/routes/docs/schemas/roomsSchema.js @@ -0,0 +1,36 @@ +const { z } = require("zod"); +const { zodToSchemaObject } = require("../utils"); +const { objectId, room } = require("../../../modules/patterns"); + +const roomsZod = {}; +roomsZod["part"] = z + .object({ + _id: z.string().regex(objectId), + name: z.string(), + nickname: z.string(), + profileImageUrl: z.string(), + isSettlement: z + .enum(["not-departed", "paid", "send-required", "sent"]) + .default("not-departed"), + readAt: z.string().datetime(), + }) + .partial({ isSettlement: true }); + +roomsZod["room"] = z + .object({ + name: z.string().regex(room.name), + from: z.string().regex(objectId), + to: z.string().regex(objectId), + time: z.string().datetime(), + part: z.array(roomsZod["part"]), + madeat: z.string().datetime(), + maxPartLength: z.number().lte(4), + settlementTotal: z.number().default(0), + isOver: z.boolean(), + isDeparted: z.boolean(), + }) + .partial({ settlementTotal: true, isOver: true }); + +const roomsSchema = zodToSchemaObject(roomsZod); + +module.exports = { roomsSchema, roomsZod }; diff --git a/src/routes/docs/swaggerDocs.js b/src/routes/docs/swaggerDocs.js index cf52ff2a..62639dfa 100644 --- a/src/routes/docs/swaggerDocs.js +++ b/src/routes/docs/swaggerDocs.js @@ -1,7 +1,35 @@ -const reportsSchema = require("./reportsSchema"); +const { reportsSchema } = require("./schemas/reportsSchema"); +const { roomsSchema } = require("./schemas/roomsSchema"); const reportsDocs = require("./reports"); const logininfoDocs = require("./logininfo"); const locationsDocs = require("./locations"); +const authDocs = require("./auth"); +const authReplaceDocs = require("./auth.replace"); +const usersDocs = require("./users"); +const roomsDocs = require("./rooms"); +const chatsDocs = require("./chats"); +const { port, nodeEnv } = require("../../../loadenv"); + +const serverList = [ + { + url: `http://localhost:${port}`, + description: "local api server", + development: true, + production: false, + }, + { + url: "https://taxi.sparcs.org/api", + description: "taxi main api server", + development: true, + production: true, + }, + { + url: "https://taxi.dev.sparcs.org/api", + description: "taxi dev api server", + development: true, + production: false, + }, +]; const swaggerDocs = { openapi: "3.0.3", @@ -10,6 +38,7 @@ const swaggerDocs = { version: "1.0.0", }, basePath: "/", + servers: serverList.filter((server) => server[nodeEnv]), tags: [ { name: "locations", @@ -23,6 +52,22 @@ const swaggerDocs = { name: "reports", description: "사용자 신고 및 신고 기록 조회", }, + { + name: "auth", + description: "사용자 생성, 로그인, 로그아웃 등 사용자 상태 관리 지원", + }, + { + name: "users", + description: "유저 계정 정보 수정 및 조회", + }, + { + name: "rooms", + description: "방 생성/수정/삭제/조회 및 관리 지원", + }, + { + name: "chats", + description: "채팅 시 발생하는 이벤트 정리", + }, ], consumes: ["application/json"], produces: ["application/json"], @@ -30,10 +75,16 @@ const swaggerDocs = { ...reportsDocs, ...logininfoDocs, ...locationsDocs, + ...usersDocs, + ...authDocs, + ...authReplaceDocs, + ...chatsDocs, + ...roomsDocs, }, components: { schemas: { ...reportsSchema, + ...roomsSchema, }, }, }; diff --git a/src/routes/docs/users.js b/src/routes/docs/users.js new file mode 100644 index 00000000..3cd8aa96 --- /dev/null +++ b/src/routes/docs/users.js @@ -0,0 +1,332 @@ +const tag = "users"; +const apiPrefix = "/users"; + +const usersDocs = {}; +usersDocs[`${apiPrefix}/agreeOnTermsOfService`] = { + post: { + tags: [tag], + summary: "이용 약관에 동의", + description: + "요청을 보낸 유저의 약관 동의 여부를 동의함으로 변경합니다. 철회는 불가능합니다.", + responses: { + 200: { + content: { + "text/html": { + example: + "Users/agreeOnTermsOfService : agree on Terms of Service successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Users/agreeOnTermsOfService : already agreed", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Users/agreeOnTermsOfService : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/getAgreeOnTermsOfService`] = { + get: { + tags: [tag], + summary: "이용 약관 동의 여부 전송", + description: + "요청을 보낸 유저의 이용 약관 동의 여부를 json 형태로 전송합니다.", + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + agreeOnTermsOfService: { + type: "boolean", + description: "유저의 이용 약관 동의 여부", + example: true, + }, + }, + }, + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Users/getAgreeOnTermsOfService : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/editNickname`] = { + post: { + tags: [tag], + summary: "유저의 닉네임 변경", + description: "유저의 닉네임을 요청한 닉네임으로 변경합니다.", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + nickname: { + type: "string", + example: "끈질긴 열과 분자의 이동", + description: "유저의 새 닉네임", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "text/html": { + example: "Users/editNickname : edit user nickname successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Users/editNickname : such user id does not exist", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Users/editNickname : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/resetNickname`] = { + get: { + tags: [tag], + summary: "유저 닉네임 기본값으로 재설정", + description: "유저의 별명을 기본값(랜덤한 닉네임)으로 초기화합니다", + responses: { + 200: { + content: { + "text/html": { + example: "Users/resetNickname : reset user nickname successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Users/resetNickname : such user does not exist", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Users/resetNickname : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/editAccount`] = { + post: { + tags: [tag], + summary: "유저의 계좌 번호 변경", + description: "유저의 계좌 번호를 요청한 계좌 번호로 변경합니다.", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + account: { + type: "string", + description: "유저의 새 계좌 번호", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "text/html": { + example: "Users/editAccount : edit user account successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Users/editAccount : such user id does not exist", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Users/editAccount : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/editProfileImg/getPUrl`] = { + post: { + tags: [tag], + summary: "프로필 이미지 업로드를 위한 presigned-url 발급", + description: `유저의 프로필 이미지는 AWS S3에서 관리됩니다. 변경할 프로필을 업로드 하기 위한 주소인 presigned-url을 발급합니다.
+
+ **프로필 사진은 아래 규칙을 만족해야 합니다:**
+ 1. 파일 형식은 image/png, image/jpg, image/jpeg 중 하나
+ 2. 파일 크기는 최대 50 MB`, + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + type: { + type: "string", + description: "업로드할 이미지 type", + example: "image/png", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + url: { + type: "string", + description: "이미지 업로드를 위한 presigned-url", + }, + fields: { + type: "object", + properties: { + "Content-Type": { + type: "string", + description: "이미지의 type", + }, + key: { + type: "string", + description: "이미지의 S3 파일 경로", + }, + }, + description: "업로드 파일의 type 및 key 정보", + }, + }, + }, + }, + }, + }, + 500: { + contnet: { + "text/html": { + example: "Users/editProfileImg/getPUrl : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/editProfileImg/done`] = { + get: { + tags: [tag], + summary: "프로필 이미지 정상 업로드 여부 확인", + description: `프로필 이미지가 S3에 정상적으로 업로드 되었는지 확인합니다.
+ 정상적으로 확인 되었다면, 유저의 \`profileImageUrl\` 정보를 새 프로필 이미지 파일명으로 업데이트 합니다.`, + responses: { + 200: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + result: { + type: "boolean", + description: "정상적인 업로드 성공 여부", + }, + profileImageUrl: { + type: "string", + description: + "새 프로필 이미지 파일명 (업로드 실패 시 `undefined`)", + }, + }, + }, + }, + }, + }, + 500: { + contnet: { + "text/html": { + example: "Users/editProfileImg/done : internal server error", + }, + }, + }, + }, + }, +}; + +usersDocs[`${apiPrefix}/resetProfileImg`] = { + get: { + tags: [tag], + summary: "유저 프로필 사진 기본값으로 재설정", + description: "유저의 프로필 사진을 기본값(랜덤한 사진)으로 초기화합니다", + responses: { + 200: { + content: { + "text/html": { + example: + "Users/resetProfileImg : reset user profile image successful", + }, + }, + }, + 400: { + content: { + "text/html": { + example: "Users/resetProfileImg : such user does not exist", + }, + }, + }, + 500: { + content: { + "text/html": { + example: "Users/resetProfileImg : internal server error", + }, + }, + }, + }, + }, +}; + +module.exports = usersDocs; diff --git a/src/routes/docs/users.md b/src/routes/docs/users.md deleted file mode 100755 index 6b46963b..00000000 --- a/src/routes/docs/users.md +++ /dev/null @@ -1,305 +0,0 @@ -## `/users` - -- 사용자 정보 조회 및 수정 기능을 지원하는 API. -- 로그인된 상태에서만 접근 가능 -- 사용자를 반환할 경우 그 type은 다음과 같다. - -```javascript -User { - name: String, - nickname: String, // 3글자 이상 25글자 이하로 구성되며 영어 대소문자, 한글, " ", 0~9, "-", "_" 으로만 이루어져야 함. - id: String, - withdraw: Boolean, - ban: Boolean, - joinat: Date, - agreeOnTermsOfService: { type: Boolean, default: false }, //이용약관 동의 여부 - room: [Room], - subinfo: { - kaist: String, - sparcs: String, - facebook: String, - twitter: String, - }, - email: String, - __v: Number, -} -``` - -### `/agreeOnTermsOfService` **(POST)** - -- 이용 약관에 동의함 (철회 불가) - -#### URL Parameters, Request JSON form - -- 없음 - -#### Response - -- 200 "agree on Terms of Service successful" -- 400 "already agreed" -- 500 "internal server error" - -### `/getAgreeOnTermsOfService` **(GET)** - -- 이용 약관 동의 여부를 가져옴 - -#### URL Parameters, Request JSON form - -- 없음 - -#### Response - -```javascript -{ - agreeOnTermsOfService: Boolean -}, -``` - -### `/editNickname` **(POST)** - -- 해당 사용자의 닉네임을 새로 설정함. -- 새로운 닉네임은 상술한 규칙을 만족해야 함. - -#### URL Parameters - -- user_id : 사용자의 SPARCS SSO ID - -#### request JSON form - -```javascript -{ - nickname: String, // 새 닉네임 -} -``` - -#### Response - -```javascript -{ - status: 200, - data: "edit user nickname successful", -} -``` - -#### Errors - -- 400 "wrong nickname" -- 400 "such user id does not exist" -- 403 "not logged in" -- 500 "internal server error" - -### `/editProfileImg/getPUrl` **(POST)** - -- 프로필 이미지를 업로드할 수 있는 Presigned-url을 발급합니다. -- 프로필 사진은 아래 규칙을 만족해야 함. - 1. 파일 형식은 image/png, image/jpg, image/jpeg 중 하나 - 2. 파일 크기는 최대 50 MB - -#### URL Parameters - -- type : 업로드할 이미지 type - -#### request JSON form - -```javascript -{ - url: String, // pre-signed url - fields: Object, // post fields -} -``` - -#### Errors - -- 500 "internal server error" - -### `/editProfileImg/done` **(GET)** - -- 프로필 이미지가 S3에 정상적으로 업로드가 되었는지 확인합니다. - -#### URL Parameters - -- 없음 - -#### request JSON form - -```javascript -{ - result: Boolean, // 정상적으로 업로드 되었으면 true - profileImageUrl?: user._id, // 정상적으로 업로드 되었으면 새 프로필 이미지 파일명, 그렇지 않은 경우 undefined -} -``` - -#### Errors - -- 500 "internal server error" - -### `/` **(GET)** (for dev) - -- 사용자 전체 리스트를 반환함. - -#### URL Parameters - -- 없음 - -#### Response - -```javascript -{ - status: 200, - data: User[], // 전체 사용자 리스트 -} -``` - -#### Errors - -- 없음 - -### `/rooms` **(GET)** (for dev) - -- 사용자의 방 리스트를 반환함. - -#### URL Parameters - -- id : User document의 id - -#### Response - -```javascript -{ - id: String, // 요청된 id - rooms: Room[], // 방 리스트 -} -``` - -#### Errors - -- 404 "user/rooms : such id does not exist" -- 500 "user/rooms : internal server error" - -### `/:id` **(GET)** (for dev) - -- 사용자 정보를 반환함. - -#### URL Parameters - -- id : User document의 id - -#### Response - -```javascript -{ - status: 200, - data: User, //id에 대응되는 사용자 정보 -} -``` - -#### Errors - -- 404 "user/:id : such id does not exist" -- 500 "user/:id : internal server error" - -### `/:id/edit` **(POST)** (for dev) - -- 새 사용자 정보를 받아 업데이트함. - -#### URL Parameters - -- id : User document의 id - -#### request JSON form - -```javascript -User; //수정할 사용자 정보 -``` - -#### Response - -```javascript -{ - status: 200, - data: "edit user successful", -} -``` - -#### Errors - -- 400 "such id does not exist" - -### `/:id/ban` **(GET)** (for dev) - -- 해당 사용자를 밴함. - -#### URL Parameters - -- id : User document의 id - -#### Response - -```javascript -{ - status: 200, - data: "The user banned successfully", -} -``` - -#### Errors - -- 400 "The user does not exist" -- 409 "The user is already banned" -- 500 "User/ban : Error 500" - -### `/:id/unban` **(GET)** (for dev) - -- 해당 사용자를 밴 해제함. - -#### URL Parameters - -- id : User document의 id - -#### Response - -```javascript -{ - status: 200, - data: "The user unbanned successfully", -} -``` - -#### Errors - -- 400 "The user does not exist" -- 409 "The user is already banned" -- 500 "User/unban : Error 500" - -### `/:id/participate` **(POST)** (for dev) - -- 해당 사용자를 특정 방에 참여시킴. - -#### URL Parameters - -- id : User document의 id - -#### request JSON form - -```javascript -{ - room: String, // Room document의 id -} -``` - -#### Response - -```javascript -{ - status: 200, - data: "User/participate : Successful", -} -``` - -#### Errors - -- 400 "User/participate : Bad request" -- 400 "User/participate : No corresponding room" -- 400 "The user does not exist" -- 409 "The user already entered the room" -- 500 "User/participate : Error 500" diff --git a/src/routes/docs/utils.js b/src/routes/docs/utils.js new file mode 100644 index 00000000..2f99c13c --- /dev/null +++ b/src/routes/docs/utils.js @@ -0,0 +1,17 @@ +const { zodToJsonSchema } = require("zod-to-json-schema"); +const logger = require("../../modules/logger"); + +const zodToSchemaObject = (zodObejct) => { + try { + const schemaObject = {}; + Object.keys(zodObejct).forEach((key) => { + schemaObject[key] = zodToJsonSchema(zodObejct[key]); + }); + return schemaObject; + } catch (err) { + logger.error(`Failed to convert from zod to schema object: ${err}`); + return {}; + } +}; + +module.exports = { zodToSchemaObject }; diff --git a/src/routes/reports.js b/src/routes/reports.js index 07fcbf51..3f1781b6 100644 --- a/src/routes/reports.js +++ b/src/routes/reports.js @@ -1,6 +1,6 @@ const express = require("express"); -const reportsSchema = require("./docs/reportsSchema"); -const { validateBody } = require("../middlewares/ajv"); +const { validateBody } = require("../middlewares/zod"); +const { reportsZod } = require("./docs/schemas/reportsSchema"); const router = express.Router(); const reportHandlers = require("../services/reports"); @@ -9,7 +9,7 @@ router.use(require("../middlewares/auth")); router.post( "/create", - validateBody(reportsSchema.createHandler), + validateBody(reportsZod.createHandler), reportHandlers.createHandler ); diff --git a/src/routes/rooms.js b/src/routes/rooms.js index be1c3f59..6334136d 100644 --- a/src/routes/rooms.js +++ b/src/routes/rooms.js @@ -55,6 +55,19 @@ router.post( roomHandlers.createHandler ); +// 방을 생성하기 전, 생성하고자 하는 방이 실제로 택시 탑승의 목적성을 갖고 있는지 예측한다. +router.post( + "/create/test", + [ + body("from").isMongoId(), + body("to").isMongoId(), + body("time").isISO8601(), + body("maxPartLength").isInt({ min: 2, max: 4 }), + ], + validator, + roomHandlers.createTestHandler +); + // 새로운 사용자를 방에 참여시킨다. // FIXME: req.body.users 검증할 때 SSO ID 규칙 반영하기 router.post( 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/sampleGenerator/.gitignore b/src/sampleGenerator/.gitignore new file mode 100644 index 00000000..2909449b --- /dev/null +++ b/src/sampleGenerator/.gitignore @@ -0,0 +1,107 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# MongoDB Dump +dump/ diff --git a/src/sampleGenerator/README.md b/src/sampleGenerator/README.md new file mode 100644 index 00000000..5afd5960 --- /dev/null +++ b/src/sampleGenerator/README.md @@ -0,0 +1,28 @@ +# taxiSampleGenerator + +이 node 프로그램은 SPARCS-Taxi 프로젝트를 위한 샘플 사용자, 방, 채팅 목록을 생성합니다. +현재 이 프로그램으로 생성된 샘플 채팅 데이터는 입, 퇴장 메시지들과 일반 채팅 메시지들로만 구성되어 있습니다. + +**WARNING** +스크립트 실행 시 기존에 MongoDB에 저장된 사용자, 방, 채팅 정보는 **삭제**됩니다! + +**SETUP** + +1. *(optional)* Root directory의 `.env.test` 파일에 다음 내용을 추가합니다. + ``` + #방과 각각의 방의 채팅 개수 + SAMPLE_NUM_OF_ROOMS=2 + SAMPLE_NUM_OF_CHATS=200 + #채팅 간 최대 시간 간격(단위: 초, 소수도 가능) + SAMPLE_MAXIMUM_INTERVAL_BETWEEN_CHATS=20 + #새로운 채팅이 각각 입/퇴장 메시지일 확률(각각 10%) + SAMPLE_OCCURENCE_OF_JOIN=0.1 + SAMPLE_OCCURENCE_OF_ABORT=0.1 + ``` +1. sampleData.json에 장소, 유저, 방 데이터를 입력합니다. + javascript `User { "id": "sampleId", 사용자 id }` + +1. `pnpm start`로 샘플 채팅 데이터를 만들 수 있습니다. + +1. `pnpm run dumpDB`으로 현재 DB를 덤프할 수 있습니다. +1. `pnpm run restoreDB`로 과거 DB를 덤프 파일로부터 복원할 수 있습니다. diff --git a/src/sampleGenerator/index.js b/src/sampleGenerator/index.js new file mode 100644 index 00000000..e83a9335 --- /dev/null +++ b/src/sampleGenerator/index.js @@ -0,0 +1,47 @@ +const { + generateUser, + generateRoom, + generateSampleLocations, + generateChats, +} = require("./src/testData"); +const { connectDatabase } = require("../modules/stores/mongo"); +const { mongo: mongoUrl, numberOfChats, numberOfRooms } = require("./loadenv"); + +const database = connectDatabase(mongoUrl); + +const fs = require("fs"); +const sampleData = JSON.parse(fs.readFileSync("./sampleData.json")); + +const main = async () => { + await database.db.dropDatabase(); + + const { users, locations } = sampleData; + + const userOids = []; + const roomOids = []; + + for (const [index, user] of users.entries()) { + const userOid = await generateUser(user.id, index + 1, user.isAdmin); + userOids.push(userOid); + } + + const sampleLocationOids = await generateSampleLocations(locations); + + for (const index of Array(numberOfRooms).keys()) { + const roomOid = await generateRoom( + sampleLocationOids, + index + 1, + 7, + userOids[0] + ); //하드코딩: 일주일 뒤에 출발하는 방(들)을 만듭니다. + roomOids.push(roomOid); + } + + for (const roomOid of roomOids) { + await generateChats(roomOid, userOids, numberOfChats); + } + console.log("끝! 스크립트 실행을 중단하셔도 됩니다."); + process.exit(0); +}; + +database.on("open", main); diff --git a/src/sampleGenerator/loadenv.js b/src/sampleGenerator/loadenv.js new file mode 100644 index 00000000..0843789b --- /dev/null +++ b/src/sampleGenerator/loadenv.js @@ -0,0 +1,13 @@ +// Root directory에 있는 .env.test 파일을 읽어옴 +require("dotenv").config({ path: "../../.env.test" }); + +module.exports = { + mongo: process.env.DB_PATH, // required + numberOfRooms: parseInt(process.env.SAMPLE_NUM_OF_ROOMS ?? 2), // optional + numberOfChats: parseInt(process.env.SAMPLE_NUM_OF_CHATS ?? 200), // optional + maximumIntervalBtwChats: parseFloat( + process.env.SAMPLE_MAXIMUM_INTERVAL_BETWEEN_CHATS ?? 20 + ), // optional + occurenceOfJoin: parseFloat(process.env.SAMPLE_OCCURENCE_OF_JOIN ?? 0.1), // optional + occurenceOfAbort: parseFloat(process.env.SAMPLE_OCCURENCE_OF_ABORT ?? 0.1), // optional +}; diff --git a/src/sampleGenerator/package.json b/src/sampleGenerator/package.json new file mode 100644 index 00000000..3c7473bc --- /dev/null +++ b/src/sampleGenerator/package.json @@ -0,0 +1,17 @@ +{ + "name": "taxisamplegenerator", + "version": "1.0.0", + "description": "sample generator", + "main": "index.js", + "scripts": { + "preinstall": "npx only-allow pnpm", + "start": "node index.js", + "test": "echo \"Error: no test specified\" && exit 1", + "dumpDB": "node tools/dump.js", + "restoreDB": "node tools/restore.js" + }, + "keywords": [ + "test" + ], + "license": "ISC" +} diff --git a/src/sampleGenerator/sampleData.json b/src/sampleGenerator/sampleData.json new file mode 100644 index 00000000..546812cd --- /dev/null +++ b/src/sampleGenerator/sampleData.json @@ -0,0 +1,112 @@ +{ + "users": [ + { + "id": "sunday", + "isAdmin": true + }, + { + "id": "monday", + "isAdmin": true + }, + { + "id": "tuesday", + "isAdmin": true + }, + { + "id": "wednesday", + "isAdmin": true + } + ], + "locations": [ + { + "koName": "택시승강장", + "enName": "Taxi Stand", + "longitude": 127.359507, + "latitude": 36.373199 + }, + { + "koName": "대전역", + "enName": "Daejeon Station", + "longitude": 127.434522, + "latitude": 36.331894 + }, + { + "koName": "갤러리아 타임월드", + "enName": "Galleria Timeworld", + "longitude": 127.378188, + "latitude": 36.351938 + }, + { + "koName": "궁동 로데오거리", + "enName": "Gung-dong Rodeo Street", + "longitude": 127.350161, + "latitude": 36.362785 + }, + { + "koName": "대전복합터미널", + "enName": "Daejeon Terminal Complex", + "longitude": 127.350161, + "latitude": 36.362785 + }, + { + "koName": "만년중학교", + "enName": "Mannyon Middle School", + "longitude": 127.375993, + "latitude": 36.366990 + }, + { + "koName": "서대전역", + "enName": "Seodaejeon Station", + "longitude": 127.403933, + "latitude": 36.322517 + }, + { + "koName": "신세계백화점", + "enName": "Shinsegae Department Store", + "longitude": 127.381905, + "latitude": 36.375168 + }, + { + "koName": "오리연못", + "enName": "Duck Pond", + "longitude": 127.362371, + "latitude": 36.367715 + }, + { + "koName": "월평역", + "enName": "Wolpyeong Station", + "longitude": 127.364352, + "latitude": 36.358271 + }, + { + "koName": "유성구청", + "enName": "Yuseong-gu Office", + "longitude": 127.356384, + "latitude": 36.362084 + }, + { + "koName": "유성 고속버스터미널", + "enName": "Yuseong Express Bus Terminal", + "longitude": 127.336467, + "latitude": 36.358279 + }, + { + "koName": "유성 시외버스터미널", + "enName": "Yuseong Intercity Bus Terminal", + "longitude": 127.335971, + "latitude": 36.355604 + }, + { + "koName": "대전청사 고속버스터미널", + "enName": "Government Complex Express Bus Terminal", + "longitude": 127.390504, + "latitude": 36.361462 + }, + { + "koName": "대전청사 시외버스터미널", + "enName": "Government Complex Intercity Bus Terminal", + "longitude": 127.379759, + "latitude": 36.361512 + } + ] +} diff --git a/src/sampleGenerator/src/testData.js b/src/sampleGenerator/src/testData.js new file mode 100644 index 00000000..1209c52a --- /dev/null +++ b/src/sampleGenerator/src/testData.js @@ -0,0 +1,199 @@ +const { + userModel, + roomModel, + locationModel, + chatModel, +} = require("../../modules/stores/mongo"); +const { generateProfileImageUrl } = require("../../modules/modifyProfile"); + +const { + maximumIntervalBtwChats, + occurenceOfJoin, + occurenceOfAbort, +} = require("../loadenv"); + +const generateUser = async (id, num, isAdmin) => { + const newUser = new userModel({ + id: id, + name: `${id}-name`, + nickname: `${id}-nickname`, + profileImageUrl: generateProfileImageUrl(), + joinat: Date.now(), + subinfo: { + kaist: new String(20230000 + num), // ^-^ + sparcs: "", + facebook: "", + twitter: "", + }, + email: `${id}@kaist.ac.kr`, + isAdmin: isAdmin, + }); + await newUser.save(); + return newUser._id; +}; + +const generateSampleLocations = async (locations) => { + if (locations.length === 0) { + console.log("Please provide location(s)!"); + } + + for (const location of locations) { + const locationDocument = new locationModel({ + koName: location.koName, + enName: location.enName, + longitude: location.longitude, + latitude: location.latitude, + }); + await locationDocument.save(); + } + + const locationDocuments = await locationModel.find().lean(); + return locationDocuments.map((locationDocument) => locationDocument._id); +}; + +const generateRoom = async (sampleLocationOids, num, daysAfter, creatorId) => { + const date = new Date(); + date.setDate(date.getDate() + daysAfter); + + let fromIdx = 0; + let toIdx = 0; + + while (fromIdx === toIdx) { + fromIdx = Math.floor(Math.random() * sampleLocationOids.length); + toIdx = Math.floor(Math.random() * sampleLocationOids.length); + } + + const newRoom = new roomModel({ + name: `test-${num}`, + from: sampleLocationOids[fromIdx], + to: sampleLocationOids[toIdx], + time: date, + part: [{ user: creatorId }], + madeat: Date.now(), + maxPartLength: 4, + }); + await newRoom.save(); + return newRoom._id; +}; + +const joinUserToRoom = async (userIdsInRoom, userIdsOutRoom, roomId) => { + // 들어올 사용자를 무작위로 선택 + const authorIdx = Math.floor(Math.random() * userIdsOutRoom.length); + const userOid = userIdsOutRoom[authorIdx]; + + // 방, 유저 상태 갱신 + userIdsInRoom.push(userOid); + userIdsOutRoom.splice(authorIdx, 1); + const user = await userModel.findById(userOid, "ongoingRoom"); + user.ongoingRoom.push(roomId); + await user.save(); + + return { userIdsInRoom, userIdsOutRoom, userOid }; +}; + +const abortUserfromRoom = async (userIdsInRoom, userIdsOutRoom, roomId) => { + // 나갈 사용자를 무작위로 선택 + const authorIdx = Math.floor(Math.random() * userIdsInRoom.length); + const userOid = userIdsInRoom[authorIdx]; + + // 방, 유저 상태 갱신 + userIdsOutRoom.push(userOid); + userIdsInRoom.splice(authorIdx, 1); + const user = await userModel.findById(userOid, "ongoingRoom"); + user.ongoingRoom.splice(user.ongoingRoom.indexOf(roomId), 1); + await user.save(); + + return { userIdsInRoom, userIdsOutRoom, userOid }; +}; + +const generateNormalChat = async (i, roomId, userOid, time) => { + const user = await userModel.findById(userOid); + const newChat = new chatModel({ + roomId: roomId, + type: "text", + authorId: user._id, + content: `안녕하세요! (${i}번째 메시지)`, + time: time, + inValid: false, + }); + await newChat.save(); +}; + +const generateJoinAbortChat = async (roomId, userOid, isJoining, time) => { + const user = await userModel.findById(userOid); + const newChat = new chatModel({ + roomId: roomId, + type: isJoining ? "in" : "out", + authorId: user._id, + content: user.id, + time: time, + isValid: false, + }); + await newChat.save(); +}; + +const generateChats = async (roomId, userOids, numOfChats) => { + const roomPopulateQuery = [{ path: "part", select: "id name nickname -_id" }]; + const room = await roomModel.findById(roomId).populate(roomPopulateQuery); + + let userIdsInRoom = []; + let userIdsOutRoom = userOids.map((userOid) => userOid); + let lastTime = Date.now(); + const maximumIntervalBtwChatsMilliseconds = 1000 * maximumIntervalBtwChats; + + for (const i of Array(numOfChats).keys()) { + lastTime += Math.floor(Math.random() * maximumIntervalBtwChatsMilliseconds); + const event = Math.random(); + + if ( + userIdsInRoom.length === 0 || + (event < occurenceOfJoin && userIdsOutRoom.length !== 0) + ) { + // 더 들어올 사용자가 있을 경우, 더 들어옴 + // 방, 유저 상태 갱신 + let userOid; + ({ userIdsInRoom, userIdsOutRoom, userOid } = await joinUserToRoom( + userIdsInRoom, + userIdsOutRoom, + roomId + )); + // 입장 메시지 생성 + await generateJoinAbortChat(roomId, userOid, true, lastTime); + } else if ( + occurenceOfJoin <= event && + event < occurenceOfJoin + occurenceOfAbort && + userIdsInRoom.length > 1 + ) { + // 나갈 사용자가 있을 경우, 나감 + // 방, 유저 상태 갱신 + let userOid; + ({ userIdsInRoom, userIdsOutRoom, userOid } = await abortUserfromRoom( + userIdsInRoom, + userIdsOutRoom, + roomId + )); + // 퇴장 메시지 생성 + await generateJoinAbortChat(roomId, userOid, false, lastTime); + } else { + // 방이 비어있지 않을 경우, 일반 채팅 메시지를 만듦 + if (userIdsInRoom.length !== 0) { + const authorIdx = Math.floor(Math.random() * userIdsInRoom.length); + const user = userIdsInRoom[authorIdx]; + await generateNormalChat(i, roomId, user, lastTime); + } + } + } + // 현재 참여중인 사용자 기준으로 방의 part 리스트를 업데이트함 + room.part = userIdsInRoom.map((userOid) => { + return { user: userOid }; + }); + await room.save(); + return; +}; + +module.exports = { + generateUser, + generateRoom, + generateSampleLocations, + generateChats, +}; diff --git a/src/sampleGenerator/tools/dump.js b/src/sampleGenerator/tools/dump.js new file mode 100644 index 00000000..7d26d802 --- /dev/null +++ b/src/sampleGenerator/tools/dump.js @@ -0,0 +1,20 @@ +const util = require("util"); +const path = require("path"); +const exec = util.promisify(require("child_process").exec); +const { mongo: mongoUrl } = require("../loadenv"); + +const main = async () => { + const { stdout, stderr } = await exec( + `mongodump ${mongoUrl} --out ${path.resolve("dump")}` + ); + console.log("dump 디렉토리에 데이터베이스 데이터를 덤프했습니다."); + process.exit(0); +}; + +try { + main(); +} catch { + console.log( + "DB 연결 주소가 올바르지 않습니다. DB 연결 주소를 다시 한 번 확인해주세요." + ); +} diff --git a/src/sampleGenerator/tools/restore.js b/src/sampleGenerator/tools/restore.js new file mode 100644 index 00000000..5c14c98b --- /dev/null +++ b/src/sampleGenerator/tools/restore.js @@ -0,0 +1,23 @@ +const util = require("util"); +const path = require("path"); +const exec = util.promisify(require("child_process").exec); +const { mongo: mongoUrl } = require("../loadenv"); + +const main = async () => { + const dbName = mongoUrl.split("/").pop(); + const { stdout, stderr } = await exec( + `mongorestore ${mongoUrl} ${path.resolve("dump", dbName)}` + ); + console.log( + "dump 디렉토리로부터 데이터베이스 정보를 성공적으로 복원했습니다." + ); + process.exit(0); +}; + +try { + main(); +} catch { + console.log( + "DB를 덤프해올 디렉토리가 존재하지 않습니다. 경로를 다시 한 번 확인해주세요." + ); +} diff --git a/src/services/auth.js b/src/services/auth.js index bab3fc71..dab7cd64 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -91,6 +91,7 @@ const tryLogin = async (req, res, userData, redirectOrigin, redirectPath) => { } login(req, userData.sid, user.id, user._id, user.name); + res.redirect(new URL(redirectPath, redirectOrigin).href); } catch (err) { logger.error(err); @@ -118,13 +119,13 @@ const sparcsssoCallbackHandler = (req, res) => { const { state: stateForCmp, code } = req.query; if (!loginAfterState) - return res.status(400).send("SparcsssoCallbackHandler : invalid request"); + return res.status(400).send("Auth/sparcssso/callback : invalid request"); const { state, redirectOrigin, redirectPath } = loginAfterState; req.session.loginAfterState = undefined; if (!state || !redirectOrigin || !redirectPath) { - return res.status(400).send("SparcsssoCallbackHandler : invalid request"); + return res.status(400).send("Auth/sparcssso/callback : invalid request"); } if (state !== stateForCmp) { @@ -149,7 +150,7 @@ const sparcsssoCallbackHandler = (req, res) => { const loginReplaceHandler = (req, res) => { res.status(400).json({ - error: "Auths/login/replace : Bad Request", + error: "Auth/login/replace : Bad Request", }); }; 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 a433e6f6..4103a7f0 100644 --- a/src/services/auth.replace.js +++ b/src/services/auth.replace.js @@ -32,7 +32,7 @@ const loginReplaceHandler = (req, res) => { const { id } = req.body; const loginAfterState = req.session?.loginAfterState; if (!loginAfterState) - return res.status(400).send("SparcsssoCallbackHandler : invalid request"); + return res.status(400).send("Auth/login/replace : invalid request"); const { redirectOrigin, redirectPath } = loginAfterState; req.session.loginAfterState = undefined; tryLogin(req, res, createUserData(id), redirectOrigin, redirectPath); diff --git a/src/services/chats.js b/src/services/chats.js index 4a76b1a4..ca74d774 100644 --- a/src/services/chats.js +++ b/src/services/chats.js @@ -1,7 +1,12 @@ 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; @@ -170,6 +175,51 @@ const sendChatHandler = async (req, res) => { } }; +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; @@ -223,7 +273,7 @@ const uploadChatImgDoneHandler = async (req, res) => { if (!user) { return res .status(500) - .send("Chat/uploadChatImg/getPUrl : internal server error"); + .send("Chat/uploadChatImg/done : internal server error"); } if (!chat) { return res.status(404).json({ @@ -244,7 +294,7 @@ const uploadChatImgDoneHandler = async (req, res) => { if (err) { return res .status(500) - .send("Chat/uploadChatImg/getPUrl : internal server error"); + .send("Chat/uploadChatImg/done : internal server error"); } chat.content = chat._id; @@ -284,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 0451b0cc..dbd30d53 100644 --- a/src/services/reports.js +++ b/src/services/reports.js @@ -4,10 +4,10 @@ const { roomModel, } = require("../modules/stores/mongo"); const { reportPopulateOption } = require("../modules/populates/reports"); -const { sendReportEmail } = require("../modules/stores/aws"); +const { sendReportEmail } = require("../modules/email"); const logger = require("../modules/logger"); -const emailPage = require("../views/emailNoSettlementPage"); -const { notifyToReportChannel } = require("../modules/slackNotification"); +const reportEmailPage = require("../views/reportEmailPage"); +const { notifyReportToReportChannel } = require("../modules/slackNotification"); const createHandler = async (req, res) => { try { @@ -40,12 +40,12 @@ const createHandler = async (req, res) => { await report.save(); - notifyToReportChannel(user.nickname, report); + notifyReportToReportChannel(user.nickname, report); - if (report.type === "no-settlement") { + if (report.type === "no-settlement" || report.type === "no-show") { const emailRoomName = room ? room.name : ""; const emailRoomId = room ? room._id : ""; - const emailHtml = emailPage( + const emailHtml = reportEmailPage[report.type]( req.origin, reported.name, reported.nickname, diff --git a/src/services/rooms.js b/src/services/rooms.js index 62726645..42b343c1 100644 --- a/src/services/rooms.js +++ b/src/services/rooms.js @@ -10,6 +10,17 @@ const { formatSettlement, getIsOver, } = require("../modules/populates/rooms"); +const { + notifyRoomCreationAbuseToReportChannel, +} = require("../modules/slackNotification"); + +// 이벤트 코드입니다. +const { eventConfig } = require("../../loadenv"); +const eventPeriod = eventConfig && { + startAt: new Date(eventConfig.period.startAt), + endAt: new Date(eventConfig.period.endAt), +}; +const { contracts } = require("../lottery"); const createHandler = async (req, res) => { const { name, from, to, time, maxPartLength } = req.body; @@ -17,7 +28,13 @@ const createHandler = async (req, res) => { try { if (from === to) { return res.status(400).json({ - error: "Room/create : locations are same", + error: "Rooms/create : locations are same", + }); + } + + if (req.timestamp > Date.parse(req.body.time)) { + return res.status(400).json({ + error: "Rooms/create : invalid timestamp", }); } @@ -30,7 +47,8 @@ const createHandler = async (req, res) => { if (createTime.getTime() > maxTime.getTime()) { return res.status(400).json({ - error: "Room/create : cannot over 2 weeks on the basis of current Date", + error: + "Rooms/create : cannot over 2 weeks on the basis of current Date", }); } @@ -81,7 +99,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({ @@ -91,6 +114,122 @@ const createHandler = async (req, res) => { } }; +const checkIsAbusing = ( + { from, to, time, maxPartLength }, + countRecentlyMadeRooms, + candidateRooms +) => { + /** + * 방을 생성하였을 때, 다음 조건 중 하나라도 만족하게 되면 어뷰징 가능성이 있다고 판단합니다. + * 1. 참여한 방 중, 생성하려는 방의 출발 시간 앞 뒤 12시간 내에 출발하는 방이 3개 이상인 경우 + * 2. 참여한 방 중, 생성하려는 방의 출발 시간 앞 뒤 12시간 내에 출발하는 방이 2개이고, 다음 조건 중 하나 이상을 만족하는 경우 + * a. 두 방의 출발 시간 간격이 1시간 이하인 경우 + * b. 두 방의 출발 시간 간격이 1시간 초과이고, 다음 조건 중 하나 이상을 만족하는 경우 + * i. 두 방의 출발지가 같은 경우 + * ii. 두 방의 목적지가 같은 경우 + * iii. 먼저 출발하는 방의 목적지와 나중에 출발하는 방의 출발지가 다른 경우 + * iv. 두 방의 최대 탑승 가능 인원이 모두 2명인 경우 + * 3. 최근 24시간 내에 생성한 방이 4개 이상인 경우 + */ + + if (countRecentlyMadeRooms + 1 >= 4) return true; // 조건 3 + + if (candidateRooms.length + 1 >= 3) return true; // 조건 1 + if (candidateRooms.length + 1 < 2) return false; // 조건 2의 여집합 + + let firstRoom = { + from: candidateRooms[0].from.toString(), + to: candidateRooms[0].to.toString(), + time: candidateRooms[0].time, + maxPartLength: candidateRooms[0].maxPartLength, + }; + let secondRoom = { + from, + to, + time: new Date(time), + maxPartLength, + }; + if (secondRoom.time < firstRoom.time) { + [firstRoom, secondRoom] = [secondRoom, firstRoom]; + } + + if (secondRoom.time - firstRoom.time <= 3600000) return true; // 조건 2-a + if ( + firstRoom.from === secondRoom.from || + firstRoom.to === secondRoom.to || + firstRoom.to !== secondRoom.from + ) + return true; // 조건 2-b-i, 2-b-ii, 2-b-iii + if (firstRoom.maxPartLength === 2 && secondRoom.maxPartLength === 2) + return true; // 조건 2-b-iv + + return false; +}; + +const createTestHandler = async (req, res) => { + // 이 Handler에서는 Parameter에 대해 추가적인 Validation을 하지 않습니다. + const { time } = req.body; + + try { + // 이벤트 코드입니다. + if ( + !eventPeriod || + req.timestamp >= eventPeriod.endAt || + req.timestamp < eventPeriod.startAt + ) + return res.json({ result: true }); + + const countRecentlyMadeRooms = await roomModel.countDocuments({ + madeat: { $gte: new Date(req.timestamp - 86400000) }, // 밀리초 단위로 24시간을 나타냅니다. + "part.0.user": req.userOid, // 방 최초 생성자를 저장하는 필드가 없으므로, 첫 번째 참여자를 생성자로 간주합니다. + }); + if (!countRecentlyMadeRooms && countRecentlyMadeRooms !== 0) + return res + .status(500) + .json({ error: "Rooms/create/test : internal server error" }); + + const dateTime = new Date(time); + const candidateRooms = await roomModel + .find( + { + time: { + $gte: new Date(dateTime.getTime() - 43200000), + $lte: new Date(dateTime.getTime() + 43200000), + }, + part: { $elemMatch: { user: req.userOid } }, + }, + "from to time maxPartLength" + ) + .limit(2) + .lean(); + if (!candidateRooms) + return res + .status(500) + .json({ error: "Rooms/create/test : internal server error" }); + + const isAbusing = checkIsAbusing( + req.body, + countRecentlyMadeRooms, + candidateRooms + ); + if (isAbusing) { + const user = await userModel.findById(req.userOid).lean(); + notifyRoomCreationAbuseToReportChannel( + req.userOid, + user?.nickname ?? req.userOid, + req.body + ); + } + + return res.json({ result: !isAbusing }); + } catch (err) { + logger.error(err); + res.status(500).json({ + error: "Rooms/create/test : internal server error", + }); + } +}; + const publicInfoHandler = async (req, res) => { try { const roomObject = await roomModel @@ -102,13 +241,13 @@ const publicInfoHandler = async (req, res) => { res.send(formatSettlement(roomObject, { includeSettlement: false })); } else { res.status(404).json({ - error: "Rooms/info : id does not exist", + error: "Rooms/publicInfo : id does not exist", }); } } catch (err) { logger.error(err); res.status(500).json({ - error: "Rooms/info : internal server error", + error: "Rooms/publicInfo : internal server error", }); } }; @@ -145,7 +284,7 @@ const joinHandler = async (req, res) => { // 사용자의 참여중인 진행중인 방이 5개 이상이면 오류를 반환합니다. if (user.ongoingRoom.length >= 5) { return res.status(400).json({ - error: "Rooms/create : participating in too many rooms", + error: "Rooms/join : participating in too many rooms", }); } @@ -171,7 +310,7 @@ const joinHandler = async (req, res) => { // 방이 이미 출발한 경우, 400 오류를 반환합니다. if (req.timestamp >= room.time) { res.status(400).json({ - error: "Room/join : The room has already departed", + error: "Rooms/join : The room has already departed", }); return; } @@ -179,7 +318,7 @@ const joinHandler = async (req, res) => { // 방의 인원이 모두 찬 경우, 400 오류를 반환합니다. if (room.part.length + 1 > room.maxPartLength) { res.status(400).json({ - error: "Room/join : The room is already full", + error: "Rooms/join : The room is already full", }); return; } @@ -236,7 +375,7 @@ const abortHandler = async (req, res) => { .indexOf(user._id.toString()); if (roomPartIndex === -1) { return res.status(403).json({ - error: "Rooms/info : did not joined the room", + error: "Rooms/abort : did not joined the room", }); } @@ -246,7 +385,7 @@ const abortHandler = async (req, res) => { // 방의 출발시간이 지나고 정산이 되지 않으면 나갈 수 없음 if (isOvertime(room, req.timestamp) && userOngoingRoomIndex !== -1) { return res.status(400).json({ - error: "Rooms/info : cannot exit room. Settlement is not done", + error: "Rooms/abort : cannot exit room. Settlement is not done", }); } @@ -259,7 +398,7 @@ const abortHandler = async (req, res) => { } else { // room.part에는 user가 있지만 user.ongoingRoom이나 user.doneRoom에는 room이 없는 상황. logger.error( - `Room/abort: referential integrity error (user: ${user._id}, room: ${room._id})` + `Rooms/abort: referential integrity error (user: ${user._id}, room: ${room._id})` ); return res.status(500).json({ error: "Rooms/abort : internal server error", @@ -303,7 +442,7 @@ const searchHandler = async (req, res) => { // 출발지와 도착지가 같은 경우 if (from && to && from === to) { return res.status(400).json({ - error: "Room/search : Bad request", + error: "Rooms/search : Bad request", }); } @@ -312,7 +451,7 @@ const searchHandler = async (req, res) => { const fromLocation = await locationModel.findById(from); if (!fromLocation || fromLocation?.isValid === false) { return res.status(400).json({ - error: "Room/search : no corresponding locations", + error: "Rooms/search : no corresponding locations", }); } } @@ -320,7 +459,7 @@ const searchHandler = async (req, res) => { const toLocation = await locationModel.findById(to); if (!toLocation || toLocation?.isValid === false) { return res.status(400).json({ - error: "Room/search : no corresponding locations", + error: "Rooms/search : no corresponding locations", }); } } @@ -369,7 +508,6 @@ const searchHandler = async (req, res) => { .limit(1000) .populate(roomPopulateOption) .lean(); - res.json( rooms.map((room) => formatSettlement(room, { includeSettlement: false })) ); @@ -545,6 +683,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) { @@ -611,6 +761,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) { @@ -705,6 +867,7 @@ module.exports = { publicInfoHandler, infoHandler, createHandler, + createTestHandler, joinHandler, abortHandler, searchHandler, diff --git a/src/services/users.js b/src/services/users.js index 77af8c7d..514f2b4a 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 }); @@ -11,13 +18,13 @@ const agreeOnTermsOfServiceHandler = async (req, res) => { res .status(200) .send( - "User/agreeOnTermsOfService : agree on Terms of Service successful" + "Users/agreeOnTermsOfService : agree on Terms of Service successful" ); } else { - res.status(400).send("User/agreeOnTermsOfService : already agreed"); + res.status(400).send("Users/agreeOnTermsOfService : already agreed"); } } catch { - res.status(500).send("User/agreeOnTermsOfService : internal server error"); + res.status(500).send("Users/agreeOnTermsOfService : internal server error"); } }; @@ -29,48 +36,63 @@ const getAgreeOnTermsOfServiceHandler = async (req, res) => { const agreeOnTermsOfService = user.agreeOnTermsOfService === true; res.json({ agreeOnTermsOfService }); } catch { - res.status(500).send("/getAgreeOnTermsOfService : internal server error"); + res + .status(500) + .send("Users/getAgreeOnTermsOfService : internal server error"); } }; 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("Users/editNickname : edit user nickname successful"); + } else { + res.status(400).send("Users/editNickname : such user id does not exist"); + } + } catch (err) { + logger.error(err); + res.status(500).send("Users/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("Users/editAccount : edit user account successful"); + } else { + res.status(400).send("Users/editAccount : such user id does not exist"); + } + } catch (err) { + logger.error(err); + res.status(500).send("Users/editAccount : internal server error"); + } }; const editProfileImgGetPUrlHandler = async (req, res) => { @@ -80,14 +102,14 @@ const editProfileImgGetPUrlHandler = async (req, res) => { if (!user) { return res .status(500) - .send("User/editProfileImg/getPUrl : internal server error"); + .send("Users/editProfileImg/getPUrl : internal server error"); } const key = `profile-img/${user._id}`; aws.getUploadPUrlPost(key, type, (err, data) => { if (err) { return res .status(500) - .send("User/editProfileImg/getPUrl : internal server error"); + .send("Users/editProfileImg/getPUrl : internal server error"); } data.fields["Content-Type"] = type; data.fields["key"] = key; @@ -97,7 +119,9 @@ const editProfileImgGetPUrlHandler = async (req, res) => { }); }); } catch (e) { - res.status(500).send("User/editProfileImg/getPUrl : internal server error"); + res + .status(500) + .send("Users/editProfileImg/getPUrl : internal server error"); } }; @@ -107,7 +131,7 @@ const editProfileImgDoneHandler = async (req, res) => { if (!user) { return res .status(500) - .send("User/editProfileImg/done : internal server error"); + .send("Users/editProfileImg/done : internal server error"); } const key = `profile-img/${user._id}`; aws.foundObject(key, async (err) => { @@ -115,25 +139,64 @@ const editProfileImgDoneHandler = async (req, res) => { logger.error(err); return res .status(500) - .send("User/editProfileImg/done : internal server error"); + .send("Users/editProfileImg/done : internal server error"); } const userAfter = await userModel.findOneAndUpdate( { id: req.userId }, - { profileImageUrl: user._id }, + { profileImageUrl: aws.getS3Url(`/${key}?token=${req.timestamp}`) }, { new: true } ); if (!userAfter) { return res .status(500) - .send("User/editProfileImg/done : internal server error"); + .send("Users/editProfileImg/done : internal server error"); } res.json({ result: true, - profileImageUrl: userAfter._id, + profileImageUrl: userAfter.profileImageUrl, }); }); } catch (e) { - res.status(500).send("User/editProfileImg/done : internal server error"); + res.status(500).send("Users/editProfileImg/done : internal server error"); + } +}; + +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("Users/resetNickname : such user does not exist"); + res + .status(200) + .send("Users/resetNickname : reset user nickname successful"); + } catch (err) { + logger.error(err); + res.status(500).send("Users/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("Users/resetProfileImg : such user does not exist"); + res + .status(200) + .send("Users/resetProfileImg : reset user profile image successful"); + } catch (err) { + res.status(500).send("Users/resetProfileImg : internal server error"); } }; @@ -144,4 +207,6 @@ module.exports = { editAccountHandler, editProfileImgGetPUrlHandler, editProfileImgDoneHandler, + resetNicknameHandler, + resetProfileImgHandler, }; diff --git a/src/views/emailNoSettlementPage.js b/src/views/emailNoSettlementPage.js deleted file mode 100644 index 5143c279..00000000 --- a/src/views/emailNoSettlementPage.js +++ /dev/null @@ -1,34 +0,0 @@ -const emailPage = require("./emailPage"); - -module.exports = (origin, name, nickname, roomName, payer, roomId) => - emailPage( - "미정산 내역 관련 안내", - `${name} (${nickname}) 님께

- 안녕하세요, ${name} (${nickname}) 님.
- KAIST 학부 총학생회 산하 특별기구 SPARCS의 Taxi 팀입니다.

- 최근 참여하신 방에서 정산이 이루어지지 않았다는 사용자의 문의가 접수되어 메일을 보내드립니다.

-
-
- 방 제목 - ${roomName} -
-
- 결제자 - ${payer} -
- -

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

- 감사합니다.
- SPARCS Taxi팀 드림. - ` - ); diff --git a/src/views/emailPage.js b/src/views/emailPage.js index d99dafd1..f41122a4 100644 --- a/src/views/emailPage.js +++ b/src/views/emailPage.js @@ -7,7 +7,7 @@ module.exports = (
Taxi
${ @@ -18,7 +18,7 @@ module.exports = (
${content}
SPARCS
diff --git a/src/views/loginReplacePage.js b/src/views/loginReplacePage.js index 209e0bb0..972cfb62 100644 --- a/src/views/loginReplacePage.js +++ b/src/views/loginReplacePage.js @@ -24,7 +24,7 @@ module.exports = ` } const submitHandler = () => { const value = document.getElementById("input-id").value; - if(value) post('/auth/login/replace', { + if(value) post('/api/auth/login/replace', { id: value, }); } diff --git a/src/views/reportEmailPage.js b/src/views/reportEmailPage.js new file mode 100644 index 00000000..1ee674f8 --- /dev/null +++ b/src/views/reportEmailPage.js @@ -0,0 +1,86 @@ +const emailPage = require("./emailPage"); + +const reportEmailPage = {}; + +/* 미정산 알림 메일을 위한 템플릿 */ +reportEmailPage["no-settlement"] = ( + origin, + name, + nickname, + roomName, + payer, + roomId +) => + emailPage( + "미정산 내역 관련 안내", + `${name} (${nickname}) 님께

+ 안녕하세요, ${name} (${nickname}) 님.
+ KAIST 학부 총학생회 산하 특별기구 SPARCS의 Taxi 팀입니다.

+ 최근 참여하신 방에서 정산이 이루어지지 않았다는 사용자의 문의가 접수되어 메일을 보내드립니다.

+

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

+ 감사합니다.
+ SPARCS Taxi팀 드림. + ` + ); + +/* 미탑승 알림 메일을 위한 템플릿 */ +reportEmailPage["no-show"] = ( + origin, + name, + nickname, + roomName, + payer, + roomId +) => + emailPage( + "미탑승 내역 관련 안내", + `${name} (${nickname}) 님께

+ 안녕하세요, ${name} (${nickname}) 님.
+ KAIST 학부 총학생회 산하 특별기구 SPARCS의 Taxi 팀입니다.

+ 최근 참여하신 방에서 별도의 연락 없이 탑승하지 않았다는 사용자의 문의가 접수되어 메일을 보내드립니다.

+
+
+ 방 제목 + ${roomName} +
+
+ 결제자 + ${payer} +
+ +

+ 미탑승이 반복되는 경우 Taxi 서비스 이용이 제한될 수 있음을 알려드립니다.
+ 문의가 필요하신 경우, 택시 서비스 내부의 "채널톡 문의하기"를 통해 채팅을 남겨주시거나, 또는 이 메일에 회신해 주셔도 됩니다.

+ 감사합니다.
+ SPARCS Taxi팀 드림. + ` + ); + +module.exports = reportEmailPage; 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 82% rename from test/auth.replace.js rename to test/services/auth.replace.js index 322d13ef..643eddf7 100644 --- a/test/auth.replace.js +++ b/test/services/auth.replace.js @@ -1,7 +1,7 @@ const request = require("supertest"); -const authHandlers = require("../src/services/auth.replace"); -const { userModel } = require("../src/modules/stores/mongo"); +const authHandlers = require("../../src/services/auth.replace"); +const { userModel } = require("../../src/modules/stores/mongo"); // auth.replace.js 관련 1개의 handler을 테스트 // 1. dev 환경에서 로그인이 성공적으로 이루어지는지 확인 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 90% rename from test/users.js rename to test/services/users.js index 1efa0d86..4f2195da 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 = + "Users/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 = "Users/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 = "Users/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..e537913b 100644 --- a/test/utils.js +++ b/test/utils.js @@ -4,8 +4,12 @@ const { chatModel, locationModel, reportModel, + connectDatabase, } = require("../src/modules/stores/mongo"); const { generateProfileImageUrl } = require("../src/modules/modifyProfile"); +const { mongo: mongoUrl } = require("../loadenv"); + +connectDatabase(mongoUrl); // 테스트를 위한 유저 생성 함수 const userGenerator = async (username, testData) => {