diff --git a/.github/workflows/BE-deploy.yml b/.github/workflows/BE-deploy.yml new file mode 100644 index 00000000..0ae4aeef --- /dev/null +++ b/.github/workflows/BE-deploy.yml @@ -0,0 +1,72 @@ +name: BE-deploy +on: + push: + branches: + - "BE-develop" +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: BE-develop + sparse-checkout: nestjs-BE + - name: Login to github Packages + run: echo ${{ secrets.PACKAGE_ACCESS_TOKEN }} | docker login ghcr.io -u ${{ secrets.PACKAGE_USERNAME }} --password-stdin + - name: Build and push Docker image + run: | + echo "SERVER_PORT=$SERVER_PORT" >> ./nestjs-BE/server/.env + echo "JWT_ACCESS_SECRET=$JWT_ACCESS_SECRET" >> ./nestjs-BE/server/.env + echo "JWT_REFRESH_SECRET=$JWT_REFRESH_SECRET" >> ./nestjs-BE/server/.env + echo "KAKAO_ADMIN_KEY=$KAKAO_ADMIN_KEY" >> ./nestjs-BE/server/.env + echo "MYSQL_DATABASE_URL=$MYSQL_DATABASE_URL" >> ./nestjs-BE/server/.env + echo "MONGODB_DATABASE_URL=$MONGODB_DATABASE_URL" >> ./nestjs-BE/server/.env + echo "MONGODB_DATABASE_URI=$MONGODB_DATABASE_URI" >> ./nestjs-BE/server/.env + echo "NCLOUD_ACCESS_KEY=$NCLOUD_ACCESS_KEY" >> ./nestjs-BE/server/.env + echo "NCLOUD_SECRET_KEY=$NCLOUD_SECRET_KEY" >> ./nestjs-BE/server/.env + echo "NCLOUD_REGION=$NCLOUD_REGION" >> ./nestjs-BE/server/.env + echo "STORAGE_URL=$STORAGE_URL" >> ./nestjs-BE/server/.env + echo "BASE_IMAGE_URL=$BASE_IMAGE_URL" >> ./nestjs-BE/server/.env + echo "BUCKET_NAME=$BUCKET_NAME" >> ./nestjs-BE/server/.env + echo "APP_ICON_URL=$APP_ICON_URL" >> ./nestjs-BE/server/.env + echo "CSV_FOLDER=$CSV_FOLDER" >> ./nestjs-BE/server/.env + docker build -t ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync ./nestjs-BE/server + docker push ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync:latest + env: + SERVER_PORT: ${{ secrets.CONTAINER_PORT }} + JWT_ACCESS_SECRET: ${{ secrets.JWT_ACCESS_SECRET }} + JWT_REFRESH_SECRET: ${{ secrets.JWT_REFRESH_SECRET }} + KAKAO_ADMIN_KEY: ${{ secrets.KAKAO_ADMIN_KEY }} + MYSQL_DATABASE_URL: ${{ secrets.MYSQL_DATABASE_URL }} + MONGODB_DATABASE_URL: ${{ secrets.MONGODB_DATABASE_URL }} + MONGODB_DATABASE_URI: ${{ secrets.MONGODB_DATABASE_URI }} + NCLOUD_ACCESS_KEY: ${{ secrets.NCLOUD_ACCESS_KEY }} + NCLOUD_SECRET_KEY: ${{ secrets.NCLOUD_SECRET_KEY }} + NCLOUD_REGION: ${{ secrets.NCLOUD_REGION }} + STORAGE_URL: ${{ secrets.STORAGE_URL }} + BASE_IMAGE_URL: ${{ secrets.BASE_IMAGE_URL }} + BUCKET_NAME: ${{ secrets.BUCKET_NAME }} + APP_ICON_URL: ${{ secrets.APP_ICON_URL }} + CSV_FOLDER: ${{ secrets.CSV_FOLDER }} + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Pull Docker image + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.REMOTE_HOST }} + port: ${{ secrets.REMOTE_PORT }} + username: ${{ secrets.REMOTE_USER }} + key: ${{ secrets.REMOTE_SSH_KEY }} + script: | + echo ${{ secrets.PACKAGE_ACCESS_TOKEN }} | docker login ghcr.io -u ${{ secrets.PACKAGE_USERNAME }} --password-stdin + docker pull ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync + docker stop mindsync_server || true + docker rm mindsync_server || true + docker run -d \ + --name mindsync_server \ + -p ${{ secrets.SERVER_PORT }}:${{ secrets.CONTAINER_PORT }} \ + -v temporary-volume:${{ secrets.CSV_FOLDER }} \ + ghcr.io/${{ secrets.PACKAGE_USERNAME }}/mindsync diff --git a/nestjs-BE/crdt/lww-map.ts b/nestjs-BE/crdt/lww-map.ts deleted file mode 100644 index 52fb9bde..00000000 --- a/nestjs-BE/crdt/lww-map.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { LWWRegister, lwwRegisterState } from './lww-register'; - -export type lwwMapState = { - [key: string]: lwwRegisterState; -}; - -export class LWWMap { - readonly id: string; - private data = new Map>(); - - constructor(id: string, state: lwwMapState = {}) { - this.id = id; - this.initializeData(state); - } - - private initializeData(state: lwwMapState): void { - for (const [key, register] of Object.entries(state)) - this.data.set(key, new LWWRegister(this.id, register)); - } - - getState(): lwwMapState { - const state: lwwMapState = {}; - for (const [key, register] of this.data.entries()) - if (register) state[key] = register.state; - return state; - } - - get(key: string): T | null | undefined { - return this.data.get(key)?.getValue(); - } - - set(key: string, value: T): void { - const register = this.data.get(key); - if (register) register.setValue(value); - else - this.data.set( - key, - new LWWRegister(this.id, { - id: this.id, - timestamp: Date.now(), - value, - }), - ); - } - - delete(key: string): void { - this.data.get(key)?.setValue(null); - } - - has(key: string): boolean { - return !!this.data.get(key)?.getValue(); - } - - clear(): void { - for (const [key, register] of this.data.entries()) - if (register) this.delete(key); - } - - merge(state: lwwMapState): void { - for (const [key, remoteRegister] of Object.entries(state)) { - const local = this.data.get(key); - if (local) local.merge(remoteRegister); - else this.data.set(key, new LWWRegister(this.id, remoteRegister)); - } - } -} diff --git a/nestjs-BE/crdt/lww-register.ts b/nestjs-BE/crdt/lww-register.ts deleted file mode 100644 index b7c020e2..00000000 --- a/nestjs-BE/crdt/lww-register.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface lwwRegisterState { - id: string; - timestamp: number; - value: T; -} - -export class LWWRegister { - readonly id: string; - state: lwwRegisterState; - - constructor(id: string, state: lwwRegisterState) { - this.id = id; - this.state = state; - } - - getValue(): T { - return this.state.value; - } - - setValue(value: T): void { - this.state = { id: this.id, timestamp: Date.now(), value }; - } - - merge(state: lwwRegisterState): void { - const { id: remoteId, timestamp: remoteTimestamp } = state; - const { id: localId, timestamp: localTimestamp } = this.state; - if (localTimestamp > remoteTimestamp) return; - if (localTimestamp === remoteTimestamp && localId > remoteId) return; - this.state = state; - } -} diff --git a/nestjs-BE/server/.dockerignore b/nestjs-BE/server/.dockerignore new file mode 100644 index 00000000..9b915193 --- /dev/null +++ b/nestjs-BE/server/.dockerignore @@ -0,0 +1,9 @@ +README.md +/test + +.eslintrc.js +.gitignore +.prettierrc +nest-cli.json + +*.spec.ts diff --git a/nestjs-BE/.eslintrc.js b/nestjs-BE/server/.eslintrc.js similarity index 93% rename from nestjs-BE/.eslintrc.js rename to nestjs-BE/server/.eslintrc.js index 563a12ca..5d07f54b 100644 --- a/nestjs-BE/.eslintrc.js +++ b/nestjs-BE/server/.eslintrc.js @@ -23,7 +23,7 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', 'max-depth': ['error', 3], 'no-magic-numbers': ['error', { ignore: [-1, 0, 1] }], - 'curly': ['error', 'multi', 'consistent'], + 'curly': ['error', 'multi-line', 'consistent'], 'max-params': ['error', 3], }, }; diff --git a/nestjs-BE/.gitignore b/nestjs-BE/server/.gitignore similarity index 76% rename from nestjs-BE/.gitignore rename to nestjs-BE/server/.gitignore index 9751d2d5..4af1e623 100644 --- a/nestjs-BE/.gitignore +++ b/nestjs-BE/server/.gitignore @@ -28,4 +28,11 @@ lerna-debug.log* *.sublime-workspace # IDE - VSCode -/.vscode \ No newline at end of file +/.vscode + +# Environment Variable File +.env + +# csv, prisma +/operations +/prisma/generated diff --git a/nestjs-BE/.prettierrc b/nestjs-BE/server/.prettierrc similarity index 100% rename from nestjs-BE/.prettierrc rename to nestjs-BE/server/.prettierrc diff --git a/nestjs-BE/server/Dockerfile b/nestjs-BE/server/Dockerfile new file mode 100644 index 00000000..26d49afc --- /dev/null +++ b/nestjs-BE/server/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20.4.0-alpine + +WORKDIR /server + +COPY package.json package-lock.json ./ + +RUN npm ci + +COPY ./ ./ + +RUN npx prisma generate --schema=./prisma/mysql.schema.prisma + +RUN npx prisma generate --schema=./prisma/mongodb.schema.prisma + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/nestjs-BE/README.md b/nestjs-BE/server/README.md similarity index 100% rename from nestjs-BE/README.md rename to nestjs-BE/server/README.md diff --git a/nestjs-BE/nest-cli.json b/nestjs-BE/server/nest-cli.json similarity index 100% rename from nestjs-BE/nest-cli.json rename to nestjs-BE/server/nest-cli.json diff --git a/nestjs-BE/package-lock.json b/nestjs-BE/server/package-lock.json similarity index 90% rename from nestjs-BE/package-lock.json rename to nestjs-BE/server/package-lock.json index 6b32191a..9419e02f 100644 --- a/nestjs-BE/package-lock.json +++ b/nestjs-BE/server/package-lock.json @@ -11,11 +11,25 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/mongoose": "^10.0.2", + "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.2.8", + "@nestjs/schedule": "^4.0.0", + "@nestjs/swagger": "^7.1.16", "@nestjs/websockets": "^10.2.8", + "@prisma/client": "^5.6.0", + "aws-sdk": "^2.1510.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "dotenv": "^16.3.1", + "mongoose": "^8.0.2", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -23,7 +37,9 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", + "@types/passport-jwt": "^3.0.13", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -32,6 +48,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "prisma": "^5.6.0", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", @@ -1533,6 +1550,14 @@ "node": ">=8" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz", + "integrity": "sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/cli": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.2.1.tgz", @@ -1647,6 +1672,58 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "dependencies": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.3.tgz", + "integrity": "sha512-40Zdqg98lqoF0+7ThWIZFStxgzisK6GG22+1ABO4kZiGF/Tu2FE+DYLw+Q9D94vcFWizJ+MSjNN4ns9r6hIGxw==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/mongoose": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/mongoose/-/mongoose-10.0.2.tgz", + "integrity": "sha512-ITHh075DynjPIaKeJh6WkarS21WXYslu4nrLkNPbWaCP6JfxVAOftaA2X5tPSiiE/gNJWgs+QFWsfCFZUUenow==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "mongoose": "^6.0.2 || ^7.0.0 || ^8.0.0", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.0.0" + } + }, + "node_modules/@nestjs/passport": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.2.tgz", + "integrity": "sha512-od31vfB2z3y05IDB5dWSbCGE2+pAf2k2WCBinNuTTOxN0O0+wtO1L3kawj/aCW3YR9uxsTOVbTDwtwgpNNsnjQ==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "passport": "^0.4.0 || ^0.5.0 || ^0.6.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "10.2.8", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.2.8.tgz", @@ -1685,6 +1762,20 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz", + "integrity": "sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ==", + "dependencies": { + "cron": "3.1.3", + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.3.tgz", @@ -1701,6 +1792,37 @@ "typescript": ">=4.8.2" } }, + "node_modules/@nestjs/swagger": { + "version": "7.1.16", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.1.16.tgz", + "integrity": "sha512-f9KBk/BX9MUKPTj7tQNYJ124wV/jP5W2lwWHLGwe/4qQXixuDOo39zP55HIJ44LE7S04B7BOeUOo9GBJD/vRcw==", + "dependencies": { + "@nestjs/mapped-types": "2.0.3", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.2.0", + "swagger-ui-dist": "5.9.1" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.2.8", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.2.8.tgz", @@ -1832,6 +1954,38 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@prisma/client": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.6.0.tgz", + "integrity": "sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines-version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee" + }, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/engines": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.6.0.tgz", + "integrity": "sha512-Mt2q+GNJpU2vFn6kif24oRSBQv1KOkYaterQsi0k2/lA+dLvhRX6Lm26gon6PYHwUM8/h8KRgXIUMU0PCLB6bw==", + "devOptional": true, + "hasInstallScript": true + }, + "node_modules/@prisma/engines-version": { + "version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz", + "integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw==" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2069,12 +2223,34 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.6.tgz", + "integrity": "sha512-LblarKeI26YsMLxHDIQ0295wPSLjkl98eNwDcVhz3zbo1H+kfnkzR01H5Ai5LBzSeddX0ZJSpGwKEZihGb5diw==" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz", @@ -2083,6 +2259,36 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/passport": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", + "integrity": "sha512-FD0qD5hbPWQzaM0wHUnJ/T0BBCJBxCeemtnCwc/ThhTg3x9jfrAcRUmj5Dopza+MfFS9acTe3wk7rcVnRIp/0A==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.13.tgz", + "integrity": "sha512-fjHaC6Bv8EpMMqzTnHP32SXlZGaNfBPC/Po5dmRGYi2Ky7ljXPbGnOy+SxZqa6iZvFgVhoJ1915Re3m93zmcfA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.9.10", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", @@ -2147,6 +2353,25 @@ "@types/superagent": "*" } }, + "node_modules/@types/validator": { + "version": "13.11.7", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.7.tgz", + "integrity": "sha512-q0JomTsJ2I5Mv7dhHhQLGjMvX0JJm5dyZ1DXQySIUzU1UlwzB8bt+R6+LODUbz0UDIOvEzGc28tk27gBJw2N8Q==" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.31", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.31.tgz", @@ -2685,8 +2910,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -2720,6 +2944,68 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-sdk": { + "version": "2.1510.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1510.0.tgz", + "integrity": "sha512-XQj3QINBNseA5G9Vaa/iihNz3HCrzeyhxrOUjuH0AVxYqa5Q4cxaQhrWiAiUndtO2F70nfukEYe4cCUoTalUoQ==", + "dependencies": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.16.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "util": "^0.12.4", + "uuid": "8.0.0", + "xml2js": "0.5.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-sdk/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/aws-sdk/node_modules/events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/aws-sdk/node_modules/ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "node_modules/aws-sdk/node_modules/uuid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", + "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2846,7 +3132,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3036,6 +3321,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", + "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -3060,6 +3353,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3237,6 +3535,21 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", + "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "dependencies": { + "@types/validator": "^13.7.10", + "libphonenumber-js": "^1.10.14", + "validator": "^13.7.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -3539,6 +3852,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.3.tgz", + "integrity": "sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3872,12 +4194,31 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4637,6 +4978,14 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -4999,6 +5348,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", @@ -5191,6 +5554,21 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -5209,6 +5587,17 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -5263,6 +5652,20 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5332,6 +5735,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -6097,6 +6514,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6107,7 +6532,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6181,6 +6605,54 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6221,6 +6693,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.51", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.51.tgz", + "integrity": "sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6254,8 +6731,37 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -6269,6 +6775,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6294,6 +6805,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/macos-release": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", @@ -6368,6 +6887,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -6488,6 +7012,136 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mongodb": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", + "integrity": "sha512-d7OSuGjGWDZ5usZPqfvb36laQ9CPhnWkAGHT61x5P95p/8nMVeH8asloMwW6GcYFeB0Vj4CB/1wOTDG2RA9BFA==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.2.0", + "mongodb-connection-string-url": "^2.6.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongoose": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.0.2.tgz", + "integrity": "sha512-Vsi9GzTXjdBVzheT1HZOZ2jHNzzR9Xwb5OyLz/FvDEAhlwrRnXnuqJf0QHINUOQSm7aoyvnPks0q85HJkd6yDw==", + "dependencies": { + "bson": "^6.2.0", + "kareem": "2.5.1", + "mongodb": "6.2.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6822,6 +7476,40 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6894,6 +7582,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -7056,6 +7749,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.6.0.tgz", + "integrity": "sha512-EEaccku4ZGshdr2cthYHhf7iyvCcXqwJDvnoQRAJg5ge2Tzpv0e2BaMCp+CbbDUwoVTzwgOap9Zp+d4jFa2O9A==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.6.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -7100,7 +7809,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "engines": { "node": ">=6" } @@ -7135,6 +7843,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7500,6 +8217,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -7553,7 +8275,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -7568,7 +8289,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -7579,8 +8299,7 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { "version": "0.18.0", @@ -7736,6 +8455,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7828,6 +8552,14 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -8050,6 +8782,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.9.1.tgz", + "integrity": "sha512-5zAx+hUwJb9T3EAntc7TqYkV716CMqG6sZpNlAAMOMWkNXRYxGkN8ADIvD55dQZ10LxN90ZM/TQmN7y1gpICnw==" + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -8594,6 +9331,32 @@ "punycode": "^2.1.0" } }, + "node_modules/url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", + "dependencies": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8607,6 +9370,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -8627,6 +9402,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8782,6 +9565,24 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/windows-release": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-4.0.0.tgz", @@ -8927,6 +9728,26 @@ } } }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/nestjs-BE/package.json b/nestjs-BE/server/package.json similarity index 78% rename from nestjs-BE/package.json rename to nestjs-BE/server/package.json index 4352ced4..4abe9280 100644 --- a/nestjs-BE/package.json +++ b/nestjs-BE/server/package.json @@ -22,11 +22,25 @@ "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/mongoose": "^10.0.2", + "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-socket.io": "^10.2.8", + "@nestjs/schedule": "^4.0.0", + "@nestjs/swagger": "^7.1.16", "@nestjs/websockets": "^10.2.8", + "@prisma/client": "^5.6.0", + "aws-sdk": "^2.1510.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "dotenv": "^16.3.1", + "mongoose": "^8.0.2", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "uuid": "^9.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -34,7 +48,9 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", + "@types/passport-jwt": "^3.0.13", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -43,6 +59,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", + "prisma": "^5.6.0", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", diff --git a/nestjs-BE/server/prisma/migrations/20231206093559_init/migration.sql b/nestjs-BE/server/prisma/migrations/20231206093559_init/migration.sql new file mode 100644 index 00000000..72079c55 --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/20231206093559_init/migration.sql @@ -0,0 +1,74 @@ +-- CreateTable +CREATE TABLE `USER_TB` ( + `uuid` VARCHAR(32) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `provider` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `USER_TB_email_provider_key`(`email`, `provider`), + PRIMARY KEY (`uuid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `REFRESH_TOKEN_TB` ( + `uuid` VARCHAR(32) NOT NULL, + `token` VARCHAR(191) NOT NULL, + `expiry_date` DATETIME(3) NOT NULL, + `user_id` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `REFRESH_TOKEN_TB_token_key`(`token`), + PRIMARY KEY (`uuid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `PROFILE_TB` ( + `uuid` VARCHAR(32) NOT NULL, + `user_id` VARCHAR(32) NOT NULL, + `image` VARCHAR(191) NOT NULL, + `nickname` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `PROFILE_TB_user_id_key`(`user_id`), + PRIMARY KEY (`uuid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `SPACE_TB` ( + `uuid` VARCHAR(32) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `icon` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`uuid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `PROFILE_SPACE_TB` ( + `space_uuid` VARCHAR(32) NOT NULL, + `profile_uuid` VARCHAR(32) NOT NULL, + + PRIMARY KEY (`space_uuid`, `profile_uuid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `INVITE_CODE_TB` ( + `uuid` VARCHAR(32) NOT NULL, + `invite_code` VARCHAR(10) NOT NULL, + `space_uuid` VARCHAR(32) NOT NULL, + `expiry_date` DATETIME(3) NOT NULL, + + UNIQUE INDEX `INVITE_CODE_TB_invite_code_key`(`invite_code`), + PRIMARY KEY (`uuid`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `REFRESH_TOKEN_TB` ADD CONSTRAINT `REFRESH_TOKEN_TB_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `USER_TB`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `PROFILE_TB` ADD CONSTRAINT `PROFILE_TB_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `USER_TB`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `PROFILE_SPACE_TB` ADD CONSTRAINT `PROFILE_SPACE_TB_space_uuid_fkey` FOREIGN KEY (`space_uuid`) REFERENCES `SPACE_TB`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `PROFILE_SPACE_TB` ADD CONSTRAINT `PROFILE_SPACE_TB_profile_uuid_fkey` FOREIGN KEY (`profile_uuid`) REFERENCES `PROFILE_TB`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `INVITE_CODE_TB` ADD CONSTRAINT `INVITE_CODE_TB_space_uuid_fkey` FOREIGN KEY (`space_uuid`) REFERENCES `SPACE_TB`(`uuid`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/nestjs-BE/server/prisma/migrations/migration_lock.toml b/nestjs-BE/server/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..e5a788a7 --- /dev/null +++ b/nestjs-BE/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "mysql" \ No newline at end of file diff --git a/nestjs-BE/server/prisma/mongodb.schema.prisma b/nestjs-BE/server/prisma/mongodb.schema.prisma new file mode 100644 index 00000000..7fbb0264 --- /dev/null +++ b/nestjs-BE/server/prisma/mongodb.schema.prisma @@ -0,0 +1,14 @@ +generator client { + provider = "prisma-client-js" + output = "./generated/mongodb" +} + +datasource db { + provider = "mongodb" + url = env("MONGODB_DATABASE_URL") +} + +model BoardCollection { + uuid String @id @map("_id") + data Json +} \ No newline at end of file diff --git a/nestjs-BE/server/prisma/mysql.schema.prisma b/nestjs-BE/server/prisma/mysql.schema.prisma new file mode 100644 index 00000000..30b53a6e --- /dev/null +++ b/nestjs-BE/server/prisma/mysql.schema.prisma @@ -0,0 +1,60 @@ +generator client { + provider = "prisma-client-js" + output = "./generated/mysql" +} + +datasource db { + provider = "mysql" + url = env("MYSQL_DATABASE_URL") +} + +model USER_TB { + uuid String @id @db.VarChar(32) + email String + provider String + profiles PROFILE_TB[] + refresh_tokens REFRESH_TOKEN_TB[] + @@unique([email, provider]) +} + +model REFRESH_TOKEN_TB { + uuid String @id @db.VarChar(32) + token String @unique + expiry_date DateTime + user_id String + user USER_TB @relation(fields: [user_id], references: [uuid], onDelete: Cascade) +} + +model PROFILE_TB { + uuid String @id @db.VarChar(32) + user_id String @unique @db.VarChar(32) + image String + nickname String + user USER_TB @relation(fields: [user_id], references: [uuid], onDelete: Cascade) + spaces PROFILE_SPACE_TB[] +} + +model SPACE_TB { + uuid String @id @db.VarChar(32) + name String + icon String + profiles PROFILE_SPACE_TB[] + invite_codes INVITE_CODE_TB[] +} + +model PROFILE_SPACE_TB { + space_uuid String @db.VarChar(32) + profile_uuid String @db.VarChar(32) + space SPACE_TB @relation(fields: [space_uuid], references: [uuid], onDelete: Cascade) + profile PROFILE_TB @relation(fields: [profile_uuid], references: [uuid], onDelete: Cascade) + @@unique([space_uuid, profile_uuid]) +} + +model INVITE_CODE_TB { + uuid String @id @db.VarChar(32) + invite_code String @unique @db.VarChar(10) + space_uuid String @db.VarChar(32) + expiry_date DateTime + space SPACE_TB @relation(fields: [space_uuid], references: [uuid], onDelete: Cascade) +} + diff --git a/nestjs-BE/src/app.controller.spec.ts b/nestjs-BE/server/src/app.controller.spec.ts similarity index 100% rename from nestjs-BE/src/app.controller.spec.ts rename to nestjs-BE/server/src/app.controller.spec.ts diff --git a/nestjs-BE/src/app.controller.ts b/nestjs-BE/server/src/app.controller.ts similarity index 66% rename from nestjs-BE/src/app.controller.ts rename to nestjs-BE/server/src/app.controller.ts index cce879ee..4a545d13 100644 --- a/nestjs-BE/src/app.controller.ts +++ b/nestjs-BE/server/src/app.controller.ts @@ -1,12 +1,19 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { Public } from './auth/public.decorator'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} + @Public() @Get() getHello(): string { return this.appService.getHello(); } + + @Get('login-test') + authTest(): string { + return 'login success'; + } } diff --git a/nestjs-BE/server/src/app.module.ts b/nestjs-BE/server/src/app.module.ts new file mode 100644 index 00000000..59b27d08 --- /dev/null +++ b/nestjs-BE/server/src/app.module.ts @@ -0,0 +1,38 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { UsersModule } from './users/users.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { TemporaryDatabaseModule } from './temporary-database/temporary-database.module'; +import { ProfilesModule } from './profiles/profiles.module'; +import { SpacesModule } from './spaces/spaces.module'; +import { BoardsModule } from './boards/boards.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { UploadModule } from './upload/upload.module'; +import { MongooseModule } from '@nestjs/mongoose'; +import { InviteCodesModule } from './invite-codes/invite-codes.module'; +import { ProfileSpaceModule } from './profile-space/profile-space.module'; +import { BoardTreesModule } from './board-trees/board-trees.module'; +import customEnv from './config/env'; + +@Module({ + imports: [ + AuthModule, + UsersModule, + PrismaModule, + TemporaryDatabaseModule, + ScheduleModule.forRoot(), + ProfilesModule, + SpacesModule, + BoardsModule, + UploadModule, + MongooseModule.forRoot(customEnv.MONGODB_DATABASE_URI), + InviteCodesModule, + ProfileSpaceModule, + BoardTreesModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/nestjs-BE/src/app.service.ts b/nestjs-BE/server/src/app.service.ts similarity index 100% rename from nestjs-BE/src/app.service.ts rename to nestjs-BE/server/src/app.service.ts diff --git a/nestjs-BE/server/src/auth/auth.controller.ts b/nestjs-BE/server/src/auth/auth.controller.ts new file mode 100644 index 00000000..aad67c36 --- /dev/null +++ b/nestjs-BE/server/src/auth/auth.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Post, Body, NotFoundException } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { Public } from './public.decorator'; +import { KakaoUserDto } from './dto/kakao-user.dto'; +import { UsersService } from 'src/users/users.service'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { ProfilesService } from 'src/profiles/profiles.service'; +import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; + +@Controller('auth') +@ApiTags('auth') +export class AuthController { + constructor( + private authService: AuthService, + private usersService: UsersService, + private profilesService: ProfilesService, + ) {} + + @Post('kakao-oauth') + @Public() + @ApiOperation({ summary: 'kakao login' }) + @ApiResponse({ + status: 200, + description: 'Return the token data.', + }) + @ApiResponse({ + status: 404, + description: 'Not Found.', + }) + async kakaoLogin(@Body() kakaoUserDto: KakaoUserDto) { + const kakaoUserAccount = await this.authService.getKakaoAccount( + kakaoUserDto.kakaoUserId, + ); + if (!kakaoUserAccount) throw new NotFoundException(); + const email = kakaoUserAccount.email; + let userUuid = await this.authService.findUser( + this.usersService, + email, + 'kakao', + ); + if (!userUuid) { + const data = { email, provider: 'kakao' }; + userUuid = await this.authService.createUser( + data, + this.usersService, + this.profilesService, + ); + } + return this.authService.login(userUuid); + } + + @Post('token') + @Public() + @ApiOperation({ summary: 'Renew Access Token' }) + @ApiResponse({ + status: 200, + description: 'Return the access token data.', + }) + @ApiResponse({ + status: 401, + description: 'Refresh token expired. Please log in again.', + }) + renewAccessToken(@Body() refreshTokenDto: RefreshTokenDto) { + const refreshToken = refreshTokenDto.refresh_token; + return this.authService.renewAccessToken(refreshToken); + } + + @Post('logout') + @Public() + logout(@Body() refreshTokenDto: RefreshTokenDto) { + const refreshToken = refreshTokenDto.refresh_token; + return this.authService.remove(refreshToken); + } +} diff --git a/nestjs-BE/server/src/auth/auth.module.ts b/nestjs-BE/server/src/auth/auth.module.ts new file mode 100644 index 00000000..14b7cccd --- /dev/null +++ b/nestjs-BE/server/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { UsersModule } from 'src/users/users.module'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { JwtStrategy } from './jwt.strategy'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { ProfilesModule } from 'src/profiles/profiles.module'; + +@Module({ + imports: [UsersModule, PassportModule, JwtModule, ProfilesModule], + controllers: [AuthController], + providers: [ + AuthService, + JwtStrategy, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + ], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/nestjs-BE/server/src/auth/auth.service.ts b/nestjs-BE/server/src/auth/auth.service.ts new file mode 100644 index 00000000..87e931fc --- /dev/null +++ b/nestjs-BE/server/src/auth/auth.service.ts @@ -0,0 +1,147 @@ +import { Injectable, UnauthorizedException, HttpStatus } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { jwtConstants, kakaoOauthConstants } from './constants'; +import { stringify } from 'qs'; +import { PrismaServiceMySQL } from 'src/prisma/prisma.service'; +import { TemporaryDatabaseService } from 'src/temporary-database/temporary-database.service'; +import { BaseService } from 'src/base/base.service'; +import { + REFRESH_TOKEN_CACHE_SIZE, + REFRESH_TOKEN_EXPIRY_DAYS, +} from 'src/config/magic-number'; +import generateUuid from 'src/utils/uuid'; +import { UsersService } from 'src/users/users.service'; +import { ProfilesService } from 'src/profiles/profiles.service'; +import { CreateUserDto } from 'src/users/dto/create-user.dto'; +import customEnv from 'src/config/env'; +import { ResponseUtils } from 'src/utils/response'; +const { BASE_IMAGE_URL } = customEnv; + +export interface TokenData { + uuid?: string; + token: string; + expiry_date: Date; + user_id: string; +} + +@Injectable() +export class AuthService extends BaseService { + constructor( + private jwtService: JwtService, + protected prisma: PrismaServiceMySQL, + protected temporaryDatabaseService: TemporaryDatabaseService, + ) { + super({ + prisma, + temporaryDatabaseService, + cacheSize: REFRESH_TOKEN_CACHE_SIZE, + className: 'REFRESH_TOKEN_TB', + field: 'token', + }); + } + + generateKey(data: TokenData): string { + return data.token; + } + + async getKakaoAccount(kakaoUserId: number) { + const url = `https://kapi.kakao.com/v2/user/me`; + const queryParams = { target_id_type: 'user_id', target_id: kakaoUserId }; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `KakaoAK ${kakaoOauthConstants.adminKey}`, + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + body: stringify(queryParams), + }); + const responseBody = await response.json(); + if (!response.ok) return null; + return responseBody.kakao_account; + } + + async createAccessToken(userUuid: string): Promise { + const payload = { sub: userUuid }; + const accessToken = await this.jwtService.signAsync(payload, { + secret: jwtConstants.accessSecret, + expiresIn: '5m', + }); + return accessToken; + } + + async createRefreshToken(): Promise { + const refreshTokenPayload = { uuid: generateUuid() }; + const refreshToken = await this.jwtService.signAsync(refreshTokenPayload, { + secret: jwtConstants.refreshSecret, + expiresIn: '14d', + }); + return refreshToken; + } + + createRefreshTokenData(refreshToken: string, userUuid: string) { + const currentDate = new Date(); + const expiryDate = new Date(currentDate); + expiryDate.setDate(currentDate.getDate() + REFRESH_TOKEN_EXPIRY_DAYS); + const refreshTokenData: TokenData = { + token: refreshToken, + expiry_date: expiryDate, + user_id: userUuid, + }; + return refreshTokenData; + } + + async login(userUuid: string) { + const refreshToken = await this.createRefreshToken(); + const accessToken = await this.createAccessToken(userUuid); + const refreshTokenData = this.createRefreshTokenData( + refreshToken, + userUuid, + ); + super.create(refreshTokenData); + const tokenData = { + access_token: accessToken, + refresh_token: refreshToken, + }; + return ResponseUtils.createResponse(HttpStatus.OK, tokenData); + } + + async renewAccessToken(refreshToken: string) { + try { + this.jwtService.verify(refreshToken, { + secret: jwtConstants.refreshSecret, + }); + const { data: tokenData } = await this.findOne(refreshToken); + const accessToken = await this.createAccessToken(tokenData.user_id); + return ResponseUtils.createResponse(HttpStatus.OK, { + access_token: accessToken, + }); + } catch (error) { + super.remove(refreshToken); + throw new UnauthorizedException( + 'Refresh token expired. Please log in again.', + ); + } + } + + async findUser(usersService: UsersService, email: string, provider: string) { + const key = `email:${email}+provider:${provider}`; + const findUserData = await usersService.getDataFromCacheOrDB(key); + return findUserData?.uuid; + } + + async createUser( + data: CreateUserDto, + usersService: UsersService, + profilesService: ProfilesService, + ) { + const createdData = await usersService.create(data); + const userUuid = createdData.data.uuid; + const profileData = { + user_id: userUuid, + image: BASE_IMAGE_URL, + nickname: '익명의 사용자', + }; + profilesService.create(profileData); + return userUuid; + } +} diff --git a/nestjs-BE/server/src/auth/constants.ts b/nestjs-BE/server/src/auth/constants.ts new file mode 100644 index 00000000..bdb08300 --- /dev/null +++ b/nestjs-BE/server/src/auth/constants.ts @@ -0,0 +1,10 @@ +import customEnv from 'src/config/env'; + +export const jwtConstants = { + accessSecret: customEnv.JWT_ACCESS_SECRET, + refreshSecret: customEnv.JWT_REFRESH_SECRET, +}; + +export const kakaoOauthConstants = { + adminKey: customEnv.KAKAO_ADMIN_KEY, +}; diff --git a/nestjs-BE/server/src/auth/dto/kakao-user.dto.ts b/nestjs-BE/server/src/auth/dto/kakao-user.dto.ts new file mode 100644 index 00000000..e4173266 --- /dev/null +++ b/nestjs-BE/server/src/auth/dto/kakao-user.dto.ts @@ -0,0 +1,6 @@ +import { IsNumber } from 'class-validator'; + +export class KakaoUserDto { + @IsNumber() + kakaoUserId: number; +} diff --git a/nestjs-BE/server/src/auth/dto/refresh-token.dto.ts b/nestjs-BE/server/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 00000000..9978f844 --- /dev/null +++ b/nestjs-BE/server/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshTokenDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ + example: 'refresh token', + description: 'refresh token', + }) + refresh_token: string; +} diff --git a/nestjs-BE/server/src/auth/jwt-auth.guard.ts b/nestjs-BE/server/src/auth/jwt-auth.guard.ts new file mode 100644 index 00000000..b94a54e9 --- /dev/null +++ b/nestjs-BE/server/src/auth/jwt-auth.guard.ts @@ -0,0 +1,28 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from './public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } + + handleRequest(err, user) { + if (err || !user) throw err || new UnauthorizedException(); + return user; + } +} diff --git a/nestjs-BE/server/src/auth/jwt.strategy.ts b/nestjs-BE/server/src/auth/jwt.strategy.ts new file mode 100644 index 00000000..8a171c6c --- /dev/null +++ b/nestjs-BE/server/src/auth/jwt.strategy.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PassportStrategy } from '@nestjs/passport'; +import { jwtConstants } from './constants'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: jwtConstants.accessSecret, + }); + } + + async validate(payload: any) { + return { uuid: payload.sub }; + } +} diff --git a/nestjs-BE/server/src/auth/public.decorator.ts b/nestjs-BE/server/src/auth/public.decorator.ts new file mode 100644 index 00000000..b3845e12 --- /dev/null +++ b/nestjs-BE/server/src/auth/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/nestjs-BE/server/src/base/base.service.ts b/nestjs-BE/server/src/base/base.service.ts new file mode 100644 index 00000000..b0aa14fa --- /dev/null +++ b/nestjs-BE/server/src/base/base.service.ts @@ -0,0 +1,164 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { + PrismaServiceMySQL, + PrismaServiceMongoDB, +} from '../prisma/prisma.service'; +import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; +import LRUCache from '../utils/lru-cache'; +import generateUuid from '../utils/uuid'; +import { ResponseUtils } from 'src/utils/response'; + +interface BaseServiceOptions { + prisma: PrismaServiceMySQL | PrismaServiceMongoDB; + temporaryDatabaseService: TemporaryDatabaseService; + cacheSize: number; + className: string; + field: string; +} + +export interface HasUuid { + uuid?: string; +} + +@Injectable() +export abstract class BaseService { + protected cache: LRUCache; + protected className: string; + protected field: string; + protected prisma: PrismaServiceMySQL | PrismaServiceMongoDB; + protected temporaryDatabaseService: TemporaryDatabaseService; + + constructor(options: BaseServiceOptions) { + this.cache = new LRUCache(options.cacheSize); + this.className = options.className; + this.field = options.field; + this.prisma = options.prisma; + this.temporaryDatabaseService = options.temporaryDatabaseService; + } + + abstract generateKey(data: T): string; + + async create(data: T, generateUuidFlag: boolean = true) { + if (generateUuidFlag) data.uuid = generateUuid(); + const key = this.generateKey(data); + const storeData = await this.getDataFromCacheOrDB(key); + if (storeData) { + throw new HttpException('Data already exists.', HttpStatus.CONFLICT); + } + + this.temporaryDatabaseService.create(this.className, key, data); + this.cache.put(key, data); + return ResponseUtils.createResponse(HttpStatus.CREATED, data); + } + + async findOne(key: string) { + const data = await this.getDataFromCacheOrDB(key); + const deleteCommand = this.temporaryDatabaseService.get( + this.className, + key, + 'delete', + ); + if (deleteCommand) { + throw new HttpException('Not Found', HttpStatus.NOT_FOUND); + } + if (data) { + const mergedData = this.mergeWithUpdateCommand(data, key); + this.cache.put(key, mergedData); + return ResponseUtils.createResponse(HttpStatus.OK, mergedData); + } else { + throw new HttpException('Not Found', HttpStatus.NOT_FOUND); + } + } + + async update(key: string, updateData: T) { + const data = await this.getDataFromCacheOrDB(key); + if (data) { + const updatedData = { + field: this.field, + value: { ...data, ...updateData }, + }; + if (this.temporaryDatabaseService.get(this.className, key, 'insert')) { + this.temporaryDatabaseService.create( + this.className, + key, + updatedData.value, + ); + } else { + this.temporaryDatabaseService.update(this.className, key, updatedData); + } + this.cache.put(key, updatedData.value); + return ResponseUtils.createResponse(HttpStatus.OK, updatedData.value); + } else { + return ResponseUtils.createResponse(HttpStatus.NOT_FOUND); + } + } + + async remove(key: string) { + const storeData = await this.getDataFromCacheOrDB(key); + if (!storeData) return; + this.cache.delete(key); + const insertTemporaryData = this.temporaryDatabaseService.get( + this.className, + key, + 'insert', + ); + const updateTemporaryData = this.temporaryDatabaseService.get( + this.className, + key, + 'update', + ); + if (updateTemporaryData) { + this.temporaryDatabaseService.delete(this.className, key, 'update'); + } + if (insertTemporaryData) { + this.temporaryDatabaseService.delete(this.className, key, 'insert'); + } else { + this.temporaryDatabaseService.remove(this.className, key, { + field: this.field, + value: key, + }); + } + return ResponseUtils.createResponse(HttpStatus.NO_CONTENT); + } + + async getDataFromCacheOrDB(key: string): Promise { + if (!key) throw new HttpException('Bad Request', HttpStatus.BAD_REQUEST); + const cacheData = this.cache.get(key); + if (cacheData) return cacheData; + const temporaryDatabaseData = this.temporaryDatabaseService.get( + this.className, + key, + 'insert', + ); + if (temporaryDatabaseData) return temporaryDatabaseData; + const databaseData = await this.prisma[this.className].findUnique({ + where: { + [this.field]: key.includes('+') ? this.stringToObject(key) : key, + }, + }); + return databaseData; + } + + stringToObject(key: string) { + const obj = {}; + const keyValuePairs = key.split('+'); + + keyValuePairs.forEach((keyValue) => { + const [key, value] = keyValue.split(':'); + obj[key] = value; + }); + + return obj; + } + + private mergeWithUpdateCommand(data: T, key: string): T { + const updateCommand = this.temporaryDatabaseService.get( + this.className, + key, + 'update', + ); + if (updateCommand) return { ...data, ...updateCommand.value }; + + return data; + } +} diff --git a/nestjs-BE/server/src/board-trees/board-trees.gateway.ts b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts new file mode 100644 index 00000000..502e7182 --- /dev/null +++ b/nestjs-BE/server/src/board-trees/board-trees.gateway.ts @@ -0,0 +1,56 @@ +import { + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { BoardTreesService } from './board-trees.service'; +import { + OperationAdd, + OperationDelete, + OperationMove, + OperationUpdate, +} from 'src/crdt/operation'; + +@WebSocketGateway({ namespace: 'board' }) +export class BoardTreesGateway { + constructor(private boardTreesService: BoardTreesService) {} + + @WebSocketServer() + server: Server; + + @SubscribeMessage('joinBoard') + async handleJoinBoard(client: Socket, payload: string) { + const payloadObject = JSON.parse(payload); + if (!this.boardTreesService.hasTree(payloadObject.boardId)) { + await this.boardTreesService.initBoardTree(payloadObject.boardId); + } + client.join(payloadObject.boardId); + client.emit( + 'initTree', + this.boardTreesService.getTreeData(payloadObject.boardId), + ); + } + + @SubscribeMessage('updateMindmap') + handleUpdateMindmap(client: Socket, payload: string) { + const payloadObject = JSON.parse(payload); + const { boardId, operation: serializedOperation } = payloadObject; + + const operationTypeMap = { + add: OperationAdd.parse, + delete: OperationDelete.parse, + move: OperationMove.parse, + update: OperationUpdate.parse, + }; + + const operation = + operationTypeMap[serializedOperation.operationType](serializedOperation); + this.boardTreesService.applyOperation(boardId, operation); + this.boardTreesService.updateTreeData(boardId); + + client.broadcast + .to(boardId) + .emit('operationFromServer', serializedOperation); + } +} diff --git a/nestjs-BE/server/src/board-trees/board-trees.module.ts b/nestjs-BE/server/src/board-trees/board-trees.module.ts new file mode 100644 index 00000000..aada1901 --- /dev/null +++ b/nestjs-BE/server/src/board-trees/board-trees.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { BoardTreesService } from './board-trees.service'; +import { BoardTreesGateway } from './board-trees.gateway'; +import { MongooseModule } from '@nestjs/mongoose'; +import { BoardTree, BoardTreeSchema } from './schemas/board-tree.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: BoardTree.name, schema: BoardTreeSchema }, + ]), + ], + providers: [BoardTreesService, BoardTreesGateway], +}) +export class BoardTreesModule {} diff --git a/nestjs-BE/server/src/board-trees/board-trees.service.ts b/nestjs-BE/server/src/board-trees/board-trees.service.ts new file mode 100644 index 00000000..06750d73 --- /dev/null +++ b/nestjs-BE/server/src/board-trees/board-trees.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { BoardTree } from './schemas/board-tree.schema'; +import { Model } from 'mongoose'; +import { CrdtTree } from 'src/crdt/crdt-tree'; +import { Operation } from 'src/crdt/operation'; + +@Injectable() +export class BoardTreesService { + constructor( + @InjectModel(BoardTree.name) private boardTreeModel: Model, + ) {} + + private boardTrees = new Map>(); + + async create(boardId: string, tree: string) { + const createdTree = new this.boardTreeModel({ + boardId, + tree, + }); + return createdTree.save(); + } + + async findByBoardId(boardId: string) { + return this.boardTreeModel.findOne({ boardId }).exec(); + } + + getTreeData(boardId: string) { + const boardTree = this.boardTrees.get(boardId); + return JSON.stringify(boardTree); + } + + async initBoardTree(boardId: string) { + const existingTree = await this.findByBoardId(boardId); + if (existingTree) { + this.boardTrees.set(boardId, CrdtTree.parse(existingTree.tree)); + } else { + const newTree = new CrdtTree(boardId); + this.create(boardId, JSON.stringify(newTree)); + this.boardTrees.set(boardId, newTree); + } + } + + applyOperation(boardId: string, operation: Operation) { + const boardTree = this.boardTrees.get(boardId); + boardTree.applyOperation(operation); + } + + hasTree(boardId: string) { + return this.boardTrees.has(boardId); + } + + updateTreeData(boardId: string) { + const tree = this.boardTrees.get(boardId); + this.boardTreeModel + .updateOne({ boardId }, { tree: JSON.stringify(tree) }) + .exec(); + } +} diff --git a/nestjs-BE/server/src/board-trees/schemas/board-tree.schema.ts b/nestjs-BE/server/src/board-trees/schemas/board-tree.schema.ts new file mode 100644 index 00000000..949cd13c --- /dev/null +++ b/nestjs-BE/server/src/board-trees/schemas/board-tree.schema.ts @@ -0,0 +1,15 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type BoardTreeDocument = HydratedDocument; + +@Schema() +export class BoardTree { + @Prop() + boardId: string; + + @Prop() + tree: string; +} + +export const BoardTreeSchema = SchemaFactory.createForClass(BoardTree); diff --git a/nestjs-BE/server/src/boards/boards.controller.spec.ts b/nestjs-BE/server/src/boards/boards.controller.spec.ts new file mode 100644 index 00000000..f6de507d --- /dev/null +++ b/nestjs-BE/server/src/boards/boards.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardsController } from './boards.controller'; +import { BoardsService } from './boards.service'; + +describe('BoardsController', () => { + let controller: BoardsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BoardsController], + providers: [BoardsService], + }).compile(); + + controller = module.get(BoardsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/boards/boards.controller.ts b/nestjs-BE/server/src/boards/boards.controller.ts new file mode 100644 index 00000000..4abf9c5e --- /dev/null +++ b/nestjs-BE/server/src/boards/boards.controller.ts @@ -0,0 +1,184 @@ +import { + Controller, + Get, + Post, + Body, + HttpCode, + HttpStatus, + Query, + Patch, + NotFoundException, + UseInterceptors, + UploadedFile, +} from '@nestjs/common'; +import { BoardsService } from './boards.service'; +import { CreateBoardDto } from './dto/create-board.dto'; +import { + ApiBody, + ApiConflictResponse, + ApiConsumes, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { Public } from 'src/auth/public.decorator'; +import { DeleteBoardDto } from './dto/delete-board.dto'; +import { RestoreBoardDto } from './dto/restore-board.dto'; +import { + BoardListSuccess, + CreateBoardFailure, + CreateBoardSuccess, + DeleteBoardFailure, + DeleteBoardSuccess, + RestoreBoardFailure, + RestoreBoardSuccess, +} from './swagger/boards.type'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { UploadService } from 'src/upload/upload.service'; +import customEnv from 'src/config/env'; + +const BOARD_EXPIRE_DAY = 7; + +@Controller('boards') +@ApiTags('boards') +export class BoardsController { + constructor( + private boardsService: BoardsService, + private uploadService: UploadService, + ) {} + + @ApiOperation({ + summary: '보드 생성', + description: '보드 이름, 스페이스 id, 이미지 url을 받아서 보드를 생성한다.', + }) + @ApiBody({ type: CreateBoardDto }) + @ApiCreatedResponse({ + type: CreateBoardSuccess, + description: '보드 생성 완료', + }) + @ApiConflictResponse({ + type: CreateBoardFailure, + description: '보드가 이미 존재함', + }) + @Public() + @Post('create') + @UseInterceptors(FileInterceptor('image')) + @ApiConsumes('multipart/form-data') + @HttpCode(HttpStatus.CREATED) + async createBoard( + @Body() createBoardDto: CreateBoardDto, + @UploadedFile() image: Express.Multer.File, + ) { + await this.boardsService.findByNameAndSpaceId( + createBoardDto.boardName, + createBoardDto.spaceId, + ); + const imageUrl = image + ? await this.uploadService.uploadFile(image) + : customEnv.APP_ICON_URL; + const document = await this.boardsService.create(createBoardDto, imageUrl); + const responseData = { + boardId: document.uuid, + date: document.createdAt, + imageUrl, + }; + return { + statusCode: HttpStatus.CREATED, + message: 'Board created.', + data: responseData, + }; + } + + @ApiOperation({ + summary: '보드 목록 불러오기', + description: '스페이스 id를 받아서 보드 목록을 불러온다.', + }) + @ApiQuery({ + name: 'spaceId', + required: true, + description: '보드 목록을 불러올 스페이스 id', + }) + @ApiOkResponse({ + type: BoardListSuccess, + description: '보드 목록 불러오기 완료', + }) + @Public() + @Get('list') + async findBySpaceId(@Query('spaceId') spaceId: string) { + const boardList = await this.boardsService.findBySpaceId(spaceId); + const responseData = boardList.reduce((list, board) => { + let isDeleted = false; + + if (board.deletedAt && board.deletedAt > board.restoredAt) { + const expireDate = new Date(board.deletedAt); + expireDate.setDate(board.deletedAt.getDate() + BOARD_EXPIRE_DAY); + if (new Date() > expireDate) { + this.boardsService.deleteExpiredBoard(board.uuid); + return list; + } + isDeleted = true; + } + + list.push({ + boardId: board.uuid, + boardName: board.boardName, + createdAt: board.createdAt, + imageUrl: board.imageUrl, + isDeleted, + }); + return list; + }, []); + return { + statusCode: HttpStatus.OK, + message: 'Retrieved board list.', + data: responseData, + }; + } + + @ApiOperation({ + summary: '보드 삭제', + description: '삭제할 보드 id를 받아서 보드를 삭제한다.', + }) + @ApiBody({ type: DeleteBoardDto }) + @ApiOkResponse({ type: DeleteBoardSuccess, description: '보드 삭제 완료' }) + @ApiNotFoundResponse({ + type: DeleteBoardFailure, + description: '보드가 존재하지 않음', + }) + @Public() + @Patch('delete') + async deleteBoard(@Body() deleteBoardDto: DeleteBoardDto) { + const updateResult = await this.boardsService.deleteBoard( + deleteBoardDto.boardId, + ); + if (!updateResult.matchedCount) { + throw new NotFoundException('Target board not found.'); + } + return { statusCode: HttpStatus.OK, message: 'Board deleted.' }; + } + + @ApiOperation({ + summary: '보드 복구', + description: '복구할 보드 id를 받아서 보드를 복구한다.', + }) + @ApiBody({ type: RestoreBoardDto }) + @ApiOkResponse({ type: RestoreBoardSuccess, description: '보드 복구 완료' }) + @ApiNotFoundResponse({ + type: RestoreBoardFailure, + description: '보드가 존재하지 않음', + }) + @Public() + @Patch('restore') + async restoreBoard(@Body() resotreBoardDto: RestoreBoardDto) { + const updateResult = await this.boardsService.restoreBoard( + resotreBoardDto.boardId, + ); + if (!updateResult.matchedCount) { + throw new NotFoundException('Target board not found.'); + } + return { statusCode: HttpStatus.OK, message: 'Board restored.' }; + } +} diff --git a/nestjs-BE/server/src/boards/boards.module.ts b/nestjs-BE/server/src/boards/boards.module.ts new file mode 100644 index 00000000..6a826401 --- /dev/null +++ b/nestjs-BE/server/src/boards/boards.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { BoardsService } from './boards.service'; +import { BoardsController } from './boards.controller'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Board, BoardSchema } from './schemas/board.schema'; +import { UploadModule } from 'src/upload/upload.module'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Board.name, schema: BoardSchema }]), + UploadModule, + ], + controllers: [BoardsController], + providers: [BoardsService], +}) +export class BoardsModule {} diff --git a/nestjs-BE/server/src/boards/boards.service.spec.ts b/nestjs-BE/server/src/boards/boards.service.spec.ts new file mode 100644 index 00000000..a1da1cdb --- /dev/null +++ b/nestjs-BE/server/src/boards/boards.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BoardsService } from './boards.service'; + +describe('BoardsService', () => { + let service: BoardsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BoardsService], + }).compile(); + + service = module.get(BoardsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/boards/boards.service.ts b/nestjs-BE/server/src/boards/boards.service.ts new file mode 100644 index 00000000..5034d71c --- /dev/null +++ b/nestjs-BE/server/src/boards/boards.service.ts @@ -0,0 +1,54 @@ +import { Injectable, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Board } from './schemas/board.schema'; +import { Model } from 'mongoose'; +import { CreateBoardDto } from './dto/create-board.dto'; +import { v4 } from 'uuid'; + +@Injectable() +export class BoardsService { + constructor(@InjectModel(Board.name) private boardModel: Model) {} + + async create( + createBoardDto: CreateBoardDto, + imageUrl: string | null, + ): Promise { + const { boardName, spaceId } = createBoardDto; + const uuid = v4(); + const now = new Date(); + const createdBoard = new this.boardModel({ + boardName, + imageUrl, + spaceId, + uuid, + createdAt: now, + restoredAt: now, + }); + return createdBoard.save(); + } + + async findByNameAndSpaceId(boardName: string, spaceId: string) { + const existingBoard = await this.boardModel + .findOne({ boardName, spaceId }) + .exec(); + if (existingBoard) throw new ConflictException('Board already exist.'); + } + + async findBySpaceId(spaceId: string): Promise { + return this.boardModel.find({ spaceId }).exec(); + } + + async deleteBoard(boardId: string) { + const now = new Date(); + return this.boardModel.updateOne({ uuid: boardId }, { deletedAt: now }); + } + + async deleteExpiredBoard(boardId: string) { + return this.boardModel.deleteOne({ uuid: boardId }); + } + + async restoreBoard(boardId: string) { + const now = new Date(); + return this.boardModel.updateOne({ uuid: boardId }, { restoredAt: now }); + } +} diff --git a/nestjs-BE/server/src/boards/dto/create-board.dto.ts b/nestjs-BE/server/src/boards/dto/create-board.dto.ts new file mode 100644 index 00000000..a958215b --- /dev/null +++ b/nestjs-BE/server/src/boards/dto/create-board.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateBoardDto { + @ApiProperty({ description: '보드 이름' }) + @IsString() + @IsNotEmpty() + boardName: string; + + @ApiProperty({ description: '스페이스 id' }) + @IsString() + @IsNotEmpty() + spaceId: string; + + @ApiProperty({ format: 'binary', description: '이미지' }) + image: string; +} diff --git a/nestjs-BE/server/src/boards/dto/delete-board.dto.ts b/nestjs-BE/server/src/boards/dto/delete-board.dto.ts new file mode 100644 index 00000000..03bbeb18 --- /dev/null +++ b/nestjs-BE/server/src/boards/dto/delete-board.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteBoardDto { + @ApiProperty({ description: '삭제할 보드 id' }) + @IsString() + @IsNotEmpty() + boardId: string; +} diff --git a/nestjs-BE/server/src/boards/dto/restore-board.dto.ts b/nestjs-BE/server/src/boards/dto/restore-board.dto.ts new file mode 100644 index 00000000..e5829261 --- /dev/null +++ b/nestjs-BE/server/src/boards/dto/restore-board.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RestoreBoardDto { + @ApiProperty({ description: '복구할 보드 id' }) + @IsString() + @IsNotEmpty() + boardId: string; +} diff --git a/nestjs-BE/server/src/boards/schemas/board.schema.ts b/nestjs-BE/server/src/boards/schemas/board.schema.ts new file mode 100644 index 00000000..e5045504 --- /dev/null +++ b/nestjs-BE/server/src/boards/schemas/board.schema.ts @@ -0,0 +1,30 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; + +export type BoardDocument = HydratedDocument; + +@Schema() +export class Board { + @Prop() + uuid: string; + + @Prop() + boardName: string; + + @Prop() + spaceId: string; + + @Prop() + createdAt: Date; + + @Prop() + restoredAt: Date; + + @Prop() + deletedAt: Date; + + @Prop() + imageUrl: string; +} + +export const BoardSchema = SchemaFactory.createForClass(Board); diff --git a/nestjs-BE/server/src/boards/swagger/boards.type.ts b/nestjs-BE/server/src/boards/swagger/boards.type.ts new file mode 100644 index 00000000..9a36321c --- /dev/null +++ b/nestjs-BE/server/src/boards/swagger/boards.type.ts @@ -0,0 +1,107 @@ +import { HttpStatus } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreatedBoard { + @ApiProperty({ description: '생성한 보드 id' }) + boardId: string; + + @ApiProperty({ description: '보드를 생성한 UTC 시각' }) + date: Date; + + @ApiProperty({ description: '보드 이미지 url' }) + imageUrl: string; +} + +export class CreateBoardSuccess { + @ApiProperty({ example: HttpStatus.CREATED, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ example: 'Board created', description: '응답 메세지' }) + message: string; + + @ApiProperty({ description: '데이터' }) + data: CreatedBoard; +} + +export class CreateBoardFailure { + @ApiProperty({ example: HttpStatus.CONFLICT, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ example: 'Board already exist.', description: '응답 메세지' }) + message: string; + + @ApiProperty({ example: 'Conflict', description: '응답 메세지' }) + error: string; +} + +export class BoardInSpace { + @ApiProperty({ description: '보드 id' }) + boardId: string; + + @ApiProperty({ description: '보드 이름' }) + boardName: string; + + @ApiProperty({ description: '보드를 생성한 UTC 시각' }) + createdAt: Date; + + @ApiProperty({ description: '보드 이미지 url' }) + imageUrl: string; + + @ApiProperty({ description: '삭제 여부' }) + isDeleted: boolean; +} + +export class BoardListSuccess { + @ApiProperty({ example: HttpStatus.OK, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ example: 'Retrieved board list.', description: '응답 메세지' }) + message: string; + + @ApiProperty({ isArray: true, description: '데이터' }) + data: BoardInSpace; +} + +export class DeleteBoardSuccess { + @ApiProperty({ example: HttpStatus.OK, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ example: 'Board deleted.', description: '응답 메세지' }) + message: string; +} + +export class DeleteBoardFailure { + @ApiProperty({ example: HttpStatus.NOT_FOUND, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ + example: 'Target board not found.', + description: '응답 메세지', + }) + message: string; + + @ApiProperty({ example: 'Not Found', description: '응답 메세지' }) + error: string; +} + +export class RestoreBoardSuccess { + @ApiProperty({ example: HttpStatus.OK, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ example: 'Board restored.', description: '응답 메세지' }) + message: string; +} + +export class RestoreBoardFailure { + @ApiProperty({ example: HttpStatus.NOT_FOUND, description: '응답 코드' }) + statusCode: number; + + @ApiProperty({ + example: 'Target board not found.', + description: '응답 메세지', + }) + message: string; + + @ApiProperty({ example: 'Not Found', description: '응답 메세지' }) + error: string; +} diff --git a/nestjs-BE/server/src/config/env.ts b/nestjs-BE/server/src/config/env.ts new file mode 100644 index 00000000..eba1fb25 --- /dev/null +++ b/nestjs-BE/server/src/config/env.ts @@ -0,0 +1,10 @@ +import { config } from 'dotenv'; + +interface CustomEnv { + [key: string]: string; +} + +const customEnv: CustomEnv = {}; +config({ processEnv: customEnv }); + +export default customEnv; diff --git a/nestjs-BE/server/src/config/magic-number.ts b/nestjs-BE/server/src/config/magic-number.ts new file mode 100644 index 00000000..75e72de4 --- /dev/null +++ b/nestjs-BE/server/src/config/magic-number.ts @@ -0,0 +1,12 @@ +export const USER_CACHE_SIZE = 1000; +export const PROFILE_CACHE_SIZE = 5000; +export const SPACE_CACHE_SIZE = 10000; +export const BOARD_CACHE_SIZE = 10000; +export const REFRESH_TOKEN_CACHE_SIZE = 1000; +export const PROFILE_SPACE_CACHE_SIZE = 1; +export const USER_SPACE_CACHE_SIZE = 1000; +export const SPACE_USER_CACHE_SIZE = 1000; +export const REFRESH_TOKEN_EXPIRY_DAYS = 14; +export const INVITE_CODE_CACHE_SIZE = 5000; +export const INVITE_CODE_LENGTH = 10; +export const INVITE_CODE_EXPIRY_HOURS = 6; diff --git a/nestjs-BE/server/src/config/swagger.ts b/nestjs-BE/server/src/config/swagger.ts new file mode 100644 index 00000000..92b38238 --- /dev/null +++ b/nestjs-BE/server/src/config/swagger.ts @@ -0,0 +1,6 @@ +import { DocumentBuilder } from '@nestjs/swagger'; +export const swaggerConfig = new DocumentBuilder() + .setTitle('mind-sync cache-server-api') + .setDescription('API description') + .setVersion('1.0') + .build(); diff --git a/nestjs-BE/server/src/crdt/clock.ts b/nestjs-BE/server/src/crdt/clock.ts new file mode 100644 index 00000000..9b807e89 --- /dev/null +++ b/nestjs-BE/server/src/crdt/clock.ts @@ -0,0 +1,40 @@ +export enum COMPARE { + GREATER, + LESS, +} + +export class Clock { + id: string; + counter: number; + + constructor(id: string, counter: number = 0) { + this.id = id; + this.counter = counter; + } + + increment() { + this.counter++; + } + + copy(): Clock { + return new Clock(this.id, this.counter); + } + + merge(remoteClock: Clock): Clock { + return new Clock(this.id, Math.max(this.counter, remoteClock.counter)); + } + + compare(remoteClock: Clock): COMPARE { + if (this.counter > remoteClock.counter) return COMPARE.GREATER; + if (this.counter === remoteClock.counter && this.id > remoteClock.id) { + return COMPARE.GREATER; + } + return COMPARE.LESS; + } + + static parse(json: string) { + const parsedJson = JSON.parse(json); + const clock = new Clock(parsedJson.id, parsedJson.counter); + return clock; + } +} diff --git a/nestjs-BE/server/src/crdt/crdt-tree.ts b/nestjs-BE/server/src/crdt/crdt-tree.ts new file mode 100644 index 00000000..d8791b93 --- /dev/null +++ b/nestjs-BE/server/src/crdt/crdt-tree.ts @@ -0,0 +1,137 @@ +import { Clock, COMPARE } from './clock'; +import { + Operation, + OperationAdd, + OperationAddInput, + OperationDelete, + OperationInput, + OperationLog, + OperationMove, + OperationMoveInput, + OperationUpdate, + OperationUpdateInput, +} from './operation'; +import { Tree } from './tree'; +import { Node } from './node'; + +export class CrdtTree { + operationLogs: OperationLog[] = []; + clock: Clock; + tree = new Tree(); + + constructor(id: string) { + this.clock = new Clock(id); + } + + get(id: string): Node | undefined { + return this.tree.get(id); + } + + addLog(log: OperationLog) { + this.operationLogs.push(log); + } + + generateOperationAdd( + targetId: string, + parentId: string, + description: T, + ): OperationAdd { + this.clock.increment(); + const clock = this.clock.copy(); + const input: OperationAddInput = { + id: targetId, + parentId, + description, + clock, + }; + return new OperationAdd(input); + } + + generateOperationDelete(targetId: string): OperationDelete { + this.clock.increment(); + const clock = this.clock.copy(); + const input: OperationInput = { + id: targetId, + clock, + }; + return new OperationDelete(input); + } + + generateOperationMove(targetId: string, parentId: string): OperationMove { + this.clock.increment(); + const clock = this.clock.copy(); + const input: OperationMoveInput = { + id: targetId, + parentId, + clock, + }; + return new OperationMove(input); + } + + generateOperationUpdate( + targetId: string, + description: T, + ): OperationUpdate { + this.clock.increment(); + const clock = this.clock.copy(); + const input: OperationUpdateInput = { + id: targetId, + description, + clock, + }; + return new OperationUpdate(input); + } + + applyOperation(operation: Operation) { + this.clock = this.clock.merge(operation.clock); + + if (this.operationLogs.length === 0) { + const log = operation.doOperation(this.tree); + this.addLog(log); + return; + } + + const lastOperation = + this.operationLogs[this.operationLogs.length - 1].operation; + if (operation.clock.compare(lastOperation.clock) === COMPARE.LESS) { + const prevLog = this.operationLogs.pop(); + prevLog.operation.undoOperation(this.tree, prevLog); + this.applyOperation(operation); + const redoLog = prevLog.operation.redoOperation(this.tree, prevLog); + this.addLog(redoLog); + } else { + const log = operation.doOperation(this.tree); + this.addLog(log); + } + } + + applyOperations(operations: Operation[]) { + for (const operation of operations) this.applyOperation(operation); + } + + static parse(json: string) { + const parsedJson = JSON.parse(json); + const crdtTree = new CrdtTree('0'); + crdtTree.clock = Clock.parse(JSON.stringify(parsedJson.clock)); + crdtTree.tree = Tree.parse(JSON.stringify(parsedJson.tree)); + + const operationTypeMap = { + add: OperationAdd.parse, + delete: OperationDelete.parse, + move: OperationMove.parse, + update: OperationUpdate.parse, + }; + + const parsedOperationLogs = parsedJson.operationLogs.map( + (operationLog: OperationLog) => { + const operationType = operationLog.operation.operationType; + operationLog.operation = operationTypeMap[operationType]( + operationLog.operation, + ); + return operationLog; + }, + ); + crdtTree.operationLogs = parsedOperationLogs; + return crdtTree; + } +} diff --git a/nestjs-BE/server/src/crdt/node.ts b/nestjs-BE/server/src/crdt/node.ts new file mode 100644 index 00000000..78d825f3 --- /dev/null +++ b/nestjs-BE/server/src/crdt/node.ts @@ -0,0 +1,27 @@ +export class Node { + targetId: string; + parentId: string; + description: T; + children = new Array(); + + constructor( + targetId: string, + parentId: string = '0', + description: T | null = null, + ) { + this.targetId = targetId; + this.parentId = parentId; + this.description = description; + } + + static parse(json: string) { + const parsedJson = JSON.parse(json); + const node = new Node( + parsedJson.targetId, + parsedJson.parentId, + parsedJson.description, + ); + node.children = parsedJson.children; + return node; + } +} diff --git a/nestjs-BE/server/src/crdt/operation.ts b/nestjs-BE/server/src/crdt/operation.ts new file mode 100644 index 00000000..ef2704c7 --- /dev/null +++ b/nestjs-BE/server/src/crdt/operation.ts @@ -0,0 +1,211 @@ +import { Clock } from './clock'; +import { Tree } from './tree'; + +export interface OperationLog { + operation: Operation; + oldParentId?: string; + oldDescription?: T; +} + +export interface OperationInput { + id: string; + clock: Clock; +} + +export interface OperationAddInput extends OperationInput { + description: T; + parentId: string; +} + +export interface OperationMoveInput extends OperationInput { + parentId: string; +} + +export interface OperationUpdateInput extends OperationInput { + description: T; +} + +interface ClockInterface { + id: string; + counter: number; +} + +export interface SerializedOperation { + operationType: string; + id: string; + clock: ClockInterface; + description?: T; + parentId?: string; +} + +export abstract class Operation { + operationType: string; + id: string; + clock: Clock; + + constructor(operationType: string, id: string, clock: Clock) { + this.operationType = operationType; + this.id = id; + this.clock = clock; + } + + abstract doOperation(tree: Tree): OperationLog; + abstract undoOperation(tree: Tree, log: OperationLog): void; + abstract redoOperation(tree: Tree, log: OperationLog): OperationLog; +} + +export class OperationAdd extends Operation { + description: T; + parentId: string; + + constructor(input: OperationAddInput) { + super('add', input.id, input.clock); + this.description = input.description; + this.parentId = input.parentId; + } + + doOperation(tree: Tree): OperationLog { + tree.addNode(this.id, this.parentId, this.description); + return { operation: this }; + } + + undoOperation(tree: Tree, log: OperationLog): void { + tree.removeNode(log.operation.id); + } + + redoOperation(tree: Tree, log: OperationLog): OperationLog { + tree.attachNode(log.operation.id, this.parentId); + return { operation: this }; + } + + static parse( + serializedOperation: SerializedOperation, + ): OperationAdd { + const input: OperationAddInput = { + id: serializedOperation.id, + parentId: serializedOperation.parentId, + description: serializedOperation.description, + clock: new Clock( + serializedOperation.clock.id, + serializedOperation.clock.counter, + ), + }; + return new OperationAdd(input); + } +} + +export class OperationDelete extends Operation { + constructor(input: OperationInput) { + super('delete', input.id, input.clock); + } + + doOperation(tree: Tree): OperationLog { + const node = tree.get(this.id); + const oldParentId = node.parentId; + tree.removeNode(this.id); + return { operation: this, oldParentId: oldParentId }; + } + + undoOperation(tree: Tree, log: OperationLog): void { + tree.attachNode(log.operation.id, log.oldParentId); + } + + redoOperation(tree: Tree, log: OperationLog): OperationLog { + const redoLog = log.operation.doOperation(tree); + return redoLog; + } + + static parse( + serializedOperation: SerializedOperation, + ): OperationDelete { + const input: OperationInput = { + id: serializedOperation.id, + clock: new Clock( + serializedOperation.clock.id, + serializedOperation.clock.counter, + ), + }; + return new OperationDelete(input); + } +} + +export class OperationMove extends Operation { + parentId: string; + + constructor(input: OperationMoveInput) { + super('move', input.id, input.clock); + this.parentId = input.parentId; + } + + doOperation(tree: Tree): OperationLog { + const node = tree.get(this.id); + const oldParentId = node.parentId; + + tree.removeNode(this.id); + tree.attachNode(this.id, this.parentId); + return { operation: this, oldParentId }; + } + + undoOperation(tree: Tree, log: OperationLog): void { + tree.removeNode(log.operation.id); + tree.attachNode(log.operation.id, log.oldParentId); + } + + redoOperation(tree: Tree, log: OperationLog): OperationLog { + const redoLog = log.operation.doOperation(tree); + return redoLog; + } + + static parse( + serializedOperation: SerializedOperation, + ): OperationMove { + const input: OperationMoveInput = { + id: serializedOperation.id, + parentId: serializedOperation.parentId, + clock: new Clock( + serializedOperation.clock.id, + serializedOperation.clock.counter, + ), + }; + return new OperationMove(input); + } +} + +export class OperationUpdate extends Operation { + description: T; + + constructor(input: OperationUpdateInput) { + super('update', input.id, input.clock); + this.description = input.description; + } + + doOperation(tree: Tree): OperationLog { + const node = tree.get(this.id); + const oldDescription = node.description; + tree.updateNode(this.id, this.description); + return { operation: this, oldDescription: oldDescription }; + } + + undoOperation(tree: Tree, log: OperationLog): void { + tree.updateNode(log.operation.id, log.oldDescription); + } + + redoOperation(tree: Tree, log: OperationLog): OperationLog { + const redoLog = log.operation.doOperation(tree); + return redoLog; + } + + static parse( + serializedOperation: SerializedOperation, + ): OperationUpdate { + const input: OperationUpdateInput = { + id: serializedOperation.id, + description: serializedOperation.description, + clock: new Clock( + serializedOperation.clock.id, + serializedOperation.clock.counter, + ), + }; + return new OperationUpdate(input); + } +} diff --git a/nestjs-BE/server/src/crdt/tree.ts b/nestjs-BE/server/src/crdt/tree.ts new file mode 100644 index 00000000..f0ea9066 --- /dev/null +++ b/nestjs-BE/server/src/crdt/tree.ts @@ -0,0 +1,69 @@ +import { Node } from './node'; + +export class Tree { + nodes = new Map>(); + + constructor() { + this.nodes.set('root', new Node('root')); + } + + get(id: string): Node | undefined { + return this.nodes.get(id); + } + + addNode(targetId: string, parentId: string, description: T) { + const newNode = new Node(targetId, parentId, description); + + const parentNode = this.nodes.get(parentId); + if (!parentNode) return; + + parentNode.children.push(targetId); + this.nodes.set(targetId, newNode); + } + + attachNode(targetId: string, parentId: string) { + const targetNode = this.nodes.get(targetId); + if (!targetNode) return; + + const parentNode = this.nodes.get(parentId); + if (!parentNode) return; + + parentNode.children.push(targetId); + targetNode.parentId = parentId; + } + + removeNode(targetId: string): Node { + const targetNode = this.nodes.get(targetId); + if (!targetNode) return; + + const parentNode = this.nodes.get(targetNode.parentId); + if (!parentNode) return; + + const targetIndex = parentNode.children.indexOf(targetId); + if (targetIndex !== -1) parentNode.children.splice(targetIndex, 1); + + return this.nodes.get(targetId); + } + + updateNode(targetId: string, description: T) { + const targetNode = this.nodes.get(targetId); + if (!targetNode) return; + + targetNode.description = description; + } + + toJSON() { + return { nodes: Array.from(this.nodes.values()) }; + } + + static parse(json: string) { + const { nodes } = JSON.parse(json); + const tree = new Tree(); + tree.nodes = new Map>(); + nodes.forEach((nodeJson) => { + const node = Node.parse(JSON.stringify(nodeJson)); + tree.nodes.set(node.targetId, node); + }); + return tree; + } +} diff --git a/nestjs-BE/server/src/invite-codes/dto/create-invite-code.dto.ts b/nestjs-BE/server/src/invite-codes/dto/create-invite-code.dto.ts new file mode 100644 index 00000000..da0501c3 --- /dev/null +++ b/nestjs-BE/server/src/invite-codes/dto/create-invite-code.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateInviteCodeDto { + @ApiProperty({ + example: 'space uuid', + description: 'Space UUID', + }) + @IsNotEmpty() + @IsString() + space_uuid: string; +} diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts b/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts new file mode 100644 index 00000000..f6e10e3d --- /dev/null +++ b/nestjs-BE/server/src/invite-codes/invite-codes.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Post, Body, Param } from '@nestjs/common'; +import { InviteCodesService } from './invite-codes.service'; +import { CreateInviteCodeDto } from './dto/create-invite-code.dto'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@Controller('inviteCodes') +@ApiTags('inviteCodes') +export class InviteCodesController { + constructor(private readonly inviteCodesService: InviteCodesService) {} + + @Post() + @ApiOperation({ summary: 'Create invite code' }) + @ApiResponse({ + status: 201, + description: 'The invite code has been successfully created.', + }) + @ApiResponse({ + status: 400, + description: 'Space code input is missing.', + }) + @ApiResponse({ + status: 404, + description: 'Space not found.', + }) + create(@Body() createInviteCodeDto: CreateInviteCodeDto) { + return this.inviteCodesService.createCode(createInviteCodeDto); + } + + @Get(':inviteCode') + @ApiOperation({ summary: 'Find space by invite code' }) + @ApiResponse({ + status: 200, + description: 'Returns a space associated with the invite code.', + }) + @ApiResponse({ + status: 404, + description: 'Invite code not found.', + }) + @ApiResponse({ + status: 410, + description: 'Invite code has expired', + }) + findSpace(@Param('inviteCode') inviteCode: string) { + return this.inviteCodesService.findSpace(inviteCode); + } +} diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.module.ts b/nestjs-BE/server/src/invite-codes/invite-codes.module.ts new file mode 100644 index 00000000..ba6ec305 --- /dev/null +++ b/nestjs-BE/server/src/invite-codes/invite-codes.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InviteCodesService } from './invite-codes.service'; +import { InviteCodesController } from './invite-codes.controller'; +import { SpacesService } from 'src/spaces/spaces.service'; + +@Module({ + controllers: [InviteCodesController], + providers: [InviteCodesService, SpacesService], +}) +export class InviteCodesModule {} diff --git a/nestjs-BE/server/src/invite-codes/invite-codes.service.ts b/nestjs-BE/server/src/invite-codes/invite-codes.service.ts new file mode 100644 index 00000000..8c8f54e7 --- /dev/null +++ b/nestjs-BE/server/src/invite-codes/invite-codes.service.ts @@ -0,0 +1,115 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { CreateInviteCodeDto } from './dto/create-invite-code.dto'; +import { BaseService } from 'src/base/base.service'; +import { PrismaServiceMySQL } from 'src/prisma/prisma.service'; +import { TemporaryDatabaseService } from 'src/temporary-database/temporary-database.service'; +import { + INVITE_CODE_CACHE_SIZE, + INVITE_CODE_EXPIRY_HOURS, + INVITE_CODE_LENGTH, +} from 'src/config/magic-number'; +import { SpacesService } from 'src/spaces/spaces.service'; +import { ResponseUtils } from 'src/utils/response'; + +export interface InviteCodeData extends CreateInviteCodeDto { + uuid?: string; + invite_code: string; + expiry_date: Date; +} + +@Injectable() +export class InviteCodesService extends BaseService { + constructor( + protected prisma: PrismaServiceMySQL, + protected temporaryDatabaseService: TemporaryDatabaseService, + protected spacesService: SpacesService, + ) { + super({ + prisma, + temporaryDatabaseService, + cacheSize: INVITE_CODE_CACHE_SIZE, + className: 'INVITE_CODE_TB', + field: 'invite_code', + }); + } + + generateKey(data: InviteCodeData): string { + return data.invite_code; + } + + async createCode(createInviteCodeDto: CreateInviteCodeDto) { + const { space_uuid: spaceUuid } = createInviteCodeDto; + await this.spacesService.findOne(spaceUuid); + const inviteCodeData = await this.generateInviteCode(createInviteCodeDto); + super.create(inviteCodeData); + const { invite_code } = inviteCodeData; + return ResponseUtils.createResponse(HttpStatus.CREATED, { invite_code }); + } + + async findSpace(inviteCode: string) { + const inviteCodeData = await this.getInviteCodeData(inviteCode); + this.checkExpiry(inviteCode, inviteCodeData.expiry_date); + const spaceResponse = await this.spacesService.findOne( + inviteCodeData.space_uuid, + ); + return spaceResponse; + } + + private async generateInviteCode(createInviteCodeDto: CreateInviteCodeDto) { + const uniqueInviteCode = + await this.generateUniqueInviteCode(INVITE_CODE_LENGTH); + const expiryDate = this.calculateExpiryDate(); + + return { + ...createInviteCodeDto, + invite_code: uniqueInviteCode, + expiry_date: expiryDate, + }; + } + + private calculateExpiryDate(): Date { + const currentDate = new Date(); + const expiryDate = new Date(currentDate); + expiryDate.setHours(currentDate.getHours() + INVITE_CODE_EXPIRY_HOURS); + return expiryDate; + } + + private async getInviteCodeData(inviteCode: string) { + const inviteCodeResponse = await super.findOne(inviteCode); + const { data: inviteCodeData } = inviteCodeResponse; + return inviteCodeData; + } + + private checkExpiry(inviteCode: string, expiryDate: Date) { + const currentTimestamp = new Date().getTime(); + const expiryTimestamp = new Date(expiryDate).getTime(); + if (expiryTimestamp < currentTimestamp) { + super.remove(inviteCode); + throw new HttpException('Invite code has expired.', HttpStatus.GONE); + } + } + + private generateShortInviteCode(length: number) { + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let inviteCode = ''; + for (let i = 0; i < length; i++) { + inviteCode += characters.charAt( + Math.floor(Math.random() * characters.length), + ); + } + return inviteCode; + } + + private async generateUniqueInviteCode(length: number): Promise { + let inviteCode: string; + let inviteCodeData: InviteCodeData; + + do { + inviteCode = this.generateShortInviteCode(length); + inviteCodeData = await super.getDataFromCacheOrDB(inviteCode); + } while (inviteCodeData !== null); + + return inviteCode; + } +} diff --git a/nestjs-BE/server/src/main.ts b/nestjs-BE/server/src/main.ts new file mode 100644 index 00000000..2f997991 --- /dev/null +++ b/nestjs-BE/server/src/main.ts @@ -0,0 +1,16 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { SwaggerModule } from '@nestjs/swagger'; +import { swaggerConfig } from './config/swagger'; +import { ValidationPipe } from '@nestjs/common'; +import customEnv from 'src/config/env'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api-docs', app, document); + app.useGlobalPipes(new ValidationPipe()); + + await app.listen(customEnv.SERVER_PORT); +} +bootstrap(); diff --git a/nestjs-BE/server/src/prisma/prisma.module.ts b/nestjs-BE/server/src/prisma/prisma.module.ts new file mode 100644 index 00000000..01b27f2d --- /dev/null +++ b/nestjs-BE/server/src/prisma/prisma.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaServiceMySQL } from './prisma.service'; +import { PrismaServiceMongoDB } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaServiceMySQL, PrismaServiceMongoDB], + exports: [PrismaServiceMySQL, PrismaServiceMongoDB], +}) +export class PrismaModule {} diff --git a/nestjs-BE/server/src/prisma/prisma.service.spec.ts b/nestjs-BE/server/src/prisma/prisma.service.spec.ts new file mode 100644 index 00000000..a68cb9e3 --- /dev/null +++ b/nestjs-BE/server/src/prisma/prisma.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from './prisma.service'; + +describe('PrismaService', () => { + let service: PrismaService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PrismaService], + }).compile(); + + service = module.get(PrismaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/prisma/prisma.service.ts b/nestjs-BE/server/src/prisma/prisma.service.ts new file mode 100644 index 00000000..5559b4ea --- /dev/null +++ b/nestjs-BE/server/src/prisma/prisma.service.ts @@ -0,0 +1,23 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient as PrismaClientMySQL } from '../../prisma/generated/mysql'; +import { PrismaClient as PrismaClientMongoDB } from '../../prisma/generated/mongodb'; + +@Injectable() +export class PrismaServiceMySQL + extends PrismaClientMySQL + implements OnModuleInit +{ + async onModuleInit() { + await this.$connect(); + } +} + +@Injectable() +export class PrismaServiceMongoDB + extends PrismaClientMongoDB + implements OnModuleInit +{ + async onModuleInit() { + await this.$connect(); + } +} diff --git a/nestjs-BE/server/src/profile-space/dto/create-profile-space.dto.ts b/nestjs-BE/server/src/profile-space/dto/create-profile-space.dto.ts new file mode 100644 index 00000000..fdd16f5c --- /dev/null +++ b/nestjs-BE/server/src/profile-space/dto/create-profile-space.dto.ts @@ -0,0 +1,14 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateProfileSpaceDto { + @ApiProperty({ + example: 'space uuid', + description: 'Space UUID', + }) + @IsNotEmpty() + @IsString() + space_uuid: string; + + profile_uuid: string; +} diff --git a/nestjs-BE/server/src/profile-space/dto/update-profile-space.dto.ts b/nestjs-BE/server/src/profile-space/dto/update-profile-space.dto.ts new file mode 100644 index 00000000..9bef8027 --- /dev/null +++ b/nestjs-BE/server/src/profile-space/dto/update-profile-space.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateProfileSpaceDto } from './create-profile-space.dto'; + +export class UpdateProfileSpaceDto extends PartialType(CreateProfileSpaceDto) { + uuid?: string; +} diff --git a/nestjs-BE/server/src/profile-space/profile-space.controller.ts b/nestjs-BE/server/src/profile-space/profile-space.controller.ts new file mode 100644 index 00000000..e10e3642 --- /dev/null +++ b/nestjs-BE/server/src/profile-space/profile-space.controller.ts @@ -0,0 +1,99 @@ +import { + Controller, + Get, + Post, + Body, + Delete, + Param, + Request as Req, +} from '@nestjs/common'; +import { ProfileSpaceService } from './profile-space.service'; +import { CreateProfileSpaceDto } from './dto/create-profile-space.dto'; +import { RequestWithUser } from 'src/utils/interface'; +import { SpacesService } from 'src/spaces/spaces.service'; +import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; + +@Controller('profileSpace') +@ApiTags('profileSpace') +export class ProfileSpaceController { + constructor( + private readonly profileSpaceService: ProfileSpaceService, + private readonly spacesService: SpacesService, + ) {} + + @Post('join') + @ApiOperation({ summary: 'Join space' }) + @ApiResponse({ + status: 201, + description: 'Join data has been successfully created.', + }) + @ApiResponse({ + status: 409, + description: 'Conflict. You have already joined the space.', + }) + async create( + @Body() createProfileSpaceDto: CreateProfileSpaceDto, + @Req() req: RequestWithUser, + ) { + const userUuid = req.user.uuid; + const { space_uuid } = createProfileSpaceDto; + const { joinData, profileData } = + await this.profileSpaceService.processData(userUuid, space_uuid); + const responseData = await this.profileSpaceService.create(joinData); + const data = await this.spacesService.processData(space_uuid, profileData); + await this.profileSpaceService.put(userUuid, space_uuid, data); + return responseData; + } + + @Delete('leave/:space_uuid') + @ApiResponse({ + status: 204, + description: 'Successfully left the space.', + }) + @ApiResponse({ + status: 404, + description: 'Space not found.', + }) + async delete( + @Param('space_uuid') spaceUuid: string, + @Req() req: RequestWithUser, + ) { + const userUuid = req.user.uuid; + const { joinData, profileData } = + await this.profileSpaceService.processData(userUuid, spaceUuid); + await this.spacesService.processData(spaceUuid, profileData); + const isSpaceEmpty = await this.profileSpaceService.delete( + userUuid, + spaceUuid, + profileData, + ); + if (isSpaceEmpty) this.spacesService.remove(spaceUuid); + const key = this.profileSpaceService.generateKey(joinData); + return this.profileSpaceService.remove(key); + } + + @Get('spaces') + @ApiOperation({ summary: 'Get user’s spaces' }) + @ApiResponse({ + status: 200, + description: 'Returns a list of spaces.', + }) + getSpaces(@Req() req: RequestWithUser) { + const userUuid = req.user.uuid; + return this.profileSpaceService.retrieveUserSpaces(userUuid); + } + + @Get('users/:space_uuid') + @ApiOperation({ summary: 'Get users in the space' }) + @ApiResponse({ + status: 200, + description: 'Returns a list of users.', + }) + @ApiResponse({ + status: 404, + description: 'Space not found.', + }) + getUsers(@Param('space_uuid') spaceUuid: string) { + return this.profileSpaceService.retrieveSpaceUsers(spaceUuid); + } +} diff --git a/nestjs-BE/server/src/profile-space/profile-space.module.ts b/nestjs-BE/server/src/profile-space/profile-space.module.ts new file mode 100644 index 00000000..c79af8a9 --- /dev/null +++ b/nestjs-BE/server/src/profile-space/profile-space.module.ts @@ -0,0 +1,13 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { ProfileSpaceService } from './profile-space.service'; +import { ProfileSpaceController } from './profile-space.controller'; +import { ProfilesModule } from 'src/profiles/profiles.module'; +import { SpacesModule } from 'src/spaces/spaces.module'; + +@Module({ + imports: [ProfilesModule, forwardRef(() => SpacesModule)], + controllers: [ProfileSpaceController], + providers: [ProfileSpaceService], + exports: [ProfileSpaceService], +}) +export class ProfileSpaceModule {} diff --git a/nestjs-BE/server/src/profile-space/profile-space.service.ts b/nestjs-BE/server/src/profile-space/profile-space.service.ts new file mode 100644 index 00000000..a451a220 --- /dev/null +++ b/nestjs-BE/server/src/profile-space/profile-space.service.ts @@ -0,0 +1,164 @@ +import { Injectable, HttpStatus } from '@nestjs/common'; +import { UpdateProfileSpaceDto } from './dto/update-profile-space.dto'; +import { BaseService } from 'src/base/base.service'; +import { PrismaServiceMySQL } from 'src/prisma/prisma.service'; +import { TemporaryDatabaseService } from 'src/temporary-database/temporary-database.service'; +import { + PROFILE_SPACE_CACHE_SIZE, + SPACE_USER_CACHE_SIZE, + USER_SPACE_CACHE_SIZE, +} from 'src/config/magic-number'; +import { CreateProfileSpaceDto } from './dto/create-profile-space.dto'; +import { ProfilesService } from 'src/profiles/profiles.service'; +import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto'; +import { UpdateSpaceDto } from 'src/spaces/dto/update-space.dto'; +import LRUCache from 'src/utils/lru-cache'; +import { ResponseUtils } from 'src/utils/response'; + +interface UpdateProfileAndSpaceDto { + profileData: UpdateProfileDto; + spaceData: UpdateSpaceDto; +} + +@Injectable() +export class ProfileSpaceService extends BaseService { + private readonly userCache: LRUCache; + private readonly spaceCache: LRUCache; + constructor( + protected prisma: PrismaServiceMySQL, + protected temporaryDatabaseService: TemporaryDatabaseService, + private readonly profilesService: ProfilesService, + ) { + super({ + prisma, + temporaryDatabaseService, + cacheSize: PROFILE_SPACE_CACHE_SIZE, + className: 'PROFILE_SPACE_TB', + field: 'space_uuid_profile_uuid', + }); + this.userCache = new LRUCache(USER_SPACE_CACHE_SIZE); + this.spaceCache = new LRUCache(SPACE_USER_CACHE_SIZE); + } + + generateKey(data: CreateProfileSpaceDto) { + return `space_uuid:${data.space_uuid}+profile_uuid:${data.profile_uuid}`; + } + + async create(data: CreateProfileSpaceDto) { + const response = await super.create(data, false); + return response; + } + + async processData(userUuid: string, spaceUuid: string) { + const profileResponse = await this.profilesService.findOne(userUuid); + const profileUuid = profileResponse.data?.uuid; + const joinData = { + profile_uuid: profileUuid, + space_uuid: spaceUuid, + }; + return { joinData, profileData: profileResponse.data }; + } + + async put( + userUuid: string, + spaceUuid: string, + data: UpdateProfileAndSpaceDto, + ) { + const { spaceData, profileData } = data; + const userSpaces = await this.fetchUserSpacesFromCacheOrDB( + userUuid, + profileData.uuid, + ); + userSpaces.push(spaceData); + this.userCache.put(userUuid, userSpaces); + const spaceProfiles = await this.fetchSpaceUsersFromCacheOrDB(spaceUuid); + spaceProfiles.push(profileData); + this.spaceCache.put(spaceUuid, spaceProfiles); + } + + async delete( + userUuid: string, + spaceUuid: string, + profileData: UpdateProfileDto, + ) { + const userSpaces = await this.fetchUserSpacesFromCacheOrDB( + userUuid, + profileData.uuid, + ); + const filterUserSpaces = userSpaces.filter( + (space) => space.uuid !== spaceUuid, + ); + this.userCache.put(userUuid, filterUserSpaces); + const spaceUsers = await this.fetchSpaceUsersFromCacheOrDB(spaceUuid); + const filterSpaceUsers = spaceUsers.filter( + (profile) => profile.uuid !== profileData.uuid, + ); + this.spaceCache.put(spaceUuid, filterSpaceUsers); + return filterSpaceUsers.length === 0; + } + + async fetchUserSpacesFromCacheOrDB( + userUuid: string, + profileUuid: string, + ): Promise { + const cacheUserSpaces = this.userCache.get(userUuid); + if (cacheUserSpaces) return cacheUserSpaces; + const profileResponse = await this.prisma['PROFILE_TB'].findUnique({ + where: { uuid: profileUuid }, + include: { + spaces: { + include: { + space: true, + }, + }, + }, + }); + const storeUserSpaces = + profileResponse?.spaces.map((profileSpace) => profileSpace.space) || []; + return storeUserSpaces; + } + + async fetchSpaceUsersFromCacheOrDB( + spaceUuid: string, + ): Promise { + const cacheSpaceProfiles = this.spaceCache.get(spaceUuid); + if (cacheSpaceProfiles) return cacheSpaceProfiles; + + const spaceResponse = await this.prisma['SPACE_TB'].findUnique({ + where: { uuid: spaceUuid }, + include: { + profiles: { + include: { + profile: true, + }, + }, + }, + }); + + const storeSpaceProfiles = + spaceResponse?.profiles.map((profileSpace) => profileSpace.profile) || []; + return storeSpaceProfiles; + } + + async retrieveUserSpaces(userUuid: string) { + const profileResponse = await this.profilesService.findOne(userUuid); + const profileUuid = profileResponse.data?.uuid; + const spaces = await this.fetchUserSpacesFromCacheOrDB( + userUuid, + profileUuid, + ); + this.userCache.put(userUuid, spaces); + return ResponseUtils.createResponse(HttpStatus.OK, spaces); + } + + async retrieveSpaceUsers(spaceUuid: string) { + const users = await this.fetchSpaceUsersFromCacheOrDB(spaceUuid); + const usersData = await Promise.all( + users.map(async (user) => { + return await this.profilesService.findOne(user.user_id); + }), + ); + this.spaceCache.put(spaceUuid, usersData); + return ResponseUtils.createResponse(HttpStatus.OK, usersData); + } +} diff --git a/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts b/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts new file mode 100644 index 00000000..905a78e3 --- /dev/null +++ b/nestjs-BE/server/src/profiles/dto/create-profile.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateProfileDto { + user_id: string; + + @ApiProperty({ + example: 'profile-image.png', + description: 'Profile image file', + }) + image: string; + + @ApiProperty({ + example: 'Sample nickname', + description: 'Nickname for the profile', + }) + nickname: string; +} diff --git a/nestjs-BE/server/src/profiles/dto/profile-space.dto.ts b/nestjs-BE/server/src/profiles/dto/profile-space.dto.ts new file mode 100644 index 00000000..19fad8d4 --- /dev/null +++ b/nestjs-BE/server/src/profiles/dto/profile-space.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ProfileSpaceDto { + @ApiProperty({ + example: 'profile-uuid-123', + description: 'UUID of the profile', + }) + profile_uuid: string; + + @ApiProperty({ example: 'space-uuid-456', description: 'UUID of the space' }) + space_uuid: string; +} diff --git a/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts b/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts new file mode 100644 index 00000000..c6e4c50e --- /dev/null +++ b/nestjs-BE/server/src/profiles/dto/update-profile.dto.ts @@ -0,0 +1,21 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProfileDto } from './create-profile.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateProfileDto extends PartialType(CreateProfileDto) { + @ApiProperty({ + example: 'new nickname', + description: 'Updated nickname of the profile', + required: false, + }) + nickname?: string; + + @ApiProperty({ + example: 'new image.png', + description: 'Updated Profile image file', + required: false, + }) + image?: string; + + uuid?: string; +} diff --git a/nestjs-BE/server/src/profiles/entities/profile.entity.ts b/nestjs-BE/server/src/profiles/entities/profile.entity.ts new file mode 100644 index 00000000..b4a8829d --- /dev/null +++ b/nestjs-BE/server/src/profiles/entities/profile.entity.ts @@ -0,0 +1 @@ +export class Profile {} diff --git a/nestjs-BE/server/src/profiles/profiles.controller.spec.ts b/nestjs-BE/server/src/profiles/profiles.controller.spec.ts new file mode 100644 index 00000000..752ac281 --- /dev/null +++ b/nestjs-BE/server/src/profiles/profiles.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProfilesController } from './profiles.controller'; +import { ProfilesService } from './profiles.service'; + +describe('ProfilesController', () => { + let controller: ProfilesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProfilesController], + providers: [ProfilesService], + }).compile(); + + controller = module.get(ProfilesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/profiles/profiles.controller.ts b/nestjs-BE/server/src/profiles/profiles.controller.ts new file mode 100644 index 00000000..c59bff3b --- /dev/null +++ b/nestjs-BE/server/src/profiles/profiles.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Body, + Patch, + UseInterceptors, + UploadedFile, + Request as Req, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ProfilesService } from './profiles.service'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { UploadService } from 'src/upload/upload.service'; +import { RequestWithUser } from 'src/utils/interface'; + +@Controller('profiles') +@ApiTags('profiles') +export class ProfilesController { + constructor( + private readonly profilesService: ProfilesService, + private readonly uploadService: UploadService, + ) {} + + @Get() + @ApiOperation({ summary: 'Get profile' }) + @ApiResponse({ + status: 200, + description: 'Return the profile data.', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized.', + }) + findOne(@Req() req: RequestWithUser) { + return this.profilesService.findOne(req.user.uuid); + } + + @Patch() + @UseInterceptors(FileInterceptor('image')) + @ApiOperation({ summary: 'Update profile' }) + @ApiResponse({ + status: 200, + description: 'Profile has been successfully updated.', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized.', + }) + async update( + @UploadedFile() image: Express.Multer.File, + @Req() req: RequestWithUser, + @Body() updateProfileDto: UpdateProfileDto, + ) { + if (image) { + updateProfileDto.image = await this.uploadService.uploadFile(image); + } + return this.profilesService.update(req.user.uuid, updateProfileDto); + } +} diff --git a/nestjs-BE/server/src/profiles/profiles.module.ts b/nestjs-BE/server/src/profiles/profiles.module.ts new file mode 100644 index 00000000..97674fa9 --- /dev/null +++ b/nestjs-BE/server/src/profiles/profiles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ProfilesService } from './profiles.service'; +import { ProfilesController } from './profiles.controller'; +import { UploadService } from 'src/upload/upload.service'; + +@Module({ + controllers: [ProfilesController], + providers: [ProfilesService, UploadService], + exports: [ProfilesService], +}) +export class ProfilesModule {} diff --git a/nestjs-BE/server/src/profiles/profiles.service.spec.ts b/nestjs-BE/server/src/profiles/profiles.service.spec.ts new file mode 100644 index 00000000..29cbe78f --- /dev/null +++ b/nestjs-BE/server/src/profiles/profiles.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProfilesService } from './profiles.service'; + +describe('ProfilesService', () => { + let service: ProfilesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ProfilesService], + }).compile(); + + service = module.get(ProfilesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/profiles/profiles.service.ts b/nestjs-BE/server/src/profiles/profiles.service.ts new file mode 100644 index 00000000..0a1027fb --- /dev/null +++ b/nestjs-BE/server/src/profiles/profiles.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaServiceMySQL } from '../prisma/prisma.service'; +import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; +import { BaseService } from '../base/base.service'; +import { PROFILE_CACHE_SIZE } from 'src/config/magic-number'; +import { UpdateProfileDto } from './dto/update-profile.dto'; + +@Injectable() +export class ProfilesService extends BaseService { + constructor( + protected prisma: PrismaServiceMySQL, + protected temporaryDatabaseService: TemporaryDatabaseService, + ) { + super({ + prisma, + temporaryDatabaseService, + cacheSize: PROFILE_CACHE_SIZE, + className: 'PROFILE_TB', + field: 'user_id', + }); + } + + generateKey(data: UpdateProfileDto): string { + return data.user_id; + } +} diff --git a/nestjs-BE/server/src/spaces/dto/create-space.dto.ts b/nestjs-BE/server/src/spaces/dto/create-space.dto.ts new file mode 100644 index 00000000..23317491 --- /dev/null +++ b/nestjs-BE/server/src/spaces/dto/create-space.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateSpaceDto { + @IsString() + @IsNotEmpty() + @ApiProperty({ example: 'Sample Space', description: 'Name of the space' }) + name: string; + + @ApiProperty({ + example: 'space-icon.png', + description: 'Profile icon for the space', + }) + icon: string; +} diff --git a/nestjs-BE/server/src/spaces/dto/update-space.dto.ts b/nestjs-BE/server/src/spaces/dto/update-space.dto.ts new file mode 100644 index 00000000..017ec463 --- /dev/null +++ b/nestjs-BE/server/src/spaces/dto/update-space.dto.ts @@ -0,0 +1,21 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSpaceDto } from './create-space.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateSpaceDto extends PartialType(CreateSpaceDto) { + @ApiProperty({ + example: 'new space', + description: 'Updated space name', + required: false, + }) + name?: string; + + @ApiProperty({ + example: 'new image', + description: 'Updated space icon', + required: false, + }) + icon?: string; + + uuid?: string; +} diff --git a/nestjs-BE/server/src/spaces/entities/space.entity.ts b/nestjs-BE/server/src/spaces/entities/space.entity.ts new file mode 100644 index 00000000..c75a03e4 --- /dev/null +++ b/nestjs-BE/server/src/spaces/entities/space.entity.ts @@ -0,0 +1 @@ +export class Space {} diff --git a/nestjs-BE/server/src/spaces/spaces.controller.spec.ts b/nestjs-BE/server/src/spaces/spaces.controller.spec.ts new file mode 100644 index 00000000..bc7556ae --- /dev/null +++ b/nestjs-BE/server/src/spaces/spaces.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpacesController } from './spaces.controller'; +import { SpacesService } from './spaces.service'; + +describe('SpacesController', () => { + let controller: SpacesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SpacesController], + providers: [SpacesService], + }).compile(); + + controller = module.get(SpacesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/spaces/spaces.controller.ts b/nestjs-BE/server/src/spaces/spaces.controller.ts new file mode 100644 index 00000000..39c025fd --- /dev/null +++ b/nestjs-BE/server/src/spaces/spaces.controller.ts @@ -0,0 +1,99 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + UseInterceptors, + UploadedFile, + Request as Req, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { SpacesService } from './spaces.service'; +import { CreateSpaceDto } from './dto/create-space.dto'; +import { UpdateSpaceDto } from './dto/update-space.dto'; +import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; +import { UploadService } from 'src/upload/upload.service'; +import { ProfileSpaceService } from 'src/profile-space/profile-space.service'; +import { RequestWithUser } from 'src/utils/interface'; +import customEnv from 'src/config/env'; +const { APP_ICON_URL } = customEnv; + +@Controller('spaces') +@ApiTags('spaces') +export class SpacesController { + constructor( + private readonly spacesService: SpacesService, + private readonly uploadService: UploadService, + private readonly profileSpaceService: ProfileSpaceService, + ) {} + + @Post() + @UseInterceptors(FileInterceptor('icon')) + @ApiOperation({ summary: 'Create space' }) + @ApiResponse({ + status: 201, + description: 'The space has been successfully created.', + }) + async create( + @UploadedFile() icon: Express.Multer.File, + @Body() createSpaceDto: CreateSpaceDto, + @Req() req: RequestWithUser, + ) { + const iconUrl = icon + ? await this.uploadService.uploadFile(icon) + : APP_ICON_URL; + createSpaceDto.icon = iconUrl; + const response = await this.spacesService.create(createSpaceDto); + const { uuid: spaceUuid } = response.data; + const userUuid = req.user.uuid; + const { joinData, profileData } = + await this.profileSpaceService.processData(userUuid, spaceUuid); + this.profileSpaceService.create(joinData); + const spaceData = response.data; + const data = { profileData, spaceData }; + await this.profileSpaceService.put(userUuid, spaceUuid, data); + return response; + } + + @Get(':space_uuid') + @ApiOperation({ summary: 'Get space by space_uuid' }) + @ApiResponse({ + status: 200, + description: 'Return the space data.', + }) + @ApiResponse({ + status: 404, + description: 'Space not found.', + }) + findOne(@Param('space_uuid') spaceUuid: string) { + return this.spacesService.findOne(spaceUuid); + } + + @Patch(':space_uuid') + @UseInterceptors(FileInterceptor('icon')) + @ApiOperation({ summary: 'Update space by space_uuid' }) + @ApiResponse({ + status: 200, + description: 'Space has been successfully updated.', + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. Invalid input data.', + }) + @ApiResponse({ + status: 404, + description: 'Space not found.', + }) + async update( + @UploadedFile() icon: Express.Multer.File, + @Param('space_uuid') spaceUuid: string, + @Body() updateSpaceDto: UpdateSpaceDto, + ) { + if (icon) { + updateSpaceDto.icon = await this.uploadService.uploadFile(icon); + } + return this.spacesService.update(spaceUuid, updateSpaceDto); + } +} diff --git a/nestjs-BE/server/src/spaces/spaces.module.ts b/nestjs-BE/server/src/spaces/spaces.module.ts new file mode 100644 index 00000000..2964682c --- /dev/null +++ b/nestjs-BE/server/src/spaces/spaces.module.ts @@ -0,0 +1,13 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { SpacesService } from './spaces.service'; +import { SpacesController } from './spaces.controller'; +import { UploadService } from 'src/upload/upload.service'; +import { ProfileSpaceModule } from 'src/profile-space/profile-space.module'; + +@Module({ + imports: [forwardRef(() => ProfileSpaceModule)], + controllers: [SpacesController], + providers: [SpacesService, UploadService], + exports: [SpacesService], +}) +export class SpacesModule {} diff --git a/nestjs-BE/server/src/spaces/spaces.service.spec.ts b/nestjs-BE/server/src/spaces/spaces.service.spec.ts new file mode 100644 index 00000000..6b9d4876 --- /dev/null +++ b/nestjs-BE/server/src/spaces/spaces.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpacesService } from './spaces.service'; + +describe('SpacesService', () => { + let service: SpacesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SpacesService], + }).compile(); + + service = module.get(SpacesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/spaces/spaces.service.ts b/nestjs-BE/server/src/spaces/spaces.service.ts new file mode 100644 index 00000000..4eba8ff0 --- /dev/null +++ b/nestjs-BE/server/src/spaces/spaces.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaServiceMySQL } from '../prisma/prisma.service'; +import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; +import { BaseService } from '../base/base.service'; +import { SPACE_CACHE_SIZE } from 'src/config/magic-number'; +import { UpdateSpaceDto } from './dto/update-space.dto'; +import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto'; + +@Injectable() +export class SpacesService extends BaseService { + constructor( + protected prisma: PrismaServiceMySQL, + protected temporaryDatabaseService: TemporaryDatabaseService, + ) { + super({ + prisma, + temporaryDatabaseService, + cacheSize: SPACE_CACHE_SIZE, + className: 'SPACE_TB', + field: 'uuid', + }); + } + + generateKey(data: UpdateSpaceDto): string { + return data.uuid; + } + + async processData(spaceUuid: string, profileData: UpdateProfileDto) { + const spaceResponseData = await super.findOne(spaceUuid); + const spaceData = spaceResponseData.data; + const data = { profileData, spaceData }; + return data; + } +} diff --git a/nestjs-BE/server/src/temporary-database/temporary-database.module.ts b/nestjs-BE/server/src/temporary-database/temporary-database.module.ts new file mode 100644 index 00000000..d3a2f58a --- /dev/null +++ b/nestjs-BE/server/src/temporary-database/temporary-database.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { TemporaryDatabaseService } from './temporary-database.service'; + +@Global() +@Module({ + providers: [TemporaryDatabaseService], + exports: [TemporaryDatabaseService], +}) +export class TemporaryDatabaseModule {} diff --git a/nestjs-BE/server/src/temporary-database/temporary-database.service.spec.ts b/nestjs-BE/server/src/temporary-database/temporary-database.service.spec.ts new file mode 100644 index 00000000..b76bc582 --- /dev/null +++ b/nestjs-BE/server/src/temporary-database/temporary-database.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TemporaryDatabaseService } from './temporary-database.service'; + +describe('TemporaryDatabaseService', () => { + let service: TemporaryDatabaseService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TemporaryDatabaseService], + }).compile(); + + service = module.get(TemporaryDatabaseService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/temporary-database/temporary-database.service.ts b/nestjs-BE/server/src/temporary-database/temporary-database.service.ts new file mode 100644 index 00000000..4330e949 --- /dev/null +++ b/nestjs-BE/server/src/temporary-database/temporary-database.service.ts @@ -0,0 +1,229 @@ +import { Injectable } from '@nestjs/common'; +import { + PrismaServiceMySQL, + PrismaServiceMongoDB, +} from '../prisma/prisma.service'; +import { Cron } from '@nestjs/schedule'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { TokenData } from 'src/auth/auth.service'; +import { InviteCodeData } from 'src/invite-codes/invite-codes.service'; +import { CreateProfileSpaceDto } from 'src/profile-space/dto/create-profile-space.dto'; +import { UpdateProfileDto } from 'src/profiles/dto/update-profile.dto'; +import { UpdateSpaceDto } from 'src/spaces/dto/update-space.dto'; +import { UpdateUserDto } from 'src/users/dto/update-user.dto'; +import costomEnv from 'src/config/env'; +const { CSV_FOLDER } = costomEnv; + +type DeleteDataType = { + field: string; + value: string; +}; + +export type InsertDataType = + | TokenData + | InviteCodeData + | CreateProfileSpaceDto + | UpdateProfileDto + | UpdateSpaceDto + | UpdateUserDto; + +type UpdateDataType = { + field: string; + value: InsertDataType; +}; +type DataType = InsertDataType | UpdateDataType | DeleteDataType; + +interface OperationData { + service: string; + uniqueKey: string; + command: string; + data: DataType; +} + +@Injectable() +export class TemporaryDatabaseService { + private database: Map>> = new Map(); + private readonly FOLDER_NAME = CSV_FOLDER; + + constructor( + private readonly prismaMysql: PrismaServiceMySQL, + private readonly prismaMongoDB: PrismaServiceMongoDB, + ) { + this.init(); + } + + async init() { + this.initializeDatabase(); + await this.readDataFromFiles(); + await this.executeBulkOperations(); + } + + private initializeDatabase() { + const services = [ + 'USER_TB', + 'PROFILE_TB', + 'SPACE_TB', + 'BoardCollection', + 'PROFILE_SPACE_TB', + 'REFRESH_TOKEN_TB', + 'INVITE_CODE_TB', + ]; + const operations = ['insert', 'update', 'delete']; + + services.forEach((service) => { + const serviceMap = new Map(); + this.database.set(service, serviceMap); + operations.forEach((operation) => { + serviceMap.set(operation, new Map()); + }); + }); + } + + private async readDataFromFiles() { + const files = await fs.readdir(this.FOLDER_NAME); + return Promise.all( + files + .filter((file) => file.endsWith('.csv')) + .map((file) => this.readDataFromFile(file)), + ); + } + + private async readDataFromFile(file: string) { + const [service, commandWithExtension] = file.split('-'); + const command = commandWithExtension.replace('.csv', ''); + const fileData = await fs.readFile(join(this.FOLDER_NAME, file), 'utf8'); + fileData.split('\n').forEach((line) => { + if (line.trim() !== '') { + const [uniqueKey, ...dataParts] = line.split(','); + const data = dataParts.join(','); + this.database + .get(service) + .get(command) + .set(uniqueKey, JSON.parse(data)); + } + }); + } + + get(service: string, uniqueKey: string, command: string): any { + return this.database.get(service).get(command).get(uniqueKey); + } + + create(service: string, uniqueKey: string, data: InsertDataType) { + this.operation({ service, uniqueKey, command: 'insert', data }); + } + + update(service: string, uniqueKey: string, data: UpdateDataType) { + this.operation({ service, uniqueKey, command: 'update', data }); + } + + remove(service: string, uniqueKey: string, data: DeleteDataType) { + this.operation({ service, uniqueKey, command: 'delete', data }); + } + + delete(service: string, uniqueKey: string, command: string) { + this.database.get(service).get(command).delete(uniqueKey); + const filePath = join(this.FOLDER_NAME, `${service}-${command}.csv`); + fs.readFile(filePath, 'utf8').then((fileData) => { + const lines = fileData.split('\n'); + const updatedFileData = lines + .filter((line) => !line.startsWith(`${uniqueKey},`)) + .join('\n'); + fs.writeFile(filePath, updatedFileData); + }); + } + + operation({ service, uniqueKey, command, data }: OperationData) { + const filePath = join(this.FOLDER_NAME, `${service}-${command}.csv`); + fs.appendFile(filePath, `${uniqueKey},${JSON.stringify(data)}\n`, 'utf8'); + this.database.get(service).get(command).set(uniqueKey, data); + } + + @Cron('0 */10 * * * *') + async executeBulkOperations() { + for (const service of this.database.keys()) { + const serviceMap = this.database.get(service); + const prisma = + service === 'BoardCollection' ? this.prismaMongoDB : this.prismaMysql; + await this.performInsert(service, serviceMap.get('insert'), prisma); + await this.performUpdate(service, serviceMap.get('update'), prisma); + await this.performDelete(service, serviceMap.get('delete'), prisma); + } + } + + private async performInsert( + service: string, + dataMap: Map, + prisma: PrismaServiceMongoDB | PrismaServiceMySQL, + ) { + const data = this.prepareData(service, 'insert', dataMap); + if (!data.length) return; + if (prisma instanceof PrismaServiceMySQL) { + await prisma[service].createMany({ + data: data, + skipDuplicates: true, + }); + } else { + await prisma[service].createMany({ + data: data, + }); + } + } + + private async performUpdate( + service: string, + dataMap: Map, + prisma: PrismaServiceMongoDB | PrismaServiceMySQL, + ) { + const data = this.prepareData(service, 'update', dataMap); + if (!data.length) return; + await Promise.all( + data.map((item) => { + const keyField = item.field; + const keyValue = item.value[keyField]; + const updatedValue = Object.fromEntries( + Object.entries(item.value).filter(([key]) => key !== 'uuid'), + ); + return prisma[service].update({ + where: { [keyField]: keyValue }, + data: updatedValue, + }); + }), + ); + } + + private async performDelete( + service: string, + dataMap: Map, + prisma: PrismaServiceMongoDB | PrismaServiceMySQL, + ) { + const data = this.prepareData(service, 'delete', dataMap); + if (!data.length) return; + await Promise.all( + data.map(async (item) => { + try { + await prisma[service].delete({ + where: { [item.field]: item.value }, + }); + } finally { + return; + } + }), + ); + } + + private prepareData( + service: string, + operation: string, + dataMap: Map, + ) { + const data = Array.from(dataMap.values()); + this.clearFile(`${service}-${operation}.csv`); + dataMap.clear(); + return data; + } + + private clearFile(filename: string) { + fs.writeFile(join(this.FOLDER_NAME, filename), '', 'utf8'); + } +} diff --git a/nestjs-BE/server/src/upload/upload.module.ts b/nestjs-BE/server/src/upload/upload.module.ts new file mode 100644 index 00000000..71c7f770 --- /dev/null +++ b/nestjs-BE/server/src/upload/upload.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UploadService } from './upload.service'; + +@Module({ + providers: [UploadService], + exports: [UploadService], +}) +export class UploadModule {} diff --git a/nestjs-BE/server/src/upload/upload.service.spec.ts b/nestjs-BE/server/src/upload/upload.service.spec.ts new file mode 100644 index 00000000..7b83db6a --- /dev/null +++ b/nestjs-BE/server/src/upload/upload.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UploadService } from './upload.service'; + +describe('UploadService', () => { + let service: UploadService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UploadService], + }).compile(); + + service = module.get(UploadService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/upload/upload.service.ts b/nestjs-BE/server/src/upload/upload.service.ts new file mode 100644 index 00000000..12a0fb28 --- /dev/null +++ b/nestjs-BE/server/src/upload/upload.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import customEnv from 'src/config/env'; +import { S3, Endpoint } from 'aws-sdk'; +import uuid from '../utils/uuid'; +const { + NCLOUD_ACCESS_KEY, + NCLOUD_SECRET_KEY, + NCLOUD_REGION, + STORAGE_URL, + BUCKET_NAME, +} = customEnv; +const endpoint = new Endpoint(STORAGE_URL); + +@Injectable() +export class UploadService { + private s3: S3; + constructor() { + this.s3 = new S3({ + endpoint: endpoint, + region: NCLOUD_REGION, + credentials: { + accessKeyId: NCLOUD_ACCESS_KEY, + secretAccessKey: NCLOUD_SECRET_KEY, + }, + }); + } + + async uploadFile(image: Express.Multer.File) { + const params = { + Bucket: BUCKET_NAME, + Key: `${uuid()}-${image.originalname}`, + Body: image.buffer, + ACL: 'public-read', + }; + const uploadResult = await this.s3.upload(params).promise(); + return uploadResult.Location; + } +} diff --git a/nestjs-BE/server/src/users/dto/create-user.dto.ts b/nestjs-BE/server/src/users/dto/create-user.dto.ts new file mode 100644 index 00000000..db2d100b --- /dev/null +++ b/nestjs-BE/server/src/users/dto/create-user.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateUserDto { + @ApiProperty({ example: 'test@gmail.com', description: 'email adress' }) + readonly email: string; + + @ApiProperty({ example: 'kakao', description: 'social site name' }) + readonly provider: string; +} diff --git a/nestjs-BE/server/src/users/dto/update-user.dto.ts b/nestjs-BE/server/src/users/dto/update-user.dto.ts new file mode 100644 index 00000000..fc59078e --- /dev/null +++ b/nestjs-BE/server/src/users/dto/update-user.dto.ts @@ -0,0 +1,12 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateUserDto extends PartialType(CreateUserDto) { + @ApiProperty({ + example: 'newpassword', + description: 'The new password of the user', + }) + password?: string; + uuid?: string; +} diff --git a/nestjs-BE/server/src/users/users.module.ts b/nestjs-BE/server/src/users/users.module.ts new file mode 100644 index 00000000..153ff941 --- /dev/null +++ b/nestjs-BE/server/src/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { SpacesService } from 'src/spaces/spaces.service'; + +@Module({ + providers: [UsersService, SpacesService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/nestjs-BE/server/src/users/users.service.spec.ts b/nestjs-BE/server/src/users/users.service.spec.ts new file mode 100644 index 00000000..62815ba6 --- /dev/null +++ b/nestjs-BE/server/src/users/users.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/nestjs-BE/server/src/users/users.service.ts b/nestjs-BE/server/src/users/users.service.ts new file mode 100644 index 00000000..be26c750 --- /dev/null +++ b/nestjs-BE/server/src/users/users.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaServiceMySQL } from '../prisma/prisma.service'; +import { TemporaryDatabaseService } from '../temporary-database/temporary-database.service'; +import { BaseService } from '../base/base.service'; +import { USER_CACHE_SIZE } from '../config/magic-number'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class UsersService extends BaseService { + constructor( + protected prisma: PrismaServiceMySQL, + protected temporaryDatabaseService: TemporaryDatabaseService, + ) { + super({ + prisma, + temporaryDatabaseService, + cacheSize: USER_CACHE_SIZE, + className: 'USER_TB', + field: 'email_provider', + }); + } + + generateKey(data: UpdateUserDto) { + return `email:${data.email}+provider:${data.provider}`; + } +} diff --git a/nestjs-BE/server/src/utils/interface.ts b/nestjs-BE/server/src/utils/interface.ts new file mode 100644 index 00000000..05104ac2 --- /dev/null +++ b/nestjs-BE/server/src/utils/interface.ts @@ -0,0 +1,6 @@ +import { Request } from 'express'; +export interface RequestWithUser extends Request { + user: { + uuid: string; + }; +} diff --git a/nestjs-BE/server/src/utils/lru-cache.ts b/nestjs-BE/server/src/utils/lru-cache.ts new file mode 100644 index 00000000..5e70d557 --- /dev/null +++ b/nestjs-BE/server/src/utils/lru-cache.ts @@ -0,0 +1,86 @@ +class LRUNode { + key: string; + value: any; + prev: LRUNode | null; + next: LRUNode | null; + + constructor(key: string, value: any) { + this.key = key; + this.value = value; + this.prev = null; + this.next = null; + } +} + +export default class LRUCache { + capacity: number; + hashmap: { [key: string]: LRUNode }; + head: LRUNode; + tail: LRUNode; + + constructor(capacity: number) { + this.capacity = capacity; + this.hashmap = {}; + this.head = new LRUNode('0', 0); + this.tail = new LRUNode('0', 0); + this.head.next = this.tail; + this.tail.prev = this.head; + } + + get(key: string): any { + if (this.hashmap[key]) { + const node = this.hashmap[key]; + this.remove(node); + this.add(node); + return node.value; + } + return null; + } + + put(key: string, value: any): void { + if (this.hashmap[key]) this.delete(key); + + const node = new LRUNode(key, value); + this.add(node); + this.hashmap[key] = node; + + if (Object.keys(this.hashmap).length > this.capacity) this.removeOldest(1); + } + + delete(key: string): void { + if (this.hashmap[key]) { + const node = this.hashmap[key]; + this.remove(node); + delete this.hashmap[key]; + } + } + + private remove(node: LRUNode): void { + const prev = node.prev; + const next = node.next; + if (prev) prev.next = next; + if (next) next.prev = prev; + } + + private add(node: LRUNode): void { + const prev = this.tail.prev; + if (prev) prev.next = node; + this.tail.prev = node; + node.prev = prev; + node.next = this.tail; + } + + removeOldest(count: number): void { + for (let i = 0; i < count; i++) { + if (this.head.next !== this.tail) { + const node = this.head.next; + if (node) { + this.remove(node); + delete this.hashmap[node.key]; + } + } else { + break; + } + } + } +} diff --git a/nestjs-BE/server/src/utils/response.ts b/nestjs-BE/server/src/utils/response.ts new file mode 100644 index 00000000..a3c01ef4 --- /dev/null +++ b/nestjs-BE/server/src/utils/response.ts @@ -0,0 +1,34 @@ +import { HttpStatus } from '@nestjs/common'; +import { InsertDataType } from 'src/temporary-database/temporary-database.service'; +type TokenDataType = { + access_token: string; + refresh_token?: string; +}; +type InviteDataType = { + invite_code: string; +}; + +type ExtendedDataType = + | InsertDataType + | TokenDataType + | InviteDataType + | InsertDataType[]; + +export class ResponseUtils { + private static messages = new Map([ + [HttpStatus.OK, 'Success'], + [HttpStatus.CREATED, 'Created'], + [HttpStatus.NOT_FOUND, 'Not Found'], + [HttpStatus.NO_CONTENT, 'No Content'], + ]); + + static createResponse(status: HttpStatus, data?: ExtendedDataType) { + const response: any = { + statusCode: status, + message: this.messages.get(status), + }; + if (data) response.data = data; + + return response; + } +} diff --git a/nestjs-BE/server/src/utils/uuid.ts b/nestjs-BE/server/src/utils/uuid.ts new file mode 100644 index 00000000..bd4eea80 --- /dev/null +++ b/nestjs-BE/server/src/utils/uuid.ts @@ -0,0 +1,6 @@ +import { v1 as uuid1 } from 'uuid'; + +export default function generateUuid(): string { + const [first, second, third, fourth, fifth] = uuid1().split('-'); + return third + second + first + fourth + fifth; +} diff --git a/nestjs-BE/test/app.e2e-spec.ts b/nestjs-BE/server/test/app.e2e-spec.ts similarity index 100% rename from nestjs-BE/test/app.e2e-spec.ts rename to nestjs-BE/server/test/app.e2e-spec.ts diff --git a/nestjs-BE/test/jest-e2e.json b/nestjs-BE/server/test/jest-e2e.json similarity index 100% rename from nestjs-BE/test/jest-e2e.json rename to nestjs-BE/server/test/jest-e2e.json diff --git a/nestjs-BE/tsconfig.build.json b/nestjs-BE/server/tsconfig.build.json similarity index 100% rename from nestjs-BE/tsconfig.build.json rename to nestjs-BE/server/tsconfig.build.json diff --git a/nestjs-BE/tsconfig.json b/nestjs-BE/server/tsconfig.json similarity index 100% rename from nestjs-BE/tsconfig.json rename to nestjs-BE/server/tsconfig.json diff --git a/nestjs-BE/src/app.module.ts b/nestjs-BE/src/app.module.ts deleted file mode 100644 index fe6f5f38..00000000 --- a/nestjs-BE/src/app.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { BoardGateway } from './board/board.gateway'; -import { MindmapService } from './mindmap/mindmap.service'; - -@Module({ - imports: [], - controllers: [AppController], - providers: [AppService, BoardGateway, MindmapService], -}) -export class AppModule {} diff --git a/nestjs-BE/src/board/board.gateway.ts b/nestjs-BE/src/board/board.gateway.ts deleted file mode 100644 index cd6229ea..00000000 --- a/nestjs-BE/src/board/board.gateway.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - SubscribeMessage, - WebSocketGateway, - WebSocketServer, -} from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; -import { MindmapService } from '../mindmap/mindmap.service'; - -interface BoardDataPayload { - message: any; - boardId: string; -} - -@WebSocketGateway({ namespace: 'board' }) -export class BoardGateway { - constructor(private readonly mindmapService: MindmapService) {} - - @WebSocketServer() - server: Server; - - @SubscribeMessage('joinRoom') - handleJoinRoom(client: Socket, payload: { boardId: string }): void { - client.join(payload.boardId); - } - - @SubscribeMessage('pushData') - handlePushNode(client: Socket, payload: BoardDataPayload): void { - this.mindmapService.updateMindmap(payload.boardId, payload.message); - client.broadcast - .to(payload.boardId) - .emit( - 'messageFromServer', - this.mindmapService.getEncodedState(payload.boardId), - ); - } -} diff --git a/nestjs-BE/src/main.ts b/nestjs-BE/src/main.ts deleted file mode 100644 index 6f57e2d4..00000000 --- a/nestjs-BE/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; - -const PORT: number = 3000; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(PORT); -} -bootstrap(); diff --git a/nestjs-BE/src/mindmap/mindmap.service.ts b/nestjs-BE/src/mindmap/mindmap.service.ts deleted file mode 100644 index 14b24730..00000000 --- a/nestjs-BE/src/mindmap/mindmap.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { LWWMap, lwwMapState } from 'crdt/lww-map'; - -@Injectable() -export class MindmapService { - private boards = new Map>(); - - updateMindmap(boardId: string, message: any): void { - const board = this.getMindmap(boardId); - board.merge(message); - } - - getEncodedState(boardId: string): lwwMapState { - const board = this.getMindmap(boardId); - return board.getState(); - } - - private getMindmap(boardId: string) { - let board = this.boards.get(boardId); - if (!board) { - board = new LWWMap(boardId); // boardId 대신에 서버 아이디??? - this.boards.set(boardId, board); - } - return board; - } -}