├── .env.sentry-build-plugin ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── labeler.yml └── workflows │ ├── build.yml │ └── label.yml ├── .gitignore ├── .husky ├── post-merge └── pre-commit ├── .prettierrc ├── .swcrc ├── .vscode └── settings.json ├── README.md ├── docker-compose.yaml ├── event.html ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── prisma ├── migrations │ ├── 20240727062044_initial_migration │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── serverless.yml ├── src ├── ai_handler.ts ├── app.config.ts ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── app.swagger.ts ├── bootstrap.ts ├── common │ ├── constant │ │ └── index.ts │ ├── decorators │ │ ├── getUser.decorator.ts │ │ ├── index.ts │ │ └── public.decorator.ts │ ├── dto │ │ ├── index.ts │ │ ├── request │ │ │ ├── index.ts │ │ │ └── pagination.dto.ts │ │ └── response │ │ │ ├── index.ts │ │ │ └── pagination.dto.ts │ ├── error │ │ ├── exception.abstract.ts │ │ ├── exception.factory.ts │ │ └── index.ts │ ├── filter │ │ ├── base.filter.ts │ │ └── index.ts │ ├── index.ts │ ├── interceptor │ │ ├── index.ts │ │ └── response.interceptor.ts │ ├── middlewares │ │ ├── index.ts │ │ └── logging.interceptor.ts │ ├── types │ │ ├── enum.ts │ │ ├── index.ts │ │ └── type.d.ts │ └── utils │ │ ├── createErrorObject.util.ts │ │ ├── index.ts │ │ ├── math.util.ts │ │ ├── parser.util.ts │ │ └── tokenizer.ts ├── handler.ts ├── infrastructure │ ├── ai │ │ ├── ai.constant.ts │ │ ├── ai.module.ts │ │ ├── ai.service.ts │ │ ├── dto │ │ │ ├── index.ts │ │ │ └── summarizeURL.response.ts │ │ ├── functions │ │ │ └── index.ts │ │ ├── index.ts │ │ └── types │ │ │ └── types.d.ts │ ├── aws-lambda │ │ ├── aws-lambda.module.ts │ │ ├── aws-lambda.service.ts │ │ └── type.ts │ ├── database │ │ ├── data-source.ts │ │ ├── database.module.ts │ │ ├── entities │ │ │ ├── ai-classification.entity.ts │ │ │ ├── base.entity.ts │ │ │ ├── folder.entity.ts │ │ │ ├── keyword.entity.ts │ │ │ ├── metrics.entity.ts │ │ │ ├── onboard-category.entity.ts │ │ │ ├── post-keyword.entity.ts │ │ │ ├── post.entity.ts │ │ │ └── user.entity.ts │ │ ├── index.ts │ │ ├── schema │ │ │ ├── AIClassification.schema.ts │ │ │ ├── base.schema.ts │ │ │ ├── folder.schema.ts │ │ │ ├── index.ts │ │ │ ├── keyword.schema.ts │ │ │ ├── metrics.schema.ts │ │ │ ├── post.schema.ts │ │ │ ├── postKeyword.schema.ts │ │ │ └── user.schema.ts │ │ └── types │ │ │ └── folder-type.enum.ts │ ├── discord │ │ ├── discord-ai-webhook.provider.ts │ │ ├── discord-error-webhook.provider.ts │ │ ├── discord-webhook.provider.ts │ │ └── discord.module.ts │ ├── index.ts │ └── puppeteer-pool │ │ ├── puppeteer-pool.module.ts │ │ └── puppeteer-pool.service.ts └── modules │ ├── ai-classification │ ├── ai-classification.module.ts │ ├── ai-classification.service.ts │ └── ai-classification.v2.service.ts │ ├── auth │ ├── auth.module.ts │ └── auth.service.ts │ ├── classification │ ├── classification.controller.ts │ ├── classification.module.ts │ ├── classification.pg.repository.ts │ ├── classification.repository.ts │ ├── classification.service.spec.ts │ ├── classification.service.ts │ ├── classification.v2.controller.ts │ ├── classification.v2.service.ts │ ├── docs │ │ ├── controller.docs.ts │ │ ├── countClassification.docs.ts │ │ ├── deleteAIClassification.docs.ts │ │ ├── getAIFolderNameList.docs.ts │ │ ├── getAIPostList.docs.ts │ │ ├── index.ts │ │ ├── patchAIPost.docs.ts │ │ └── patchAIPostList.docs.ts │ ├── dto │ │ ├── classification.dto.ts │ │ └── getAIFolderNameLIst.dto.ts │ ├── error │ │ └── index.ts │ └── response │ │ ├── ai-folder-list.dto.ts │ │ └── ai-post-list.dto.ts │ ├── folders │ ├── docs │ │ ├── controller.docs.ts │ │ ├── folder-api.docs.ts │ │ └── index.ts │ ├── dto │ │ ├── delete-custom-folder.dto.ts │ │ ├── folder-list-service.dto.ts │ │ ├── index.ts │ │ └── mutate-folder.dto.ts │ ├── error │ │ └── index.ts │ ├── folders.controller.spec.ts │ ├── folders.controller.ts │ ├── folders.module.ts │ ├── folders.pg.repository.ts │ ├── folders.repository.ts │ ├── folders.service.spec.ts │ ├── folders.service.ts │ ├── folders.v2.controller.ts │ ├── folders.v2.service.ts │ └── responses │ │ ├── folder-list.response.ts │ │ ├── folder-post.response.ts │ │ ├── folder-summary.response.ts │ │ ├── folder.response.ts │ │ ├── index.ts │ │ └── post.response.ts │ ├── keywords │ ├── keyword.pg.repository.ts │ └── keyword.repository.ts │ ├── launching-events │ ├── launching-events.controller.ts │ ├── launching-events.module.ts │ └── launching-events.service.ts │ ├── links │ ├── docs │ │ ├── controller.docs.ts │ │ ├── index.ts │ │ └── validate-link.docs.ts │ ├── links.controller.spec.ts │ ├── links.module.ts │ ├── links.service.spec.ts │ ├── links.v2.controller.ts │ ├── links.v2.service.ts │ └── responses │ │ ├── index.ts │ │ └── validate-link.response.ts │ ├── metrics │ ├── metrics.module.ts │ ├── metrics.pg.repository.ts │ ├── metrics.repository.ts │ └── metrics.service.ts │ ├── onboard │ ├── docs │ │ ├── controller.docs.ts │ │ ├── index.ts │ │ └── onboard.docs.ts │ ├── dto │ │ ├── index.ts │ │ └── onboard.query.ts │ ├── http │ │ └── request.http │ ├── onboard.const.ts │ ├── onboard.controller.ts │ ├── onboard.module.ts │ ├── onboard.pg.repository.ts │ └── onboard.service.ts │ ├── posts │ ├── docs │ │ ├── controller.docs.ts │ │ ├── countPost.docs.ts │ │ ├── createPost.docs.ts │ │ ├── deletePost.docs.ts │ │ ├── index.ts │ │ ├── listPost.docs.ts │ │ ├── retrievePost.docs.ts │ │ ├── updatePost.docs.ts │ │ └── updatePostFolder.docs.ts │ ├── dto │ │ ├── countPost.query.ts │ │ ├── create-post.dto.ts │ │ ├── find-in-folder.dto.ts │ │ ├── index.ts │ │ ├── list-post.dto.ts │ │ ├── updatePost.dto.ts │ │ └── updatePostFolder.dto.ts │ ├── error │ │ └── index.ts │ ├── postKeywords.pg.repository.ts │ ├── postKeywords.repository.ts │ ├── posts.constant.ts │ ├── posts.controller.ts │ ├── posts.module.ts │ ├── posts.pg.repository.ts │ ├── posts.repository.ts │ ├── posts.service.ts │ ├── response │ │ ├── index.ts │ │ ├── keyword-list.response.ts │ │ ├── listPost.response.ts │ │ └── retrievePost.response.ts │ └── type │ │ └── type.d.ts │ ├── prisma │ ├── prisma.module.ts │ └── prisma.service.ts │ └── users │ ├── docs │ ├── controller.docs.ts │ ├── createUser.docs.ts │ └── index.ts │ ├── dto │ ├── createUser.dto.ts │ └── index.ts │ ├── guards │ ├── index.ts │ ├── jwt.guard.ts │ └── strategy │ │ ├── index.ts │ │ ├── jwt.strategy.ts │ │ └── strategy.token.ts │ ├── response │ ├── createUser.response.ts │ └── index.ts │ ├── users.controller.spec.ts │ ├── users.controller.ts │ ├── users.module.ts │ ├── users.pg.repository.ts │ ├── users.repository.ts │ ├── users.service.spec.ts │ └── users.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.ts └── worker-webpack.config.ts / .env.sentry-build-plugin: -------------------------------------------------------------------------------- 1 | SENTRY_AUTH_TOKEN= 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @JonghunAn @Marades @hye-on @J-Hoplin @Ahn-seokjoo -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR 내용 2 | 3 | ## 추가 및 변경 사항 4 | 5 | > API endpoint와 추가 혹은 변경된 사항을 적어주세요! 6 | 7 | ## PR 중점사항 8 | 9 | > 리뷰어가 중점적으로 봐야하는 부분에 대해 적어주세요! 10 | 11 | ## 스크린샷 12 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | feature: 2 | - head-branch: ['^feature', 'feature', '^feat', 'feat'] 3 | fix: 4 | - head-branch: ['^fix', 'fix'] 5 | refactor: 6 | - head-branch: ['^refactor', 'refactor'] 7 | test: 8 | - head-branch: ['^test', 'test'] 9 | etc: 10 | - head-branch: ['^chore', 'chore'] 11 | actions: 12 | - changed-files: 13 | - any-glob-to-any-file: '.github/**' 14 | document: 15 | - changed-files: 16 | - any-glob-to-any-file: ['**/*.md', '!.github/**'] 17 | release: 18 | - base-branch: 'main' 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Application Build Test 2 | on: 3 | pull_request: 4 | branches: 5 | - develop 6 | paths: 7 | - 'src/**' 8 | - '.github/workflows/**' 9 | run-name: Application Build Test 10 | jobs: 11 | build: 12 | name: Build Test 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Repository Checkout 16 | uses: actions/checkout@v4 17 | - name: Node.js runtime setting 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - name: pnpm settings 22 | uses: pnpm/action-setup@v4 23 | with: 24 | run_install: | 25 | - recursive: true 26 | args: [--frozen-lockfile] 27 | - name: Install dependencies 28 | run: pnpm install 29 | - name: Check Application Build 30 | run: pnpm build 31 | -------------------------------------------------------------------------------- /.github/workflows/label.yml: -------------------------------------------------------------------------------- 1 | name: 'Pull Request labeler' 2 | on: 3 | pull_request: 4 | types: [opened, edited] 5 | jobs: 6 | label: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | pull-requests: write 11 | steps: 12 | - uses: actions/labeler@v5 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /worker-dist 4 | /node_modules 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | #env 39 | .env.* 40 | .env -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pnpm install -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | npx prettier --write ./src 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 5 | "importOrder": [ 6 | "^@nestjs/(.*)$", 7 | "", 8 | "^@src/(.*)$", 9 | "^[./]" 10 | ], 11 | "importOrderSortSpecifiers": true, 12 | "importOrderParserPlugins": [ 13 | "typescript", 14 | "[\"decorators-legacy\", { \"decoratorsBeforeExport\": true }]" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": false, 6 | "decorators": true 7 | }, 8 | "transform": { 9 | "decoratorMetadata": true 10 | }, 11 | "target": "es2017" 12 | }, 13 | "module": { 14 | "type": "commonjs" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": "explicit" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dorabangs Node.js 2 | 3 | 도라방스 Node.js Repository 4 | 5 | ## 도라방스 노드팀 리뷰룰 ʜɪ⚞₍⑅ᐢ.ˬ.ᐢ₎ 6 | 7 | - (˵ •̀ ᴗ - ˵ ) ✧ : 수정해 주세요 8 | 9 | - 〆(・∀・@) : 의논 해 보고 싶어요 10 | 11 | - (/¯◡‿◡)/¯✧·˚ : _✧·˚ : _ : 그냥 의견이에요 12 | 13 | ## 구성원 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 28 | 33 | 38 | 43 | 44 |
안종훈김건회조혜온윤준호
24 | 25 |
26 | JonghunAn 27 |
29 | 30 |
31 | Marades 32 |
34 | 35 |
36 | hye-on 37 |
39 | 40 |
41 | J-Hoplin 42 |
45 | 46 | ## Project Init 47 | 48 | - Local Postgres Up/Down 49 | 50 | ``` 51 | pnpm (`pg:up` | `pg:down`) 52 | ``` 53 | 54 | - 마이그레이션 스크립트 생성 55 | 56 | ``` 57 | pnpm pg:makemigrations (마이그레이션 이름) 58 | ``` 59 | 60 | - 마이그레이션 sync 61 | 62 | ``` 63 | pnpm pg:migrate 64 | ``` 65 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | pg: 4 | image: postgres:16.2-alpine 5 | restart: unless-stopped 6 | environment: 7 | - 'POSTGRES_PASSWORD=linkit1234!' 8 | - 'POSTGRES_DB=linkit' 9 | - 'TZ=Asia/Seoul' 10 | ports: 11 | - '5432:5432' 12 | networks: 13 | - linkit 14 | volumes: 15 | - linkit-data:/var/lib/postgresql/data 16 | networks: 17 | linkit: 18 | driver: bridge 19 | volumes: 20 | linkit-data: 21 | external: false 22 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "entryFile": "handler", 5 | "sourceRoot": "src", 6 | "compilerOptions": { 7 | "deleteOutDir": true, 8 | "builder": "swc", 9 | "typeCheck": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dorabangs-node", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "prebuild:worker": "if [ -d worker-dist ]; then rm -r worker-dist; fi", 11 | "build": "npx webpack", 12 | "build:worker": "pnpm run prebuild:worker && npx webpack -c worker-webpack.config.ts", 13 | "deploy": "npm run build && sls deploy function -f server && npm run postdeploy", 14 | "deploy:worker": "npm run build:worker && sls deploy function -f aiWorker && npm run postdeploy", 15 | "deploy:all": "npm run build && npm run build:worker && sls deploy && npm run postdeploy", 16 | "postdeploy": "rimraf .serverless", 17 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 18 | "db:up": "docker-compose up -d", 19 | "db:down": "docker compose down", 20 | "start": "cross-env NODE_ENV=local nest start", 21 | "start:dev": "cross-env NODE_ENV=local nest start --watch", 22 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 23 | "test": "jest", 24 | "pg:up": "docker compose up -d", 25 | "pg:down": "docker compose down", 26 | "typeorm": "npx ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", 27 | "migration:generate": "npm run typeorm -- migration:generate src/infrastructure/database/migrations/Migration -d ./src/infrastructure/database/data-source.ts", 28 | "migration:run": "npm run typeorm -- migration:run -d ./src/infrastructure/database/data-source.ts", 29 | "migration:revert": "npm run typeorm -- migration:revert -d ./src/infrastructure/database/data-source.ts", 30 | "test:watch": "jest --watch", 31 | "test:cov": "jest --coverage", 32 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 33 | "test:e2e": "jest --config ./test/jest-e2e.json", 34 | "prepare": "husky" 35 | }, 36 | "dependencies": { 37 | "@aws-sdk/client-lambda": "^3.606.0", 38 | "@nestjs/common": "^10.0.0", 39 | "@nestjs/config": "^3.2.2", 40 | "@nestjs/core": "^10.0.0", 41 | "@nestjs/jwt": "^10.2.0", 42 | "@nestjs/mongoose": "^10.0.6", 43 | "@nestjs/passport": "^10.0.3", 44 | "@nestjs/platform-express": "^10.0.0", 45 | "@nestjs/swagger": "^7.3.1", 46 | "@nestjs/typeorm": "^10.0.2", 47 | "@sentry/node": "^8.9.2", 48 | "@sentry/profiling-node": "^8.9.2", 49 | "@types/dotenv": "^8.2.3", 50 | "aws-lambda": "^1.0.7", 51 | "aws-serverless-express": "^3.4.0", 52 | "cheerio": "1.0.0-rc.12", 53 | "class-transformer": "^0.5.1", 54 | "class-validator": "^0.14.1", 55 | "cross-env": "^7.0.3", 56 | "dotenv": "^16.4.7", 57 | "express": "^4.19.2", 58 | "iconv-lite": "^0.6.3", 59 | "js-tiktoken": "^1.0.14", 60 | "lodash": "^4.17.21", 61 | "mongoose": "^8.4.1", 62 | "openai": "^4.52.2", 63 | "passport": "^0.7.0", 64 | "passport-jwt": "^4.0.1", 65 | "pg": "^8.13.1", 66 | "reflect-metadata": "^0.1.13", 67 | "rxjs": "^7.8.1", 68 | "typeorm": "^0.3.20", 69 | "uuid": "^10.0.0" 70 | }, 71 | "devDependencies": { 72 | "@nestjs/cli": "^10.0.0", 73 | "@nestjs/schematics": "^10.0.0", 74 | "@nestjs/testing": "^10.0.0", 75 | "@sentry/webpack-plugin": "^2.20.1", 76 | "@swc/cli": "^0.3.14", 77 | "@swc/core": "^1.6.5", 78 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 79 | "@types/express": "^4.17.17", 80 | "@types/jest": "^29.5.2", 81 | "@types/lodash": "^4.17.6", 82 | "@types/node": "^20.3.1", 83 | "@types/passport-jwt": "^4.0.1", 84 | "@types/supertest": "^2.0.12", 85 | "@types/uuid": "^10.0.0", 86 | "@typescript-eslint/eslint-plugin": "^6.0.0", 87 | "@typescript-eslint/parser": "^6.0.0", 88 | "copy-webpack-plugin": "^12.0.2", 89 | "eslint": "^8.42.0", 90 | "eslint-config-prettier": "^9.0.0", 91 | "eslint-plugin-prettier": "^5.0.0", 92 | "husky": "^9.1.7", 93 | "jest": "^29.5.0", 94 | "prettier": "^3.0.0", 95 | "rimraf": "^5.0.7", 96 | "serverless": "^4.1.6", 97 | "serverless-domain-manager": "^7.3.8", 98 | "serverless-dotenv-plugin": "^6.0.0", 99 | "serverless-offline": "^13.6.0", 100 | "source-map-support": "^0.5.21", 101 | "supertest": "^6.3.3", 102 | "swc-loader": "^0.2.6", 103 | "ts-jest": "^29.1.0", 104 | "ts-loader": "^9.4.3", 105 | "ts-node": "^10.9.1", 106 | "tsconfig-paths": "^4.2.0", 107 | "typescript": "^5.1.3", 108 | "webpack": "^5.92.1", 109 | "webpack-cli": "^5.1.4" 110 | }, 111 | "jest": { 112 | "moduleFileExtensions": [ 113 | "js", 114 | "json", 115 | "ts" 116 | ], 117 | "rootDir": "src", 118 | "testRegex": ".*\\.spec\\.ts$", 119 | "transform": { 120 | "^.+\\.(t|j)s$": "ts-jest" 121 | }, 122 | "collectCoverageFrom": [ 123 | "**/*.(t|j)s" 124 | ], 125 | "coverageDirectory": "../coverage", 126 | "testEnvironment": "node" 127 | }, 128 | "packageManager": "pnpm@9.4.0" 129 | } 130 | -------------------------------------------------------------------------------- /prisma/migrations/20240727062044_initial_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "FolderType" AS ENUM ('custom', 'default'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "PostAiStatus" AS ENUM ('IN_PROGRES', 'SUCCESS', 'FAIL'); 6 | 7 | -- CreateTable 8 | CREATE TABLE "users" ( 9 | "id" TEXT NOT NULL, 10 | "device_token" VARCHAR(20) NOT NULL, 11 | 12 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 13 | ); 14 | 15 | -- CreateTable 16 | CREATE TABLE "folders" ( 17 | "id" TEXT NOT NULL, 18 | "name" VARCHAR(30) NOT NULL, 19 | "type" "FolderType" NOT NULL, 20 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 21 | "updated_at" TIMESTAMP(3) NOT NULL, 22 | "user_id" TEXT NOT NULL, 23 | 24 | CONSTRAINT "folders_pkey" PRIMARY KEY ("id") 25 | ); 26 | 27 | -- CreateTable 28 | CREATE TABLE "posts" ( 29 | "id" TEXT NOT NULL, 30 | "url" VARCHAR(2048) NOT NULL, 31 | "title" VARCHAR(50) NOT NULL, 32 | "description" VARCHAR(3000), 33 | "is_favorite" BOOLEAN NOT NULL DEFAULT false, 34 | "read_at" TIMESTAMP(3), 35 | "thumbnail_image_url" TEXT NOT NULL, 36 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 37 | "updated_at" TIMESTAMP(3) NOT NULL, 38 | "user_id" TEXT NOT NULL, 39 | "folder_id" TEXT NOT NULL, 40 | "ai_classification_id" TEXT, 41 | 42 | CONSTRAINT "posts_pkey" PRIMARY KEY ("id") 43 | ); 44 | 45 | -- CreateTable 46 | CREATE TABLE "ai_classifications" ( 47 | "id" TEXT NOT NULL, 48 | "url" VARCHAR(2048) NOT NULL, 49 | "description" VARCHAR(3000), 50 | "keywords" TEXT[] DEFAULT ARRAY[]::TEXT[], 51 | "completed_at" TIMESTAMP(3), 52 | "deleted_at" TIMESTAMP(3), 53 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 54 | "updated_at" TIMESTAMP(3) NOT NULL, 55 | "suggested_folder_id" TEXT, 56 | "user_id" TEXT NOT NULL, 57 | 58 | CONSTRAINT "ai_classifications_pkey" PRIMARY KEY ("id") 59 | ); 60 | 61 | -- CreateTable 62 | CREATE TABLE "post_keywords" ( 63 | "id" TEXT NOT NULL, 64 | "post_id" TEXT NOT NULL, 65 | "keyword_id" TEXT NOT NULL, 66 | 67 | CONSTRAINT "post_keywords_pkey" PRIMARY KEY ("id") 68 | ); 69 | 70 | -- CreateTable 71 | CREATE TABLE "keywords" ( 72 | "id" TEXT NOT NULL, 73 | "name" VARCHAR(100) NOT NULL, 74 | 75 | CONSTRAINT "keywords_pkey" PRIMARY KEY ("id") 76 | ); 77 | 78 | -- CreateIndex 79 | CREATE UNIQUE INDEX "users_device_token_key" ON "users"("device_token"); 80 | 81 | -- CreateIndex 82 | CREATE UNIQUE INDEX "folders_name_user_id_key" ON "folders"("name", "user_id"); 83 | 84 | -- CreateIndex 85 | CREATE UNIQUE INDEX "posts_ai_classification_id_key" ON "posts"("ai_classification_id"); 86 | 87 | -- AddForeignKey 88 | ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 89 | 90 | -- AddForeignKey 91 | ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 92 | 93 | -- AddForeignKey 94 | ALTER TABLE "posts" ADD CONSTRAINT "posts_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE; 95 | 96 | -- AddForeignKey 97 | ALTER TABLE "posts" ADD CONSTRAINT "posts_ai_classification_id_fkey" FOREIGN KEY ("ai_classification_id") REFERENCES "ai_classifications"("id") ON DELETE SET NULL ON UPDATE CASCADE; 98 | 99 | -- AddForeignKey 100 | ALTER TABLE "ai_classifications" ADD CONSTRAINT "ai_classifications_suggested_folder_id_fkey" FOREIGN KEY ("suggested_folder_id") REFERENCES "folders"("id") ON DELETE SET NULL ON UPDATE CASCADE; 101 | 102 | -- AddForeignKey 103 | ALTER TABLE "ai_classifications" ADD CONSTRAINT "ai_classifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 104 | 105 | -- AddForeignKey 106 | ALTER TABLE "post_keywords" ADD CONSTRAINT "post_keywords_post_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE ON UPDATE CASCADE; 107 | 108 | -- AddForeignKey 109 | ALTER TABLE "post_keywords" ADD CONSTRAINT "post_keywords_keyword_id_fkey" FOREIGN KEY ("keyword_id") REFERENCES "keywords"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 110 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("POSTGRES_URL") 14 | } 15 | 16 | model User { 17 | id String @id @default(uuid()) 18 | deviceToken String @unique @map("device_token") @db.VarChar(20) 19 | 20 | // Relations 21 | folders Folder[] 22 | posts Post[] 23 | aiClassifications AIClassification[] 24 | 25 | @@map("users") 26 | } 27 | 28 | enum FolderType { 29 | custom 30 | default 31 | } 32 | 33 | model Folder { 34 | id String @id @default(uuid()) 35 | name String @db.VarChar(30) 36 | type FolderType 37 | createdAt DateTime @default(now()) @map("created_at") 38 | updatedAt DateTime @updatedAt @map("updated_at") 39 | 40 | // Relations 41 | userId String @map("user_id") 42 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 43 | aiClassifications AIClassification[] 44 | Post Post[] 45 | 46 | @@unique([name, userId]) 47 | @@map("folders") 48 | } 49 | 50 | enum PostAiStatus { 51 | IN_PROGRES 52 | SUCCESS 53 | FAIL 54 | } 55 | 56 | model Post { 57 | id String @id @default(uuid()) 58 | url String @db.VarChar(2048) // Standard Chrome Maximum URL length 59 | title String @db.VarChar(50) 60 | description String? @db.VarChar(3000) 61 | isFavorite Boolean @default(false) @map("is_favorite") 62 | readAt DateTime? @map("read_at") 63 | thumbnailImageUrl String @map("thumbnail_image_url") @db.Text() 64 | createdAt DateTime @default(now()) @map("created_at") 65 | updatedAt DateTime @updatedAt @map("updated_at") 66 | 67 | // Relations 68 | userId String @map("user_id") 69 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 70 | folderId String @map("folder_id") 71 | folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade) 72 | aiClassificationId String? @unique @map("ai_classification_id") 73 | aiClassification AIClassification? @relation(fields: [aiClassificationId], references: [id], onDelete: SetNull) 74 | postKeywords PostKeyword[] 75 | 76 | @@map("posts") 77 | } 78 | 79 | model AIClassification { 80 | id String @id @default(uuid()) 81 | url String @db.VarChar(2048) 82 | description String? @db.VarChar(3000) 83 | keywords String[] @default([]) 84 | completedAt DateTime? @map("completed_at") 85 | deletedAt DateTime? @map("deleted_at") 86 | createdAt DateTime @default(now()) @map("created_at") 87 | updatedAt DateTime @updatedAt @map("updated_at") 88 | 89 | // Relations 90 | suggested_folder_id String? 91 | folder Folder? @relation(fields: [suggested_folder_id], references: [id], onDelete: SetNull) 92 | user_id String 93 | user User @relation(fields: [user_id], references: [id], onDelete: Cascade) 94 | posts Post? 95 | 96 | @@map("ai_classifications") 97 | } 98 | 99 | model PostKeyword { 100 | id String @id @default(uuid()) 101 | 102 | // Relations 103 | postId String @map("post_id") 104 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 105 | keywordId String @map("keyword_id") 106 | keyword Keyword @relation(fields: [keywordId], references: [id], onDelete: Restrict) 107 | 108 | @@map("post_keywords") 109 | } 110 | 111 | model Keyword { 112 | id String @id @default(uuid()) 113 | name String @db.VarChar(100) 114 | 115 | // Relations 116 | postKeywords PostKeyword[] 117 | 118 | @@map("keywords") 119 | } 120 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: dorabangs-node 2 | plugins: 3 | - serverless-dotenv-plugin 4 | - serverless-offline 5 | 6 | provider: 7 | name: aws 8 | runtime: nodejs20.x 9 | region: ap-northeast-2 10 | architecture: arm64 11 | 12 | custom: 13 | dotenv: 14 | basePath: ./ 15 | optimize: 16 | external: ['swagger-ui-dist'] 17 | 18 | functions: 19 | server: 20 | handler: dist/index.handler 21 | timeout: 30 22 | events: 23 | - http: 24 | method: any 25 | path: '/' 26 | cors: 27 | origin: '*' 28 | - http: 29 | method: any 30 | path: '{proxy+}' 31 | cors: 32 | origin: '*' 33 | aiWorker: 34 | handler: worker-dist/index.handler 35 | timeout: 30 36 | 37 | package: 38 | exclude: 39 | - '**/*' 40 | include: 41 | - 'dist/**' 42 | - 'worker-dist/**' 43 | -------------------------------------------------------------------------------- /src/ai_handler.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { Handler } from 'aws-lambda'; 5 | import { DatabaseModule } from './infrastructure'; 6 | import { AiModule } from './infrastructure/ai/ai.module'; 7 | import { AiClassificationPayload } from './infrastructure/aws-lambda/type'; 8 | import { DiscordModule } from './infrastructure/discord/discord.module'; 9 | import { AiClassificationModule } from './modules/ai-classification/ai-classification.module'; 10 | import { AiClassificationService } from './modules/ai-classification/ai-classification.service'; 11 | 12 | @Module({ 13 | imports: [ 14 | ConfigModule.forRoot({ 15 | isGlobal: true, 16 | cache: true, 17 | envFilePath: `.env.${process.env.NODE_ENV || 'local'}`, 18 | }), 19 | DatabaseModule, 20 | DiscordModule, 21 | AiModule, 22 | AiClassificationModule, 23 | ], 24 | }) 25 | export class WorkerModule {} 26 | 27 | export const handler: Handler = async (event: AiClassificationPayload) => { 28 | const app = await NestFactory.create(WorkerModule); 29 | const aiClassificationService = app.get(AiClassificationService); 30 | 31 | const result = await aiClassificationService.execute(event); 32 | 33 | // NOTE: cloud-watch 로그 확인용 34 | console.log({ 35 | result: result.success ? 'success' : 'fail', 36 | event: JSON.stringify(event, null, 2), 37 | summarizeUrlContent: result, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/app.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ClassSerializerInterceptor, 4 | INestApplication, 5 | ValidationPipe, 6 | VersioningType, 7 | } from '@nestjs/common'; 8 | import { ConfigService } from '@nestjs/config'; 9 | import { Reflector } from '@nestjs/core'; 10 | import { 11 | CommonResponseInterceptor, 12 | RootExceptionFilter, 13 | createErrorObject, 14 | } from './common'; 15 | import { DiscordErrorWebhookProvider } from './infrastructure/discord/discord-error-webhook.provider'; 16 | 17 | export async function nestAppConfig< 18 | T extends INestApplication = INestApplication, 19 | >(app: T) { 20 | const reflector = app.get(Reflector); 21 | 22 | app.enableVersioning({ 23 | type: VersioningType.URI, 24 | }); 25 | 26 | app.useGlobalPipes( 27 | new ValidationPipe({ 28 | whitelist: true, 29 | transform: true, 30 | exceptionFactory: (error) => { 31 | const validationErrorObject = error[0]; 32 | // Validation Error Property 33 | const targetProperty = validationErrorObject.property; 34 | // Validation Error Description 35 | const validationErrorDetail = 36 | validationErrorObject.constraints[ 37 | Object.keys(validationErrorObject.constraints)[0] 38 | ]; 39 | const errorMessage = `'${targetProperty}' 필드의 검증 오류가 발생했습니다: ${validationErrorDetail}`; 40 | return new BadRequestException(createErrorObject('V000', errorMessage)); 41 | }, 42 | stopAtFirstError: true, 43 | }), 44 | ); 45 | app.useGlobalInterceptors(new ClassSerializerInterceptor(reflector)); 46 | } 47 | 48 | export function nestResponseConfig< 49 | T extends INestApplication = INestApplication, 50 | >(app: T, connectSentry = false) { 51 | app.useGlobalInterceptors(new CommonResponseInterceptor()); 52 | connectSentry 53 | ? configExceptionFilterWithSentry(app) 54 | : configFilterStandAlone(app); 55 | } 56 | 57 | // Enable Exception Filter stand-alone 58 | function configFilterStandAlone( 59 | app: T, 60 | ) { 61 | app.useGlobalFilters( 62 | new RootExceptionFilter( 63 | new DiscordErrorWebhookProvider(new ConfigService()), 64 | ), 65 | ); 66 | } 67 | 68 | // Enalbe Exception Filter with Sentry Connection 69 | function configExceptionFilterWithSentry< 70 | T extends INestApplication = INestApplication, 71 | >(app: T) { 72 | // const config = app.get(ConfigService); 73 | // Sentry.init({ 74 | // dsn: config.get('SENTRY_DSN'), 75 | // integrations: [nodeProfilingIntegration()], 76 | // tracesSampleRate: 1.0, 77 | // profilesSampleRate: 1.0, 78 | // }); 79 | // Sentry.setupNestErrorHandler(app, new RootExceptionFilter()); 80 | } 81 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello Dorabangs!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | // Nest Packagess 2 | import { Controller, Get } from '@nestjs/common'; 3 | import { ApiOperation } from '@nestjs/swagger'; 4 | // Custom Packages 5 | import { AppService } from './app.service'; 6 | 7 | @Controller() 8 | export class AppController { 9 | constructor(private readonly appService: AppService) {} 10 | 11 | @Get() 12 | getHello(): string { 13 | return this.appService.getHello(); 14 | } 15 | 16 | @Get('/pool/metrics') 17 | @ApiOperation({ 18 | summary: 'Puppeteer Pool Metric 모니터링 API (Client용 아닙니다)', 19 | }) 20 | async poolMetrics() { 21 | return await this.appService.poolMetrics(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | // Nest Packages 2 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 3 | // Custom Packages 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { DatabaseModule } from '@src/infrastructure'; 6 | import { AppController } from './app.controller'; 7 | import { AppService } from './app.service'; 8 | import { LoggerMiddleware } from './common/middlewares'; 9 | import { AiModule } from './infrastructure/ai/ai.module'; 10 | import { AwsLambdaModule } from './infrastructure/aws-lambda/aws-lambda.module'; 11 | import { DiscordModule } from './infrastructure/discord/discord.module'; 12 | import { PuppeteerPoolModule } from './infrastructure/puppeteer-pool/puppeteer-pool.module'; 13 | import { AuthModule } from './modules/auth/auth.module'; 14 | import { ClassificationModule } from './modules/classification/classification.module'; 15 | import { FoldersModule } from './modules/folders/folders.module'; 16 | import { LaunchingEventsModule } from './modules/launching-events/launching-events.module'; 17 | import { LinksModule } from './modules/links/links.module'; 18 | import { MetricsModule } from './modules/metrics/metrics.module'; 19 | import { OnboardModule } from './modules/onboard/onboard.module'; 20 | import { PostsModule } from './modules/posts/posts.module'; 21 | import { UsersModule } from './modules/users/users.module'; 22 | 23 | @Module({ 24 | imports: [ 25 | ConfigModule.forRoot({ 26 | isGlobal: true, 27 | cache: true, 28 | envFilePath: `.env.${process.env.NODE_ENV || 'local'}`, 29 | }), 30 | DatabaseModule, 31 | DiscordModule, 32 | AiModule, 33 | UsersModule, 34 | ClassificationModule, 35 | AuthModule, 36 | FoldersModule, 37 | LinksModule, 38 | PostsModule, 39 | AwsLambdaModule, 40 | OnboardModule, 41 | MetricsModule, 42 | LaunchingEventsModule, 43 | PuppeteerPoolModule, 44 | // PrismaModule, 45 | ], 46 | controllers: [AppController], 47 | providers: [AppService], 48 | }) 49 | export class AppModule implements NestModule { 50 | configure(consumer: MiddlewareConsumer): void { 51 | consumer.apply(LoggerMiddleware).forRoutes('/*'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PuppeteerPoolService } from './infrastructure/puppeteer-pool/puppeteer-pool.service'; 4 | 5 | @Injectable() 6 | export class AppService { 7 | constructor( 8 | private readonly config: ConfigService, 9 | private readonly puppeteer: PuppeteerPoolService, 10 | ) {} 11 | 12 | getHello(): string { 13 | return 'Hello Dorabangs!'; 14 | } 15 | 16 | async poolMetrics() { 17 | return await this.puppeteer.getPoolMetrics(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app.swagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export function nestSwaggerConfig< 5 | T extends INestApplication = INestApplication, 6 | >(app: T) { 7 | const config = new DocumentBuilder(); 8 | config 9 | .setTitle('Linkit API') 10 | .setDescription( 11 | ` 12 | ## Linkit Open API Specification 13 | 14 | 반환되는 에러코드에 대한 정의입니다! 15 | 16 | ### [폴더]\n 17 | 18 | - F001 - 폴더 이름이 중복되는 경우 입니다\n 19 | - F002 - 폴더가 사용자의 폴더가 아닌 경우 입니다 20 | - F003 - 수정하려는 폴더 이름이 기존 이름과 동일한 이름입니다 21 | \n 22 | 23 | ### [피드]\n 24 | 25 | - P001 - 피드가 사용자의 피드가 아닌경우 입니다 26 | \n 27 | ### [Classification]\n 28 | 29 | - C001 - Classification이 이미 삭제된 경우입니다(추천해준 폴더로 옮겼을때) 30 | \n 31 | ### [Validation]\n 32 | - V000 - Dto, Query Param등에 대한 Validation 오류입니다 33 | `, 34 | ) 35 | .setVersion('1.0.0') 36 | .setContact( 37 | 'Dorabangs Node.js Team', 38 | 'https://github.com/mash-up-kr/Dorabangs-Node', 39 | 'some@email.com', 40 | ) 41 | .addBearerAuth() 42 | .build(); 43 | 44 | const document = SwaggerModule.createDocument(app, config.build()); 45 | SwaggerModule.setup('docs', app, document, { 46 | explorer: true, 47 | customSiteTitle: 'Linkit API OAS', 48 | swaggerOptions: { 49 | persistAuthorization: true, // https://github.com/scottie1984/swagger-ui-express/issues/44#issuecomment-974749930 50 | displayRequestDuration: true, 51 | }, 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ExpressAdapter } from '@nestjs/platform-express'; 3 | import express from 'express'; 4 | import { nestAppConfig, nestResponseConfig } from './app.config'; 5 | import { AppModule } from './app.module'; 6 | import { nestSwaggerConfig } from './app.swagger'; 7 | 8 | export async function bootstrap() { 9 | const expressInstance: express.Express = express(); 10 | const app = await NestFactory.create( 11 | AppModule, 12 | new ExpressAdapter(expressInstance), 13 | { cors: true }, 14 | ); 15 | 16 | // Config Nest.js Application 17 | nestAppConfig(app); 18 | // Config application Swagger 19 | nestSwaggerConfig(app); 20 | // Config Response 21 | nestResponseConfig(app, false); 22 | 23 | return { app, expressInstance }; 24 | } 25 | 26 | export async function runServer() { 27 | const { app } = await bootstrap(); 28 | await app.listen(3000); 29 | return app; 30 | } 31 | -------------------------------------------------------------------------------- /src/common/constant/index.ts: -------------------------------------------------------------------------------- 1 | export const IS_LOCAL = process.env.NODE_ENV === 'local'; 2 | export const DEFAULT_FOLDER_NAME = '나중에 읽을 링크'; 3 | export const TOKEN_LEAST_LIMIT = 500; 4 | export const CONTENT_LEAST_LIMIT = 800; 5 | -------------------------------------------------------------------------------- /src/common/decorators/getUser.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | UnauthorizedException, 4 | createParamDecorator, 5 | } from '@nestjs/common'; 6 | import { Request } from 'express'; 7 | import { ReqUserPayload } from '../types/type'; 8 | 9 | /** 10 | * @GetUser 11 | * 12 | * - Param Decorator 13 | * - @GetUser의 매개변수로는 ReqUserPaylod의 프로퍼티를 주거나 생략할 수 있습니다. 14 | * 15 | */ 16 | export const GetUser = createParamDecorator( 17 | ( 18 | property: keyof ReqUserPayload, 19 | context: ExecutionContext, 20 | ): ReqUserPayload | ReqUserPayload[keyof ReqUserPayload] => { 21 | // Get context Request Object 22 | const request = context.switchToHttp().getRequest(); 23 | // Expect Request, user property as type 'ReqUserPayload'(Refer to defined in common/types/type.d.ts) 24 | const user: Express.User = request.user; 25 | if (!user) { 26 | throw new UnauthorizedException('인증에 실패하였습니다.'); 27 | } 28 | return user['id']; 29 | }, 30 | ); 31 | -------------------------------------------------------------------------------- /src/common/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getUser.decorator'; 2 | export * from './public.decorator'; 3 | -------------------------------------------------------------------------------- /src/common/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | /** 4 | * @Public Decorator 5 | * 6 | * Controller레벨 혹은 애플리케이션 Global Guard가 걸려있는 경우 7 | * 특정 Route에 대해 Public을 허용할 수 있게 하기 위해 사용합니다. 8 | */ 9 | 10 | export const PublicRouteToken = Symbol('public-route'); 11 | export const Public = () => SetMetadata(PublicRouteToken, true); 12 | -------------------------------------------------------------------------------- /src/common/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request'; 2 | export * from './response'; 3 | -------------------------------------------------------------------------------- /src/common/dto/request/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination.dto'; 2 | -------------------------------------------------------------------------------- /src/common/dto/request/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsEnum, IsNumber, IsOptional, Min } from 'class-validator'; 4 | import { OrderType } from '@src/common/types'; 5 | 6 | /** 7 | * Base Query for pagination 8 | * 9 | */ 10 | export class BasePaginationQuery { 11 | @ApiProperty({ 12 | description: 'Pagination - Page', 13 | default: 1, 14 | required: false, 15 | }) 16 | @Type(() => Number) 17 | @IsOptional() 18 | @IsNumber() 19 | @Min(1) 20 | readonly page = 1; 21 | 22 | @ApiProperty({ 23 | description: 'Pagination - Limit', 24 | default: 10, 25 | required: false, 26 | }) 27 | @Type(() => Number) 28 | @IsOptional() 29 | @IsNumber() 30 | @Min(1) 31 | readonly limit = 10; 32 | 33 | @ApiProperty({ 34 | required: false, 35 | enum: OrderType, 36 | }) 37 | @IsOptional() 38 | @IsEnum(OrderType) 39 | readonly order: OrderType; 40 | 41 | getOffset(): number { 42 | return Math.max(0, (this.page - 1) * this.limit); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/common/dto/response/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination.dto'; 2 | -------------------------------------------------------------------------------- /src/common/dto/response/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Exclude, Expose } from 'class-transformer'; 3 | 4 | /** 5 | * Metadata for pagination 6 | */ 7 | export class PaginationMetadata { 8 | @Exclude() 9 | private _page: number; 10 | 11 | @Exclude() 12 | private _limit: number; 13 | 14 | @Exclude() 15 | private _total: number; 16 | 17 | @Exclude() 18 | private _lastPage: number; 19 | 20 | @Exclude() 21 | private _nextPage: number; 22 | 23 | constructor(page: number, limit: number, total: number) { 24 | this._page = page; 25 | this._limit = limit; 26 | this._total = total; 27 | this._lastPage = Math.ceil(this._total / this._limit); 28 | this._nextPage = this._page < this._lastPage ? this._page + 1 : null; 29 | } 30 | 31 | @Expose() 32 | @ApiProperty({ 33 | type: Boolean, 34 | }) 35 | get hasNext(): boolean { 36 | return Boolean(this._nextPage); 37 | } 38 | 39 | @Expose() 40 | @ApiProperty({ 41 | type: Number, 42 | }) 43 | get total(): number { 44 | return this._total; 45 | } 46 | } 47 | 48 | /** 49 | * Base Pagination Response 50 | */ 51 | export abstract class BasePaginationResponse { 52 | @ApiProperty({ 53 | type: PaginationMetadata, 54 | }) 55 | metadata: PaginationMetadata; 56 | 57 | list: T | T[] | unknown; 58 | 59 | constructor( 60 | page: number, 61 | limit: number, 62 | count: number, 63 | data: T | T[] | unknown, 64 | ) { 65 | this.metadata = new PaginationMetadata(page, limit, count); 66 | this.list = data; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/common/error/exception.abstract.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionPayload } from '../types/type'; 2 | 3 | export abstract class RootException< 4 | T extends ExceptionPayload = ExceptionPayload, 5 | U extends number = number, 6 | > extends Error { 7 | constructor( 8 | public readonly payload: T, 9 | public readonly statuscode: U, 10 | public readonly name: string, 11 | ) { 12 | super(); 13 | this.message = payload.message as string; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/error/exception.factory.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionPayload } from '../types/type'; 2 | import { RootException } from './exception.abstract'; 3 | 4 | const codeUnknown = 'Unknown'; 5 | 6 | export const createException = ( 7 | statusCode: number, 8 | message: string, 9 | code = codeUnknown, 10 | ) => { 11 | const payload: ExceptionPayload = { 12 | code: code, 13 | message: message, 14 | }; 15 | 16 | const errorContextName = 17 | code === codeUnknown ? `${codeUnknown} - ${message}` : code; 18 | return class extends RootException { 19 | constructor() { 20 | super(payload, statusCode, errorContextName); 21 | } 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/common/error/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exception.abstract'; 2 | export * from './exception.factory'; 3 | -------------------------------------------------------------------------------- /src/common/filter/base.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | } from '@nestjs/common'; 7 | import { captureException } from '@sentry/node'; 8 | import { Response } from 'express'; 9 | import { DiscordErrorWebhookProvider } from '@src/infrastructure/discord/discord-error-webhook.provider'; 10 | import { RootException, createException } from '../error'; 11 | import { ExceptionPayload, ICommonResponse } from '../types/type'; 12 | 13 | @Catch() 14 | export class RootExceptionFilter implements ExceptionFilter { 15 | private unknownCode = 'Unknown'; 16 | 17 | constructor( 18 | private readonly discordErrorWebhookProvider: DiscordErrorWebhookProvider, 19 | ) {} 20 | 21 | async catch(exception: any, host: ArgumentsHost) { 22 | const context = host.switchToHttp(); 23 | const request = context.getRequest(); 24 | const response: Response = context.getResponse(); 25 | console.log(exception); 26 | 27 | let targetException = exception; 28 | let responseStatusCode = 500; 29 | let responseErrorPayload: ExceptionPayload = { 30 | code: this.unknownCode, 31 | message: '', 32 | }; 33 | 34 | // If exception is http exception instance 35 | if (targetException instanceof HttpException) { 36 | // Response Message 37 | const response = targetException.getResponse(); 38 | // Response Status Code 39 | responseStatusCode = targetException.getStatus(); 40 | responseErrorPayload = response as ExceptionPayload; 41 | } 42 | // Custom Exception 43 | else if (targetException instanceof RootException) { 44 | // Response Message 45 | const response = targetException.payload; 46 | // Response Status Code 47 | const statusCode = targetException.statuscode; 48 | responseErrorPayload = response; 49 | responseStatusCode = statusCode; 50 | } 51 | // Error 52 | else { 53 | const errorMessage = targetException.message; 54 | // Response Status Code 55 | responseStatusCode = 500; 56 | // Response Message 57 | responseErrorPayload = { 58 | code: this.unknownCode, 59 | message: errorMessage, 60 | }; 61 | targetException = new (class extends createException( 62 | responseStatusCode, 63 | errorMessage ?? exception.name, 64 | this.unknownCode, 65 | ) {})(); 66 | } 67 | captureException(targetException); 68 | const exceptionResponse: ICommonResponse = { 69 | success: false, 70 | error: responseErrorPayload, 71 | }; 72 | 73 | if (responseStatusCode >= 500) { 74 | await this.handle(request, exception); 75 | } 76 | 77 | return response.status(responseStatusCode).json(exceptionResponse); 78 | } 79 | 80 | private async handle(request: Request, error: Error) { 81 | const content = this.parseError(request, error); 82 | await this.discordErrorWebhookProvider.send(content); 83 | } 84 | 85 | private parseError(request: Request, error: Error): string { 86 | return `노드팀 채찍 맞아라~~ 🦹🏿‍♀️👹🦹🏿 87 | 에러 발생 API : ${request.method} ${request.url} 88 | 89 | 에러 메세지 : ${error.message} 90 | 91 | 에러 위치 : ${error.stack 92 | .split('\n') 93 | .slice(0, 2) 94 | .map((message) => message.trim()) 95 | .join('\n')} 96 | 97 | 당장 고쳐서 올렷! 98 | `; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/common/filter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.filter'; 2 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorators'; 2 | export * from './dto'; 3 | export * from './error'; 4 | export * from './filter'; 5 | export * from './interceptor'; 6 | export * from './types'; 7 | export * from './utils'; 8 | -------------------------------------------------------------------------------- /src/common/interceptor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './response.interceptor'; 2 | -------------------------------------------------------------------------------- /src/common/interceptor/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; 2 | import { Observable, map } from 'rxjs'; 3 | import { ICommonResponse } from '../types/type'; 4 | 5 | export class CommonResponseInterceptor implements NestInterceptor { 6 | intercept( 7 | context: ExecutionContext, 8 | next: CallHandler, 9 | ): Observable | Promise> { 10 | return next.handle().pipe( 11 | map((payload = {}): ICommonResponse => { 12 | return { 13 | success: true, 14 | data: payload, 15 | }; 16 | }), 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/common/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logging.interceptor'; 2 | -------------------------------------------------------------------------------- /src/common/middlewares/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | @Injectable() 5 | export class LoggerMiddleware implements NestMiddleware { 6 | private logger = new Logger('HTTP'); 7 | 8 | use(request: Request, response: Response, next: NextFunction): void { 9 | const { ip, method, originalUrl } = request; 10 | 11 | const start = Date.now(); 12 | 13 | response.on('finish', () => { 14 | const { statusCode } = response; 15 | const end = Date.now(); 16 | const duration = end - start; 17 | const userId = request.user ? request.user['id'] : '-'; 18 | 19 | const logMessage = [ 20 | `${method} ${originalUrl} ${statusCode} ${ip} - ${duration}ms`, 21 | `User Id : ${userId}`, 22 | `Query ${JSON.stringify(request.query)}`, 23 | `Body ${JSON.stringify(request.body)}`, 24 | ].join('\n'); 25 | this.logger.log(logMessage); 26 | }); 27 | 28 | next(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/common/types/enum.ts: -------------------------------------------------------------------------------- 1 | export enum OrderType { 2 | asc = 'asc', 3 | desc = 'desc', 4 | } 5 | -------------------------------------------------------------------------------- /src/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './enum'; 2 | -------------------------------------------------------------------------------- /src/common/types/type.d.ts: -------------------------------------------------------------------------------- 1 | // Exception Payload 2 | export type ExceptionPayload = { 3 | code?: string; 4 | message: string | object; 5 | }; 6 | 7 | export type ICommonResponse = ICommonErrorResponse | ICommonSuccessResponse; 8 | 9 | // Error Response 10 | export type ICommonErrorResponse = { 11 | success: true; 12 | data: any; 13 | }; 14 | 15 | // Success Response 16 | export type ICommonSuccessResponse = { 17 | success: false; 18 | error: ExceptionPayload; 19 | }; 20 | 21 | export type JwtPayload = { 22 | id: string; 23 | }; 24 | 25 | export type ReqUserPayload = { 26 | id: string; 27 | }; 28 | 29 | export type FunctionType = (...args: any[]) => any; 30 | -------------------------------------------------------------------------------- /src/common/utils/createErrorObject.util.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionPayload } from '../types/type'; 2 | 3 | export const createErrorObject = ( 4 | errorCode: string, 5 | message: string, 6 | ): ExceptionPayload => { 7 | return { 8 | code: errorCode, 9 | message: message, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { FunctionType } from '../types/type'; 2 | 3 | export * from './createErrorObject.util'; 4 | export * from './math.util'; 5 | export * from './parser.util'; 6 | 7 | export const pipe = (...functions: FunctionType[]) => { 8 | return (initial: unknown) => 9 | functions.reduce((result, func) => { 10 | return func(result); 11 | }, initial); 12 | }; 13 | -------------------------------------------------------------------------------- /src/common/utils/math.util.ts: -------------------------------------------------------------------------------- 1 | export const sum = ( 2 | _list: T[], 3 | selector?: T extends object ? (item: T) => number : never, 4 | ): number => { 5 | if (_list.length === 0) return 0; 6 | 7 | const list = (selector ? _list.map(selector) : _list) as number[]; 8 | return list.reduce((sum, cur) => cur + sum, 0); 9 | }; 10 | -------------------------------------------------------------------------------- /src/common/utils/parser.util.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import iconv from 'iconv-lite'; 3 | 4 | /** 5 | * 6 | * HTML Parser Utility Function. 7 | * 8 | * Parse Content and Title 9 | */ 10 | export async function parseLinkTitleAndContent(url: string): Promise<{ 11 | title: string; 12 | content: string; 13 | thumbnail: string; 14 | thumbnailDescription: string; 15 | }> { 16 | const response = await fetch(url); 17 | const arrayBuffer = await response.arrayBuffer(); 18 | 19 | let charset = 'utf-8'; 20 | const htmlText = new TextDecoder(charset).decode(arrayBuffer); 21 | 22 | /** 23 | * Parse ]+)["']?/i, 28 | ); 29 | if (metaCharsetMatch) { 30 | charset = metaCharsetMatch[1].toLowerCase().trim(); 31 | } else { 32 | const metaContentTypeMatch = htmlText.match( 33 | /]+(>|$)/g, '') 64 | .trim(); 65 | return { 66 | title: title ? title : 'Page Title', 67 | content, 68 | thumbnail: sanitizeThumbnail(thumbnail), 69 | thumbnailDescription, 70 | }; 71 | } 72 | 73 | function sanitizeThumbnail(thumbnail: string) { 74 | if (!thumbnail) { 75 | return ''; 76 | } 77 | 78 | if (thumbnail.startsWith('//')) { 79 | return thumbnail.substring(2); 80 | } 81 | 82 | return thumbnail; 83 | } 84 | -------------------------------------------------------------------------------- /src/common/utils/tokenizer.ts: -------------------------------------------------------------------------------- 1 | // Do not import 'tiktoken' 2 | import { encodingForModel } from 'js-tiktoken'; 3 | import { gptVersion } from '@src/infrastructure/ai/ai.constant'; 4 | import { summarizeURLContentFunctionFactory } from '@src/infrastructure/ai/functions'; 5 | 6 | const encoder = encodingForModel(gptVersion); 7 | 8 | // Reference: https://platform.openai.com/docs/advanced-usage/managing-tokens 9 | // 주의: 실제 Open AI랑 약간의 오차 존재 10 | export function promptTokenCalculator(content: string, folderList: string[]) { 11 | let tokenCount = 0; 12 | 13 | // Prompt Calculation 14 | const messages = [ 15 | { 16 | role: 'system', 17 | content: '한글로 답변 부탁해', 18 | }, 19 | { 20 | role: 'user', 21 | content: `주어진 글에 대해 요약하고 키워드 추출, 분류 부탁해 22 | 23 | ${content} 24 | `, 25 | }, 26 | ]; 27 | for (const message of messages) { 28 | // Message struct Overhead 29 | tokenCount += 4; 30 | tokenCount += encoder.encode(message.role).length; 31 | tokenCount += encoder.encode(message.content).length; 32 | } 33 | tokenCount += 2; 34 | 35 | // Function Calculation 36 | tokenCount += encoder.encode( 37 | JSON.stringify(summarizeURLContentFunctionFactory(folderList)), 38 | ).length; 39 | return tokenCount; 40 | } 41 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import { Context, Handler } from 'aws-lambda'; 2 | import { createServer, proxy } from 'aws-serverless-express'; 3 | import { Server } from 'http'; 4 | import { bootstrap, runServer } from './bootstrap'; 5 | 6 | let cachedServer: Server; 7 | export const handler: Handler = async (event: any, context: Context) => { 8 | if (!cachedServer) { 9 | const { app, expressInstance } = await bootstrap(); 10 | await app.init(); 11 | cachedServer = createServer(expressInstance); 12 | } 13 | 14 | if (event.path === '/docs') { 15 | event.path = '/docs/'; 16 | } 17 | if (event.path) { 18 | event.path = event.path.includes('swagger-ui') 19 | ? `/docs${event.path}` 20 | : event.path; 21 | } 22 | return proxy(cachedServer, event, context, 'PROMISE').promise; 23 | }; 24 | 25 | if (process.env.NODE_ENV === 'local') { 26 | runServer() 27 | .then(() => console.log('Nest Ready')) 28 | .catch((error) => console.log(error)); 29 | } 30 | -------------------------------------------------------------------------------- /src/infrastructure/ai/ai.constant.ts: -------------------------------------------------------------------------------- 1 | // GPT Model Version 2 | export const gptVersion = 'gpt-4o-mini-2024-07-18'; 3 | 4 | // TODO: 서버에서 붙여주는 임의의 폴더 리스트 5 | export const mockFolderLists = []; 6 | -------------------------------------------------------------------------------- /src/infrastructure/ai/ai.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AiService } from './ai.service'; 3 | 4 | @Module({ 5 | imports: [], 6 | providers: [AiService], 7 | exports: [AiService], 8 | }) 9 | export class AiModule {} 10 | -------------------------------------------------------------------------------- /src/infrastructure/ai/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './summarizeURL.response'; 2 | -------------------------------------------------------------------------------- /src/infrastructure/ai/dto/summarizeURL.response.ts: -------------------------------------------------------------------------------- 1 | import { SummarizeURLContent } from '@src/infrastructure/ai/types/types'; 2 | 3 | type SummarizeSuccessType = { 4 | success: true; 5 | isUserCategory: boolean; 6 | response: SummarizeURLContent; 7 | }; 8 | 9 | type SummarizeFailType = { 10 | success: false; 11 | message: string; 12 | thumbnailContent: string; 13 | }; 14 | 15 | type SummarizeResultType = SummarizeSuccessType | SummarizeFailType; 16 | 17 | export class SummarizeURLContentDto { 18 | success: boolean; 19 | isUserCategory?: boolean; 20 | response?: SummarizeURLContent; 21 | message?: string; 22 | thumbnailContent?: string; 23 | 24 | constructor(data: SummarizeResultType) { 25 | // true를 명시하지 않으면 Discriminate Union이 동작 안함 26 | this.success = data.success; 27 | if (data.success === true) { 28 | this.isUserCategory = data.isUserCategory; 29 | this.response = data.response; 30 | } else { 31 | this.message = data.message; 32 | this.thumbnailContent = data.thumbnailContent; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/infrastructure/ai/functions/index.ts: -------------------------------------------------------------------------------- 1 | export type AiClassificationFunctionResult = { 2 | summary: string; 3 | keywords: string[]; 4 | category: string; 5 | }; 6 | 7 | export function summarizeURLContentFunctionFactory(folderList: string[]) { 8 | return { 9 | name: 'summarizeURL', 10 | parameters: { 11 | type: 'object', 12 | properties: { 13 | summary: { 14 | type: 'string', 15 | }, 16 | keywords: { 17 | type: 'array', 18 | items: { 19 | type: 'string', 20 | }, 21 | }, 22 | category: { 23 | type: 'string', 24 | enum: folderList, 25 | }, 26 | }, 27 | required: ['summary', 'keywords', 'category'], 28 | }, 29 | }; 30 | } 31 | 32 | export const getKeywordsFromURLContentFunction = { 33 | name: 'summarizeURL', 34 | parameters: { 35 | type: 'object', 36 | properties: { 37 | summary: { 38 | type: 'string', 39 | }, 40 | keywords: { 41 | type: 'array', 42 | items: { 43 | type: 'string', 44 | }, 45 | }, 46 | }, 47 | required: ['summary', 'keywords', 'category'], 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/infrastructure/ai/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinkIt-Company/Linkit-Backend/e1e1f04cda71b6139437d59bc8c7ec47c6d716b0/src/infrastructure/ai/index.ts -------------------------------------------------------------------------------- /src/infrastructure/ai/types/types.d.ts: -------------------------------------------------------------------------------- 1 | export enum OpenAIPlatform { 2 | azure = 'azure', 3 | openai = 'openai', 4 | } 5 | 6 | export type SummarizeURLContent = { 7 | summary: string; 8 | keywords: string[]; 9 | category: string; 10 | }; 11 | -------------------------------------------------------------------------------- /src/infrastructure/aws-lambda/aws-lambda.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AwsLambdaService } from './aws-lambda.service'; 3 | 4 | @Module({ 5 | providers: [AwsLambdaService], 6 | exports: [AwsLambdaService], 7 | }) 8 | export class AwsLambdaModule {} 9 | -------------------------------------------------------------------------------- /src/infrastructure/aws-lambda/aws-lambda.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; 4 | import { AiClassificationPayload } from './type'; 5 | 6 | @Injectable() 7 | export class AwsLambdaService { 8 | constructor(private readonly config: ConfigService) {} 9 | readonly client = new LambdaClient({ 10 | region: 'ap-northeast-2', 11 | credentials: { 12 | accessKeyId: this.config.get('AWS_LAMBDA_ACCESS_KEY'), 13 | secretAccessKey: this.config.get('AWS_LAMBDA_SECRET_KEY'), 14 | }, 15 | }); 16 | 17 | async invokeLambda( 18 | lambdaFunctionName: string, 19 | payload: AiClassificationPayload, 20 | ): Promise { 21 | const command = new InvokeCommand({ 22 | FunctionName: lambdaFunctionName, 23 | InvocationType: 'Event', 24 | Payload: JSON.stringify(payload), 25 | }); 26 | await this.client.send(command); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/infrastructure/aws-lambda/type.ts: -------------------------------------------------------------------------------- 1 | export type AiClassificationPayload = { 2 | postContent: string; 3 | postThumbnailContent: string; 4 | folderList: { id: string; name: string }[]; 5 | userId: string; 6 | postId: string; 7 | url: string; 8 | }; 9 | -------------------------------------------------------------------------------- /src/infrastructure/database/data-source.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { DataSource } from 'typeorm'; 3 | 4 | dotenv.config(); 5 | 6 | export const dbConfig: any = { 7 | type: 'postgres', 8 | host: process.env.DB_HOST, 9 | port: parseInt(process.env.DB_PORT), 10 | username: process.env.DB_USERNAME, 11 | password: process.env.DB_PASSWORD, 12 | database: process.env.DB_NAME, 13 | entities: [__dirname + '/**/*.entity{.ts,.js}'], 14 | synchronize: process.env.DB_SYNC, 15 | logging: process.env.DB_LOGGING, 16 | migrations: ['**/migrations/*.ts'], 17 | }; 18 | 19 | export const AppDataSource = new DataSource(dbConfig); 20 | -------------------------------------------------------------------------------- /src/infrastructure/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { dbConfig } from './data-source'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forRootAsync({ 10 | imports: [ConfigModule], 11 | inject: [ConfigService], 12 | useFactory: async (config: ConfigService) => { 13 | return { 14 | uri: config.get('MONGO_URL'), 15 | }; 16 | }, 17 | }), 18 | TypeOrmModule.forRoot(dbConfig), 19 | ], 20 | }) 21 | export class DatabaseModule {} 22 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/ai-classification.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | DeleteDateColumn, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | Relation, 8 | } from 'typeorm'; 9 | import { BaseEntity } from './base.entity'; 10 | import { Folder } from './folder.entity'; 11 | 12 | @Entity('ai_classifications') 13 | export class AIClassification extends BaseEntity { 14 | @Column({ name: 'suggested_folder_id' }) 15 | suggestedFolderId: string; 16 | 17 | @Column() 18 | url: string; 19 | 20 | @Column() 21 | description: string; 22 | 23 | @Column('text', { array: true }) 24 | keywords: string[]; 25 | 26 | @Column({ name: 'completed_at', nullable: true }) 27 | completedAt: Date; 28 | 29 | @DeleteDateColumn({ name: 'deleted_at', nullable: true }) 30 | deletedAt: Date; 31 | 32 | @ManyToOne(() => Folder) 33 | @JoinColumn({ name: 'suggested_folder_id' }) 34 | suggestedFolder: Relation; 35 | } 36 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | PrimaryGeneratedColumn, 4 | UpdateDateColumn, 5 | } from 'typeorm'; 6 | 7 | export abstract class BaseEntity { 8 | @PrimaryGeneratedColumn('uuid') 9 | id: string; 10 | 11 | @CreateDateColumn({ name: 'created_at' }) 12 | createdAt: Date; 13 | 14 | @UpdateDateColumn({ name: 'updated_at' }) 15 | updatedAt: Date; 16 | } 17 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/folder.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | ManyToOne, 6 | OneToMany, 7 | Relation, 8 | } from 'typeorm'; 9 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 10 | import { BaseEntity } from './base.entity'; 11 | import { Post } from './post.entity'; 12 | import { User } from './user.entity'; 13 | 14 | @Entity('folders') 15 | export class Folder extends BaseEntity { 16 | @Column({ name: 'user_id' }) 17 | userId: string; 18 | 19 | @Column() 20 | name: string; 21 | 22 | @Column({ type: 'enum', enum: FolderType }) 23 | type: FolderType; 24 | 25 | @Column({ default: true }) 26 | visible: boolean; 27 | 28 | @ManyToOne(() => User, (user) => user.folders) 29 | @JoinColumn({ name: 'user_id' }) 30 | user: Relation; 31 | 32 | @OneToMany(() => Post, (post) => post.folder) 33 | posts: Relation[]; 34 | } 35 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/keyword.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, Relation } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | import { PostKeyword } from './post-keyword.entity'; 4 | 5 | @Entity('keywords') 6 | export class Keyword extends BaseEntity { 7 | @Column() 8 | name: string; 9 | 10 | @OneToMany(() => PostKeyword, (postKeyword) => postKeyword.keyword) 11 | postKeywords: Relation[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/metrics.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | 4 | @Entity('metrics') 5 | export class Metrics extends BaseEntity { 6 | @Column({ name: 'is_success' }) 7 | isSuccess: boolean; 8 | 9 | @Column() 10 | time: number; 11 | 12 | @Column({ name: 'post_url' }) 13 | postURL: string; 14 | 15 | @Column({ name: 'post_id' }) 16 | postId: string; 17 | } 18 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/onboard-category.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('onboard_categories') 4 | export class OnboardCategory { 5 | @PrimaryGeneratedColumn('uuid') 6 | id: string; 7 | 8 | @Column({ unique: true }) 9 | category: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/post-keyword.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | JoinColumn, 4 | ManyToOne, 5 | PrimaryColumn, 6 | Relation, 7 | } from 'typeorm'; 8 | import { Keyword } from './keyword.entity'; 9 | import { Post } from './post.entity'; 10 | 11 | @Entity('post_keywords') 12 | export class PostKeyword { 13 | @PrimaryColumn({ name: 'post_id' }) 14 | postId: string; 15 | 16 | @PrimaryColumn({ name: 'keyword_id' }) 17 | keywordId: string; 18 | 19 | @ManyToOne(() => Post, (post) => post.postKeywords) 20 | @JoinColumn({ name: 'post_id' }) 21 | post: Relation; 22 | 23 | @ManyToOne(() => Keyword, (keyword) => keyword.postKeywords) 24 | @JoinColumn({ name: 'keyword_id' }) 25 | keyword: Relation; 26 | } 27 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/post.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | Entity, 4 | JoinColumn, 5 | ManyToOne, 6 | OneToMany, 7 | Relation, 8 | } from 'typeorm'; 9 | import { PostAiStatus } from '@src/modules/posts/posts.constant'; 10 | import { AIClassification } from './ai-classification.entity'; 11 | import { BaseEntity } from './base.entity'; 12 | import { Folder } from './folder.entity'; 13 | import { PostKeyword } from './post-keyword.entity'; 14 | import { User } from './user.entity'; 15 | 16 | @Entity('posts') 17 | export class Post extends BaseEntity { 18 | @Column({ name: 'user_id' }) 19 | userId: string; 20 | 21 | @Column({ name: 'folder_id' }) 22 | folderId: string; 23 | 24 | @Column() 25 | url: string; 26 | 27 | @Column() 28 | title: string; 29 | 30 | @Column({ nullable: true }) 31 | description: string; 32 | 33 | @Column({ name: 'is_favorite', default: false }) 34 | isFavorite: boolean; 35 | 36 | @Column({ name: 'read_at', nullable: true }) 37 | readAt: Date; 38 | 39 | @Column({ name: 'thumbnail_img_url', nullable: true }) 40 | thumbnailImgUrl: string; 41 | 42 | @Column({ name: 'ai_status', type: 'enum', enum: PostAiStatus }) 43 | aiStatus: PostAiStatus; 44 | 45 | @Column({ name: 'ai_classification_id', nullable: true }) 46 | aiClassificationId: string; 47 | 48 | @ManyToOne(() => User, (user) => user.posts) 49 | @JoinColumn({ name: 'user_id' }) 50 | user: Relation; 51 | 52 | @ManyToOne(() => Folder, (folder) => folder.posts) 53 | @JoinColumn({ name: 'folder_id' }) 54 | folder: Relation; 55 | 56 | @ManyToOne(() => AIClassification) 57 | @JoinColumn({ name: 'ai_classification_id' }) 58 | aiClassification: Relation; 59 | 60 | @OneToMany(() => PostKeyword, (postKeyword) => postKeyword.post) 61 | postKeywords: Relation[]; 62 | } 63 | -------------------------------------------------------------------------------- /src/infrastructure/database/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, Relation } from 'typeorm'; 2 | import { BaseEntity } from './base.entity'; 3 | import { Folder } from './folder.entity'; 4 | import { Post } from './post.entity'; 5 | 6 | @Entity('users') 7 | export class User extends BaseEntity { 8 | @Column({ name: 'device_token', unique: true }) 9 | deviceToken: string; 10 | 11 | @OneToMany(() => Post, (post) => post.user) 12 | posts: Relation[]; 13 | 14 | @OneToMany(() => Folder, (folder) => folder.user) 15 | folders: Relation[]; 16 | } 17 | -------------------------------------------------------------------------------- /src/infrastructure/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@src/infrastructure/database/schema'; 2 | export * from '@src/infrastructure/database/database.module'; 3 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/AIClassification.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; 3 | import { BaseDocument } from './base.schema'; 4 | 5 | @Schema({ 6 | collection: 'ai_classifications', 7 | timestamps: true, 8 | versionKey: false, 9 | }) 10 | export class AIClassification extends BaseDocument { 11 | @Prop({ required: true, type: MongooseSchema.Types.ObjectId, ref: 'Folder' }) 12 | suggestedFolderId: MongooseSchema.Types.ObjectId; 13 | 14 | @Prop({ required: true }) 15 | url: string; 16 | 17 | @Prop({ required: true }) 18 | description: string; 19 | 20 | @Prop({ required: true, type: [String] }) 21 | keywords: string[]; 22 | 23 | @Prop({ type: Date }) 24 | completedAt: Date; 25 | 26 | @Prop({ default: null }) 27 | deletedAt: Date; 28 | } 29 | 30 | export type AIClassificationDocument = HydratedDocument; 31 | export const AIClassificationSchema = 32 | SchemaFactory.createForClass(AIClassification); 33 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/base.schema.ts: -------------------------------------------------------------------------------- 1 | export class BaseDocument { 2 | createdAt: Date; 3 | 4 | updatedAt: Date; 5 | } 6 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/folder.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; 3 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 4 | import { BaseDocument } from './base.schema'; 5 | 6 | @Schema({ collection: 'folders', timestamps: true, versionKey: false }) 7 | export class Folder extends BaseDocument { 8 | @Prop({ required: true, type: MongooseSchema.Types.ObjectId, ref: 'User' }) 9 | userId: MongooseSchema.Types.ObjectId; 10 | 11 | @Prop({ required: true }) 12 | name: string; 13 | 14 | @Prop({ required: true, enum: FolderType, type: String }) 15 | type: FolderType; 16 | 17 | @Prop({ required: true, default: true }) 18 | visible: boolean; 19 | } 20 | 21 | export type FolderDocument = HydratedDocument; 22 | export const FolderSchema = SchemaFactory.createForClass(Folder); 23 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AIClassification.schema'; 2 | export * from './folder.schema'; 3 | export * from './keyword.schema'; 4 | export * from './metrics.schema'; 5 | export * from './post.schema'; 6 | export * from './user.schema'; 7 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/keyword.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | @Schema({ collection: 'keywords', versionKey: false }) 5 | export class Keyword { 6 | @Prop({ required: true }) 7 | name: string; 8 | } 9 | 10 | export type KeywordDocument = Keyword & Document; 11 | export const KeywordSchema = SchemaFactory.createForClass(Keyword); 12 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/metrics.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument } from 'mongoose'; 3 | import { BaseDocument } from './base.schema'; 4 | 5 | @Schema({ collection: 'metrics', timestamps: true, versionKey: false }) 6 | export class Metrics extends BaseDocument { 7 | @Prop({ required: true }) 8 | isSuccess: boolean; 9 | 10 | @Prop({ required: true }) 11 | time: number; 12 | 13 | @Prop({ required: true }) 14 | postURL: string; 15 | 16 | @Prop({ required: true }) 17 | postId: string; // 굳이 연관관계를 가져야하는 필드는 아니므로 string 처리 18 | } 19 | 20 | export type MetricsDocument = HydratedDocument; 21 | export const MetricsSchema = SchemaFactory.createForClass(Metrics); 22 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/post.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; 3 | import { PostAiStatus } from '@src/modules/posts/posts.constant'; 4 | import { AIClassification } from './AIClassification.schema'; 5 | import { BaseDocument } from './base.schema'; 6 | 7 | @Schema({ collection: 'posts', timestamps: true, versionKey: false }) 8 | export class Post extends BaseDocument { 9 | @Prop({ required: true, type: MongooseSchema.Types.ObjectId, ref: 'User' }) 10 | userId!: MongooseSchema.Types.ObjectId; 11 | 12 | @Prop({ required: true, type: MongooseSchema.Types.ObjectId, ref: 'Folder' }) 13 | folderId!: MongooseSchema.Types.ObjectId; 14 | 15 | @Prop({ required: true }) 16 | url!: string; 17 | 18 | @Prop({ required: true }) 19 | title!: string; 20 | 21 | @Prop({ default: null, type: String }) 22 | description: string | null; 23 | 24 | @Prop({ default: false }) 25 | isFavorite: boolean; 26 | 27 | @Prop({ default: null }) 28 | readAt: Date; 29 | 30 | @Prop({ required: false, type: String }) 31 | thumbnailImgUrl?: string | null; 32 | 33 | @Prop({ required: true, enum: PostAiStatus, type: String }) 34 | aiStatus: PostAiStatus; 35 | 36 | @Prop({ 37 | required: false, 38 | type: MongooseSchema.Types.ObjectId, 39 | ref: 'AIClassification', 40 | }) 41 | aiClassificationId?: MongooseSchema.Types.ObjectId | AIClassification; 42 | } 43 | 44 | export type PostDocument = HydratedDocument; 45 | export const PostSchema = SchemaFactory.createForClass(Post); 46 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/postKeyword.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; 3 | 4 | @Schema({ collection: 'post_keywords', versionKey: false }) 5 | export class PostKeyword { 6 | @Prop({ required: true, type: MongooseSchema.Types.ObjectId, ref: 'Post' }) 7 | postId: MongooseSchema.Types.ObjectId; 8 | 9 | @Prop({ required: true, type: MongooseSchema.Types.ObjectId, ref: 'Keyword' }) 10 | keywordId: MongooseSchema.Types.ObjectId; 11 | } 12 | 13 | export type PostKeywordDocument = HydratedDocument; 14 | export const PostKeywordSchema = SchemaFactory.createForClass(PostKeyword); 15 | -------------------------------------------------------------------------------- /src/infrastructure/database/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument } from 'mongoose'; 3 | 4 | @Schema({ collection: 'users', timestamps: true, versionKey: false }) 5 | export class User { 6 | @Prop({ required: true, unique: true }) 7 | deviceToken: string; 8 | } 9 | 10 | export type UserDocument = HydratedDocument; 11 | export const UserSchema = SchemaFactory.createForClass(User); 12 | -------------------------------------------------------------------------------- /src/infrastructure/database/types/folder-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum FolderType { 2 | CUSTOM = 'custom', 3 | DEFAULT = 'default', 4 | 5 | /** 실제 DB에 들어가지는 않고 클라이언트에 내려주기 위해 있는 값 타입 */ 6 | ALL = 'all', 7 | FAVORITE = 'favorite', 8 | } 9 | -------------------------------------------------------------------------------- /src/infrastructure/discord/discord-ai-webhook.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { DiscordWebhookProvider } from './discord-webhook.provider'; 4 | 5 | @Injectable() 6 | export class DiscordAIWebhookProvider extends DiscordWebhookProvider { 7 | protected readonly webhookUrl: string; 8 | 9 | constructor(private readonly configService: ConfigService) { 10 | super(); 11 | this.webhookUrl = this.configService.get('DISCORD_AI_WEBHOOK_URL'); 12 | } 13 | 14 | public async send(content: string) { 15 | await super.send(this.webhookUrl, content); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/infrastructure/discord/discord-error-webhook.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { DiscordWebhookProvider } from './discord-webhook.provider'; 4 | 5 | @Injectable() 6 | export class DiscordErrorWebhookProvider extends DiscordWebhookProvider { 7 | protected webhookUrl: string; 8 | 9 | constructor(private readonly configService: ConfigService) { 10 | super(); 11 | this.webhookUrl = this.configService.get('DISCORD_ERROR_WEBHOOK_URL'); 12 | } 13 | 14 | public async send(content: string) { 15 | await super.send(this.webhookUrl, content); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/infrastructure/discord/discord-webhook.provider.ts: -------------------------------------------------------------------------------- 1 | import { IS_LOCAL } from '@src/common/constant'; 2 | 3 | export class DiscordWebhookProvider { 4 | protected readonly webhookUrl: string; 5 | constructor() {} 6 | 7 | public async send(url: string, content: string) { 8 | if (IS_LOCAL) { 9 | return; 10 | } 11 | 12 | await fetch(url, { 13 | method: 'post', 14 | headers: { 'Content-Type': 'application/json' }, 15 | body: JSON.stringify({ content }), 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/infrastructure/discord/discord.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { DiscordAIWebhookProvider } from './discord-ai-webhook.provider'; 3 | import { DiscordErrorWebhookProvider } from './discord-error-webhook.provider'; 4 | 5 | @Global() 6 | @Module({ 7 | providers: [DiscordAIWebhookProvider, DiscordErrorWebhookProvider], 8 | exports: [DiscordAIWebhookProvider, DiscordErrorWebhookProvider], 9 | }) 10 | export class DiscordModule {} 11 | -------------------------------------------------------------------------------- /src/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@src/infrastructure/database'; 2 | -------------------------------------------------------------------------------- /src/infrastructure/puppeteer-pool/puppeteer-pool.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PuppeteerPoolService } from './puppeteer-pool.service'; 3 | 4 | @Module({ 5 | providers: [PuppeteerPoolService], 6 | exports: [PuppeteerPoolService], 7 | }) 8 | export class PuppeteerPoolModule {} 9 | -------------------------------------------------------------------------------- /src/infrastructure/puppeteer-pool/puppeteer-pool.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | @Injectable() 5 | export class PuppeteerPoolService { 6 | private puppeteerURL: string = ''; 7 | 8 | constructor(private config: ConfigService) { 9 | this.puppeteerURL = `http://${config.get('PUPPETEER_POOL_URL')}`; 10 | } 11 | 12 | async getPoolMetrics() { 13 | try { 14 | const response = await fetch(`${this.puppeteerURL}/health/metrics`, { 15 | method: 'GET', 16 | }); 17 | if (!response.ok) { 18 | throw new Error(); 19 | } 20 | const data = await response.json(); 21 | return data; 22 | } catch (err) { 23 | return 'Fail to retrieve pool metrics'; 24 | } 25 | } 26 | 27 | async invokeRemoteSessionParser(url: string) { 28 | try { 29 | // https://developer.mozilla.org/ko/docs/Web/API/AbortController 30 | const controller = new AbortController(); 31 | const timeOut = setTimeout(() => controller.abort(), 10000); 32 | const response = await fetch(this.puppeteerURL, { 33 | method: 'POST', 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | }, 37 | body: JSON.stringify({ 38 | url: url, 39 | }), 40 | signal: controller.signal, 41 | }); 42 | clearTimeout(timeOut); 43 | if (!response.ok) { 44 | return { 45 | ok: false, 46 | body: {}, 47 | }; 48 | } 49 | const responseJSON = await response.json(); 50 | return { 51 | ok: true, 52 | body: responseJSON, 53 | }; 54 | } catch (err) { 55 | return { 56 | ok: false, 57 | body: {}, 58 | }; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/modules/ai-classification/ai-classification.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { 4 | AIClassification, 5 | AIClassificationSchema, 6 | Folder, 7 | FolderSchema, 8 | Keyword, 9 | KeywordSchema, 10 | Metrics, 11 | MetricsSchema, 12 | Post, 13 | PostSchema, 14 | } from '@src/infrastructure'; 15 | import { AiModule } from '@src/infrastructure/ai/ai.module'; 16 | import { 17 | PostKeyword, 18 | PostKeywordSchema, 19 | } from '@src/infrastructure/database/schema/postKeyword.schema'; 20 | import { PuppeteerPoolModule } from '@src/infrastructure/puppeteer-pool/puppeteer-pool.module'; 21 | import { ClassficiationRepository } from '../classification/classification.repository'; 22 | import { FolderRepository } from '../folders/folders.repository'; 23 | import { KeywordsPGRepository } from '../keywords/keyword.pg.repository'; 24 | import { KeywordsRepository } from '../keywords/keyword.repository'; 25 | import { MetricsRepository } from '../metrics/metrics.repository'; 26 | import { PostKeywordsRepository } from '../posts/postKeywords.repository'; 27 | import { PostsRepository } from '../posts/posts.repository'; 28 | import { AiClassificationService } from './ai-classification.service'; 29 | 30 | @Module({ 31 | imports: [ 32 | MongooseModule.forFeature([ 33 | { name: Post.name, schema: PostSchema }, 34 | { name: Folder.name, schema: FolderSchema }, 35 | { name: Keyword.name, schema: KeywordSchema }, 36 | { name: PostKeyword.name, schema: PostKeywordSchema }, 37 | { name: AIClassification.name, schema: AIClassificationSchema }, 38 | { name: Metrics.name, schema: MetricsSchema }, 39 | ]), 40 | AiModule, 41 | PuppeteerPoolModule, 42 | ], 43 | providers: [ 44 | AiClassificationService, 45 | ClassficiationRepository, 46 | FolderRepository, 47 | PostsRepository, 48 | KeywordsRepository, 49 | KeywordsPGRepository, 50 | PostKeywordsRepository, 51 | MetricsRepository, 52 | ], 53 | exports: [AiClassificationService], 54 | }) 55 | export class AiClassificationModule {} 56 | -------------------------------------------------------------------------------- /src/modules/ai-classification/ai-classification.v2.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CONTENT_LEAST_LIMIT } from '@src/common/constant'; 3 | import { AiService } from '@src/infrastructure/ai/ai.service'; 4 | import { AiClassificationPayload } from '@src/infrastructure/aws-lambda/type'; 5 | import { Post } from '@src/infrastructure/database/entities/post.entity'; 6 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 7 | import { PuppeteerPoolService } from '@src/infrastructure/puppeteer-pool/puppeteer-pool.service'; 8 | import { ClassificationPGRepository } from '@src/modules/classification/classification.pg.repository'; 9 | import { FoldersPGRepository } from '@src/modules/folders/folders.pg.repository'; 10 | import { KeywordsPGRepository } from '@src/modules/keywords/keyword.pg.repository'; 11 | import { PostKeywordsPGRepository } from '@src/modules/posts/postKeywords.pg.repository'; 12 | import { PostAiStatus } from '@src/modules/posts/posts.constant'; 13 | import { PostsPGRepository } from '@src/modules/posts/posts.pg.repository'; 14 | 15 | @Injectable() 16 | export class AiClassificationV2Service { 17 | constructor( 18 | private readonly aiService: AiService, 19 | private readonly classificationRepository: ClassificationPGRepository, 20 | private readonly folderRepository: FoldersPGRepository, 21 | private readonly postRepository: PostsPGRepository, 22 | private readonly keywordsRepository: KeywordsPGRepository, 23 | private readonly postKeywordsRepository: PostKeywordsPGRepository, 24 | private readonly puppeteer: PuppeteerPoolService, 25 | ) {} 26 | 27 | async execute(payload: AiClassificationPayload) { 28 | try { 29 | const folderMapper = new Map(); 30 | for (const folder of payload.folderList) { 31 | folderMapper.set(folder.name, folder.id); 32 | } 33 | 34 | if (payload.postContent.length < CONTENT_LEAST_LIMIT) { 35 | const { ok, body } = await this.puppeteer.invokeRemoteSessionParser( 36 | payload.url, 37 | ); 38 | if (ok) { 39 | const content = body['result']['body']; 40 | const title = body['result']['title']; 41 | const ogImage = body['result']['ogImage']; 42 | 43 | payload.postContent = content; 44 | await this.postRepository.updatePost(payload.userId, payload.postId, { 45 | title: title, 46 | thumbnailImgUrl: ogImage, 47 | }); 48 | } 49 | } 50 | 51 | const summarizeUrlContent = await this.aiService.summarizeLinkContent( 52 | payload.postContent, 53 | payload.postThumbnailContent, 54 | Object.keys(folderMapper), 55 | payload.url, 56 | ); 57 | 58 | // If summarize result is success and is not user category, create new foler 59 | if (summarizeUrlContent.success && !summarizeUrlContent.isUserCategory) { 60 | /** 61 | * payload.userId, 62 | * summarizeUrlContent.response.category, 63 | * FolderType.CUSTOM, 64 | * false, 65 | */ 66 | const newFolder = await this.folderRepository.save({ 67 | userId: payload.userId, 68 | name: summarizeUrlContent.response.category, 69 | type: FolderType.CUSTOM, 70 | visible: false, 71 | }); 72 | folderMapper[summarizeUrlContent.response.category] = newFolder.id; 73 | } 74 | 75 | const postId = payload.postId; 76 | let post: Post = null; 77 | let classificationId = null; 78 | let postAiStatus = PostAiStatus.FAIL; 79 | 80 | if (summarizeUrlContent.success) { 81 | let folderId = folderMapper.get(summarizeUrlContent.response.category); 82 | if (!folderId) { 83 | folderId = await this.folderRepository.getDefaultFolder( 84 | payload.userId, 85 | ); 86 | } 87 | 88 | post = 89 | await this.postRepository.findPostByIdForAIClassification(postId); 90 | 91 | const defaultFolders = await this.folderRepository.getDefaultFolders( 92 | post.userId, 93 | ); 94 | const defaultFolderIds = defaultFolders.map((folder) => folder.id); 95 | const isDefaultFolder = defaultFolderIds.includes( 96 | post.folderId.toString(), 97 | ); 98 | postAiStatus = PostAiStatus.SUCCESS; 99 | 100 | // Default Folder인 경우에는 Classification 생성함 101 | if (isDefaultFolder) { 102 | const classification = 103 | await this.classificationRepository.createClassification( 104 | post.url, 105 | summarizeUrlContent.response.summary, 106 | summarizeUrlContent.response.keywords, 107 | folderId, 108 | ); 109 | classificationId = classification.id; 110 | } 111 | 112 | // Keyword는 성공 여부 상관없이 생성 113 | const keywords = await this.keywordsRepository.createMany( 114 | summarizeUrlContent.response.keywords, 115 | ); 116 | 117 | const keywordIds = keywords.map((keyword) => keyword.id); 118 | await this.postKeywordsRepository.createPostKeywords( 119 | postId, 120 | keywordIds, 121 | ); 122 | } 123 | 124 | await this.postRepository.updatePostClassificationForAIClassification( 125 | postAiStatus, 126 | postId, 127 | classificationId, 128 | summarizeUrlContent.success === true 129 | ? summarizeUrlContent.response.summary 130 | : summarizeUrlContent.thumbnailContent, 131 | ); 132 | } catch (error: unknown) { 133 | return { success: false, error }; 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { AuthService } from './auth.service'; 4 | 5 | @Module({ 6 | imports: [ 7 | JwtModule.register({ 8 | global: true, 9 | }), 10 | ], 11 | providers: [AuthService], 12 | exports: [AuthService], 13 | }) 14 | export class AuthModule {} 15 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { JwtPayload } from '@src/common/types/type'; 5 | 6 | @Injectable() 7 | export class AuthService { 8 | constructor( 9 | private readonly jwt: JwtService, 10 | private readonly config: ConfigService, 11 | ) {} 12 | 13 | async issueAccessToken(payload: JwtPayload): Promise { 14 | const token = await this.jwt.signAsync(payload, { 15 | secret: this.config.get('JWT_SECRET'), 16 | expiresIn: this.config.get('JWT_EXPIRE_TIME'), 17 | }); 18 | return token; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/classification/classification.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Query, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { BasePaginationQuery, GetUser } from '@src/common'; 12 | import { JwtGuard } from '../users/guards'; 13 | import { ClassificationService } from './classification.service'; 14 | import { 15 | ClassificationControllerDocs, 16 | DeleteAIClassificationDocs, 17 | GetAIFolderNameListDocs, 18 | GetAIPostListDocs, 19 | PatchAIPostDocs, 20 | PatchAIPostListDocs, 21 | } from './docs'; 22 | import { CountClassificationDocs } from './docs/countClassification.docs'; 23 | import { UpdateAIClassificationDto } from './dto/classification.dto'; 24 | import { AIFolderNameListResponse } from './response/ai-folder-list.dto'; 25 | import { AIPostListResponse } from './response/ai-post-list.dto'; 26 | 27 | @Controller('classification') 28 | @UseGuards(JwtGuard) 29 | @ClassificationControllerDocs 30 | export class ClassificationController { 31 | constructor(private readonly classificationService: ClassificationService) {} 32 | 33 | @Get('/count') 34 | @CountClassificationDocs 35 | async countClassifiedPost(@GetUser() userId: string) { 36 | const count = await this.classificationService.countClassifiedPost(userId); 37 | return count; 38 | } 39 | 40 | @Get('/folders') 41 | @GetAIFolderNameListDocs 42 | async getSuggestedFolderNameList(@GetUser() userId: string) { 43 | const folders = await this.classificationService.getFolderNameList(userId); 44 | 45 | return new AIFolderNameListResponse(folders); 46 | } 47 | @Get('/posts') 48 | @GetAIPostListDocs 49 | async getSuggestedPostList( 50 | @GetUser() userId: string, 51 | @Query() pagingQuery: BasePaginationQuery, 52 | ) { 53 | const { count, classificationPostList } = 54 | await this.classificationService.getPostList(userId, pagingQuery); 55 | 56 | return new AIPostListResponse( 57 | pagingQuery.page, 58 | pagingQuery.limit, 59 | count, 60 | classificationPostList, 61 | ); 62 | } 63 | 64 | @Get('/posts/:folderId') 65 | @GetAIPostListDocs 66 | async getSuggestedPostListInFolder( 67 | @GetUser() userId: string, 68 | @Param('folderId') folderId: string, 69 | @Query() pagingQuery: BasePaginationQuery, 70 | ) { 71 | const { count, classificationPostList } = 72 | await this.classificationService.getPostListInFolder( 73 | userId, 74 | folderId, 75 | pagingQuery, 76 | ); 77 | 78 | return new AIPostListResponse( 79 | pagingQuery.page, 80 | pagingQuery.limit, 81 | count, 82 | classificationPostList, 83 | ); 84 | } 85 | 86 | @Patch('/posts') 87 | @PatchAIPostListDocs 88 | async moveAllPost( 89 | @GetUser() userId: string, 90 | @Query('suggestionFolderId') suggestionFolderId: string, 91 | ) { 92 | return await this.classificationService.moveAllPostTosuggestionFolderV2( 93 | userId, 94 | suggestionFolderId, 95 | ); 96 | } 97 | 98 | @Patch('/posts/:postId') 99 | @PatchAIPostDocs 100 | async moveOnePost( 101 | @GetUser() userId: string, 102 | @Param('postId') postId: string, 103 | @Body() dto: UpdateAIClassificationDto, 104 | ) { 105 | await this.classificationService.moveOnePostTosuggestionFolder( 106 | userId, 107 | postId, 108 | dto.suggestionFolderId, 109 | ); 110 | } 111 | 112 | @Delete('/posts/:postId') 113 | @DeleteAIClassificationDocs 114 | async abortClassification( 115 | @GetUser() userId: string, 116 | @Param('postId') postId: string, 117 | ) { 118 | await this.classificationService.abortClassification(userId, postId); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/modules/classification/classification.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { 4 | AIClassification, 5 | AIClassificationSchema, 6 | Folder, 7 | FolderSchema, 8 | Post, 9 | PostSchema, 10 | } from '@src/infrastructure/database/schema'; 11 | import { FoldersPGRepository } from '../folders/folders.pg.repository'; 12 | import { FolderRepository } from '../folders/folders.repository'; 13 | import { PostsPGRepository } from '../posts/posts.pg.repository'; 14 | import { PostsRepository } from '../posts/posts.repository'; 15 | import { ClassificationController } from './classification.controller'; 16 | import { ClassificationPGRepository } from './classification.pg.repository'; 17 | import { ClassficiationRepository } from './classification.repository'; 18 | import { ClassificationService } from './classification.service'; 19 | import { ClassificationV2Controller } from './classification.v2.controller'; 20 | import { ClassificationV2Service } from './classification.v2.service'; 21 | 22 | @Module({ 23 | imports: [ 24 | MongooseModule.forFeature([ 25 | { name: Folder.name, schema: FolderSchema }, 26 | { name: AIClassification.name, schema: AIClassificationSchema }, 27 | { name: Post.name, schema: PostSchema }, 28 | ]), 29 | ], 30 | controllers: [ClassificationController, ClassificationV2Controller], 31 | providers: [ 32 | ClassificationService, 33 | ClassificationV2Service, 34 | ClassficiationRepository, 35 | ClassificationPGRepository, 36 | PostsRepository, 37 | PostsPGRepository, 38 | FolderRepository, 39 | FoldersPGRepository, 40 | ], 41 | exports: [ClassificationService, ClassificationV2Service], 42 | }) 43 | export class ClassificationModule {} 44 | -------------------------------------------------------------------------------- /src/modules/classification/classification.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, In, IsNull, Not, Repository } from 'typeorm'; 3 | import { AIClassification } from '@src/infrastructure/database/entities/ai-classification.entity'; 4 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 5 | import { ClassificationFolderWithCount } from './dto/classification.dto'; 6 | 7 | @Injectable() 8 | export class ClassificationPGRepository extends Repository { 9 | constructor(private dataSource: DataSource) { 10 | super(AIClassification, dataSource.createEntityManager()); 11 | } 12 | 13 | async countClassifiedPostByUserId(userId: string) { 14 | const userFolders = await this.dataSource.getRepository('folders').find({ 15 | where: { 16 | userId: userId, 17 | type: Not(FolderType.DEFAULT), 18 | }, 19 | select: ['id'], 20 | }); 21 | 22 | const folderIds = userFolders.map((folder) => folder.id); 23 | const classifiedCount = await this.count({ 24 | where: { 25 | suggestedFolderId: In(folderIds), 26 | deletedAt: IsNull(), 27 | }, 28 | }); 29 | return classifiedCount; 30 | } 31 | 32 | async findById(classificationId: string) { 33 | return await this.findOne({ 34 | where: { id: classificationId }, 35 | }); 36 | } 37 | 38 | async getClassificationPostCount( 39 | userId: string, 40 | suggestedFolderId?: string, 41 | ): Promise { 42 | const qb = this.createQueryBuilder('classification') 43 | .leftJoin('posts', 'post', 'post.aiClassificationId = classification.id') 44 | .where('classification.deletedAt IS NULL') 45 | .andWhere('post.userId = :userId', { userId }); 46 | 47 | if (suggestedFolderId) { 48 | qb.andWhere('classification.suggestedFolderId = :suggestedFolderId', { 49 | suggestedFolderId, 50 | }); 51 | } 52 | 53 | return await qb.getCount(); 54 | } 55 | 56 | async createClassification( 57 | url: string, 58 | description: string, 59 | keywords: string[], 60 | suggestedFolderId: string, 61 | ) { 62 | const classification = this.create({ 63 | suggestedFolderId, 64 | url, 65 | description, 66 | keywords, 67 | completedAt: new Date(), 68 | }); 69 | 70 | return await this.save(classification); 71 | } 72 | 73 | async findContainedFolderByUserId( 74 | userId: string, 75 | ): Promise { 76 | const result: ClassificationFolderWithCount[] = 77 | await this.createQueryBuilder('classification') 78 | .select([ 79 | 'folder.id as "folderId"', 80 | 'folder.name as "folderName"', 81 | 'COUNT(classification.id) as "postCount"', 82 | 'CASE WHEN folder.visible = true THEN false ELSE true END as "isAIGenerated"', 83 | ]) 84 | .innerJoin( 85 | 'folders', 86 | 'folder', 87 | 'folder.id = classification.suggestedFolderId AND folder.userId = :userId AND folder.type != :type', 88 | { userId, type: FolderType.DEFAULT }, 89 | ) 90 | .where('classification.deletedAt IS NULL') 91 | .groupBy('folder.id') 92 | .addGroupBy('folder.name') 93 | .addGroupBy('folder.visible') 94 | .orderBy('"postCount"', 'DESC') 95 | .addOrderBy('folder.createdAt', 'DESC') 96 | .getRawMany(); 97 | 98 | return result; 99 | } 100 | 101 | async deleteBySuggestedFolderId(suggestedFolderId: string) { 102 | await this.update( 103 | { 104 | suggestedFolderId, 105 | deletedAt: IsNull(), 106 | }, 107 | { deletedAt: new Date() }, 108 | ); 109 | } 110 | 111 | async deleteManyBySuggestedFolderIdList( 112 | suggestedFolderIdList: string[], 113 | ): Promise { 114 | await this.update( 115 | { suggestedFolderId: In(suggestedFolderIdList) }, 116 | { deletedAt: new Date() }, 117 | ); 118 | 119 | return true; 120 | } 121 | 122 | async getClassificationBySuggestedFolderId(suggestedFolderId: string) { 123 | const classifications = await this.find({ 124 | where: { 125 | suggestedFolderId, 126 | deletedAt: IsNull(), 127 | }, 128 | select: ['id'], 129 | }); 130 | 131 | return classifications.map((classification) => classification.id); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/modules/classification/classification.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ClassificationService } from './classification.service'; 3 | 4 | describe('ClassificationService', () => { 5 | let service: ClassificationService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [ClassificationService], 10 | }).compile(); 11 | 12 | service = module.get(ClassificationService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/classification/classification.v2.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Patch, 10 | Query, 11 | UseGuards, 12 | } from '@nestjs/common'; 13 | import { BasePaginationQuery, GetUser } from '@src/common'; 14 | import { JwtGuard } from '../users/guards'; 15 | import { ClassificationV2Service } from './classification.v2.service'; 16 | import { 17 | ClassificationControllerDocs, 18 | DeleteAIClassificationDocs, 19 | GetAIFolderNameListDocs, 20 | GetAIPostListDocs, 21 | PatchAIPostDocs, 22 | PatchAIPostListDocs, 23 | } from './docs'; 24 | import { CountClassificationDocs } from './docs/countClassification.docs'; 25 | import { UpdateAIClassificationDto } from './dto/classification.dto'; 26 | import { AIFolderNameListResponse } from './response/ai-folder-list.dto'; 27 | import { AIPostListResponse } from './response/ai-post-list.dto'; 28 | 29 | @Controller({ version: '2', path: 'classification' }) 30 | @UseGuards(JwtGuard) 31 | @ClassificationControllerDocs 32 | export class ClassificationV2Controller { 33 | constructor( 34 | private readonly classificationService: ClassificationV2Service, 35 | ) {} 36 | 37 | @Get('/count') 38 | @CountClassificationDocs 39 | @HttpCode(HttpStatus.OK) 40 | async countClassifiedPost(@GetUser() userId: string): Promise { 41 | return await this.classificationService.countClassifiedPost(userId); 42 | } 43 | 44 | @Get('/folders') 45 | @GetAIFolderNameListDocs 46 | @HttpCode(HttpStatus.OK) 47 | async getSuggestedFolderNameList( 48 | @GetUser() userId: string, 49 | ): Promise { 50 | const folders = await this.classificationService.getFolderNameList(userId); 51 | return new AIFolderNameListResponse(folders); 52 | } 53 | 54 | @Get('/posts') 55 | @GetAIPostListDocs 56 | @HttpCode(HttpStatus.OK) 57 | async getSuggestedPostList( 58 | @GetUser() userId: string, 59 | @Query() pagingQuery: BasePaginationQuery, 60 | ): Promise { 61 | const { count, classificationPostList } = 62 | await this.classificationService.getPostList(userId, pagingQuery); 63 | 64 | return new AIPostListResponse( 65 | pagingQuery.page, 66 | pagingQuery.limit, 67 | count, 68 | classificationPostList, 69 | ); 70 | } 71 | 72 | @Get('/posts/:folderId') 73 | @GetAIPostListDocs 74 | async getSuggestedPostListInFolder( 75 | @GetUser() userId: string, 76 | @Param('folderId') folderId: string, 77 | @Query() pagingQuery: BasePaginationQuery, 78 | ): Promise { 79 | const { count, classificationPostList } = 80 | await this.classificationService.getPostListInFolder( 81 | userId, 82 | folderId, 83 | pagingQuery, 84 | ); 85 | 86 | return new AIPostListResponse( 87 | pagingQuery.page, 88 | pagingQuery.limit, 89 | count, 90 | classificationPostList, 91 | ); 92 | } 93 | 94 | @Patch('/posts') 95 | @PatchAIPostListDocs 96 | async moveAllPost( 97 | @GetUser() userId: string, 98 | @Body() dto: UpdateAIClassificationDto, 99 | ) { 100 | return await this.classificationService.moveAllPostTosuggestionFolder( 101 | userId, 102 | dto.suggestionFolderId, 103 | ); 104 | } 105 | 106 | @Patch('/posts/:postId') 107 | @PatchAIPostDocs 108 | async moveOnePost( 109 | @GetUser() userId: string, 110 | @Param('postId') postId: string, 111 | @Body() dto: UpdateAIClassificationDto, 112 | ) { 113 | await this.classificationService.moveOnePostTosuggestionFolder( 114 | userId, 115 | postId, 116 | dto.suggestionFolderId, 117 | ); 118 | } 119 | 120 | @Delete('/posts/:postId') 121 | @DeleteAIClassificationDocs 122 | async abortClassification( 123 | @GetUser() userId: string, 124 | @Param('postId') postId: string, 125 | ): Promise { 126 | await this.classificationService.abortClassification(userId, postId); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/modules/classification/classification.v2.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { BasePaginationQuery, sum } from '@src/common'; 3 | import { FoldersPGRepository } from '../folders/folders.pg.repository'; 4 | import { PostsPGRepository } from '../posts/posts.pg.repository'; 5 | import { ClassificationPGRepository } from './classification.pg.repository'; 6 | import { ClassificationFolderWithCount } from './dto/classification.dto'; 7 | import { C001 } from './error'; 8 | 9 | @Injectable() 10 | export class ClassificationV2Service { 11 | constructor( 12 | private readonly classificationRepository: ClassificationPGRepository, 13 | private readonly postRepository: PostsPGRepository, 14 | private readonly folderRepository: FoldersPGRepository, 15 | ) {} 16 | 17 | async countClassifiedPost(userId: string): Promise { 18 | return await this.classificationRepository.countClassifiedPostByUserId( 19 | userId, 20 | ); 21 | } 22 | 23 | async getFolderNameList( 24 | userId: string, 25 | ): Promise { 26 | return await this.classificationRepository.findContainedFolderByUserId( 27 | userId, 28 | ); 29 | } 30 | 31 | async getPostList(userId: string, pagingQuery: BasePaginationQuery) { 32 | const { count, orderedFolderIdList } = 33 | await this.getFolderCountAndOrder(userId); 34 | 35 | if (orderedFolderIdList.length === 0) { 36 | return { count: 0, classificationPostList: [] }; 37 | } 38 | const offset = pagingQuery.getOffset(); 39 | const classificationPostList = 40 | await this.postRepository.findAndSortBySuggestedFolderIds( 41 | userId, 42 | orderedFolderIdList, 43 | offset, 44 | pagingQuery.limit, 45 | ); 46 | 47 | return { count, classificationPostList }; 48 | } 49 | 50 | async getFolderCountAndOrder(userId: string) { 51 | const orderedFolderList = 52 | await this.classificationRepository.findContainedFolderByUserId(userId); 53 | 54 | const count = sum(orderedFolderList, (folder) => Number(folder.postCount)); 55 | const orderedFolderIdList = orderedFolderList.map( 56 | (folder) => folder.folderId, 57 | ); 58 | 59 | return { count, orderedFolderIdList }; 60 | } 61 | 62 | async getPostListInFolder( 63 | userId: string, 64 | folderId: string, 65 | pagingQuery: BasePaginationQuery, 66 | ) { 67 | const offset = pagingQuery.getOffset(); 68 | 69 | const [count, classificationPostList] = await Promise.all([ 70 | this.classificationRepository.getClassificationPostCount( 71 | userId, 72 | folderId, 73 | ), 74 | this.postRepository.findBySuggestedFolderId( 75 | userId, 76 | folderId, 77 | offset, 78 | pagingQuery.limit, 79 | ), 80 | ]); 81 | 82 | return { count, classificationPostList }; 83 | } 84 | 85 | async moveAllPostTosuggestionFolder( 86 | userId: string, 87 | suggestedFolderId: string, 88 | ): Promise { 89 | await this.folderRepository.makeFolderVisible(suggestedFolderId); 90 | 91 | const targetClassificationIds = 92 | await this.classificationRepository.getClassificationBySuggestedFolderId( 93 | suggestedFolderId, 94 | ); 95 | 96 | const targetPostIds = 97 | await this.postRepository.findPostsBySuggestedFolderIds( 98 | userId, 99 | targetClassificationIds, 100 | ); 101 | 102 | if (targetPostIds.length > 0) { 103 | await this.postRepository.updatePostListFolder( 104 | userId, 105 | targetPostIds, 106 | suggestedFolderId, 107 | ); 108 | } 109 | 110 | await this.classificationRepository.deleteManyBySuggestedFolderIdList([ 111 | suggestedFolderId, 112 | ]); 113 | 114 | return true; 115 | } 116 | 117 | async moveOnePostTosuggestionFolder( 118 | userId: string, 119 | postId: string, 120 | suggestedFolderId: string, 121 | ): Promise { 122 | await this.folderRepository.makeFolderVisible(suggestedFolderId); 123 | 124 | const post = await this.postRepository.findOne({ 125 | where: { id: postId, userId }, 126 | }); 127 | 128 | await this.postRepository.findAndupdateFolderId( 129 | userId, 130 | postId, 131 | suggestedFolderId, 132 | ); 133 | 134 | if (post.aiClassificationId) { 135 | await this.classificationRepository.softDelete(post.aiClassificationId); 136 | } 137 | } 138 | 139 | async abortClassification(userId: string, postId: string): Promise { 140 | const post = await this.postRepository.findPostOrThrow({ 141 | id: postId, 142 | }); 143 | 144 | if (!post.aiClassificationId) { 145 | throw new BadRequestException(C001); 146 | } 147 | 148 | const classification = await this.classificationRepository.findById( 149 | post.aiClassificationId, 150 | ); 151 | 152 | if (!classification) { 153 | throw new BadRequestException(C001); 154 | } 155 | 156 | await this.classificationRepository.softDelete(classification.id); 157 | return true; 158 | } 159 | 160 | async deleteClassificationBySuggestedFolderId( 161 | suggestedFolderIdList: string[], 162 | ): Promise { 163 | return await this.classificationRepository.deleteManyBySuggestedFolderIdList( 164 | suggestedFolderIdList, 165 | ); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/modules/classification/docs/controller.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 3 | 4 | export const ClassificationControllerDocs = applyDecorators( 5 | ApiTags('AI classification API'), 6 | ApiBearerAuth(), 7 | ); 8 | -------------------------------------------------------------------------------- /src/modules/classification/docs/countClassification.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | 4 | export const CountClassificationDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: 'AI 분류된 개수를 반환합니다', 7 | description: '반환 데이터 타입은 정수입니다!', 8 | }), 9 | ApiResponse({ 10 | status: 200, 11 | description: '분류된 Post의 개수', 12 | type: Number, 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /src/modules/classification/docs/deleteAIClassification.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | 4 | export const DeleteAIClassificationDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: 'ai 분류 추천 리스트에서 삭제', 7 | description: 8 | 'ai 분류 추천 리스트에서 삭제합니다. 나중에 읽을 폴더에 계속 위치됩니다.', 9 | }), 10 | ApiResponse({}), 11 | ApiBearerAuth(), 12 | ); 13 | -------------------------------------------------------------------------------- /src/modules/classification/docs/getAIFolderNameList.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { AIFolderNameListResponse } from '../response/ai-folder-list.dto'; 4 | 5 | export const GetAIFolderNameListDocs = applyDecorators( 6 | ApiOperation({ 7 | summary: '폴더 리스트', 8 | description: 'AI 분류 폴더 리스트.', 9 | }), 10 | ApiResponse({ 11 | type: AIFolderNameListResponse, 12 | }), 13 | ApiBearerAuth(), 14 | ); 15 | -------------------------------------------------------------------------------- /src/modules/classification/docs/getAIPostList.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { AIPostListResponse } from '../response/ai-post-list.dto'; 4 | 5 | export const GetAIPostListDocs = applyDecorators( 6 | ApiOperation({ 7 | summary: 'Post(링크) 리스트', 8 | description: 'AI 분류 추천된 링크 리스트.', 9 | }), 10 | ApiResponse({ 11 | type: AIPostListResponse, 12 | }), 13 | ApiBearerAuth(), 14 | ); 15 | -------------------------------------------------------------------------------- /src/modules/classification/docs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.docs'; 2 | export * from './deleteAIClassification.docs'; 3 | export * from './getAIFolderNameList.docs'; 4 | export * from './getAIPostList.docs'; 5 | export * from './patchAIPost.docs'; 6 | export * from './patchAIPostList.docs'; 7 | -------------------------------------------------------------------------------- /src/modules/classification/docs/patchAIPost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | 4 | export const PatchAIPostDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: 'Post 하나 이동', 7 | description: '추천해준 폴더로 post이동. postId가 필요합니다.', 8 | }), 9 | ApiResponse({}), 10 | ApiBearerAuth(), 11 | ); 12 | -------------------------------------------------------------------------------- /src/modules/classification/docs/patchAIPostList.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | 4 | export const PatchAIPostListDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: 'Post 모두 이동', 7 | description: '추천 폴더 안에 들어있는 Post(링크)를 추천 폴더로 전부 이동', 8 | }), 9 | ApiResponse({ 10 | type: Boolean, 11 | }), 12 | ApiBearerAuth(), 13 | ); 14 | -------------------------------------------------------------------------------- /src/modules/classification/dto/classification.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | import { PostAiStatus } from '@src/modules/posts/posts.constant'; 4 | 5 | export interface ClassificationFolderWithCount { 6 | folderId: string; 7 | folderName: string; 8 | postCount: number; 9 | } 10 | 11 | export class ClassificationPostList { 12 | @ApiProperty({ required: true, description: '피드 id', type: String }) 13 | postId: string; 14 | 15 | @ApiProperty({ required: true, description: '폴더 id', type: String }) 16 | folderId: string; 17 | 18 | @ApiProperty({ required: true, description: '피드 제목', type: String }) 19 | title: string; 20 | 21 | @ApiProperty({ required: true, description: '피드 URL', type: String }) 22 | url: string; 23 | 24 | @ApiProperty({ required: true, description: '피드 요약', type: String }) 25 | description: string; 26 | 27 | @ApiProperty({ 28 | required: true, 29 | description: '키워드', 30 | isArray: true, 31 | type: String, 32 | }) 33 | keywords: string[]; 34 | 35 | @ApiProperty({ 36 | required: true, 37 | description: 'ai 요약 상태', 38 | enum: PostAiStatus, 39 | }) 40 | aiStatus: PostAiStatus; 41 | 42 | @ApiProperty({ nullable: true, description: '피드 og 이미지', type: String }) 43 | thumbnailImgUrl: string | null; 44 | 45 | @ApiProperty({ description: '생성 시간', type: Date }) 46 | createdAt: Date; 47 | 48 | @ApiProperty({ nullable: true, description: '읽은 시간', type: Date }) 49 | readAt: Date | null; 50 | } 51 | 52 | export class UpdateAIClassificationDto { 53 | @IsNotEmpty() 54 | @IsString() 55 | @ApiProperty({ description: '추천된 폴더의 아이디' }) 56 | suggestionFolderId: string; 57 | } 58 | -------------------------------------------------------------------------------- /src/modules/classification/dto/getAIFolderNameLIst.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | import { FolderDocument } from '@src/infrastructure'; 4 | 5 | export class AIFolderNameServiceDto { 6 | id: string; 7 | 8 | name: string; 9 | 10 | constructor(data: FolderDocument) { 11 | this.id = data._id.toString(); 12 | this.name = data.name; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/classification/error/index.ts: -------------------------------------------------------------------------------- 1 | import { createErrorObject } from '@src/common'; 2 | 3 | export const C001 = createErrorObject('C001', 'AI 분류가 존재하지 않습니다!'); 4 | -------------------------------------------------------------------------------- /src/modules/classification/response/ai-folder-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ClassificationFolderWithCount } from '../dto/classification.dto'; 3 | 4 | export class AIFolderNameListResponse { 5 | @ApiProperty({ 6 | description: 'ai로 분류된 링크의 총 개수', 7 | }) 8 | totalCounts: number; 9 | 10 | @ApiProperty({ 11 | isArray: true, 12 | }) 13 | list: ClassificationFolderWithCount[]; 14 | 15 | constructor(data: ClassificationFolderWithCount[]) { 16 | this.totalCounts = data.reduce((sum, folder) => sum + folder.postCount, 0); 17 | this.list = data; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/classification/response/ai-post-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { BasePaginationResponse } from '@src/common'; 3 | import { ClassificationPostList } from '../dto/classification.dto'; 4 | 5 | export class AIPostListResponse extends BasePaginationResponse { 6 | @ApiProperty({ 7 | type: ClassificationPostList, 8 | isArray: true, 9 | }) 10 | list: ClassificationPostList[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/folders/docs/controller.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 3 | 4 | export const FolderControllerDocs = applyDecorators( 5 | ApiTags('Folder API'), 6 | ApiBearerAuth(), 7 | ); 8 | -------------------------------------------------------------------------------- /src/modules/folders/docs/folder-api.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiBadRequestResponse, 4 | ApiNotFoundResponse, 5 | ApiOperation, 6 | ApiResponse, 7 | } from '@nestjs/swagger'; 8 | import { 9 | FolderListResponse, 10 | FolderPostResponse, 11 | FolderResponse, 12 | } from '../responses'; 13 | 14 | export const CreateFolderDocs = applyDecorators( 15 | ApiOperation({ 16 | summary: '폴더 생성 API', 17 | }), 18 | ApiResponse({ 19 | type: FolderResponse, 20 | isArray: true, 21 | }), 22 | ApiBadRequestResponse({ 23 | description: ['F001'].join(','), 24 | }), 25 | ); 26 | 27 | export const FindAFolderListDocs = applyDecorators( 28 | ApiOperation({ 29 | summary: '내 폴더 목록 조회', 30 | description: 31 | 'defaultFolders에는 자동으로 생성되는 폴더, customFolders에는 유저가 생성한 폴더들이 감', 32 | }), 33 | ApiResponse({ 34 | type: FolderListResponse, 35 | }), 36 | ); 37 | 38 | export const FindFolderDocs = applyDecorators( 39 | ApiOperation({ 40 | summary: '폴더 단일 조회', 41 | description: '', 42 | }), 43 | ApiResponse({ 44 | type: FolderResponse, 45 | }), 46 | ApiNotFoundResponse({ 47 | description: ['F002'].join(', '), 48 | }), 49 | ); 50 | 51 | export const FindLinksInFolderDocs = applyDecorators( 52 | ApiOperation({ 53 | summary: '폴더 내 링크 목록 조회', 54 | description: '', 55 | }), 56 | ApiResponse({ 57 | type: FolderPostResponse, 58 | }), 59 | ); 60 | 61 | export const UpdateFolderDocs = applyDecorators( 62 | ApiOperation({ 63 | summary: '폴더 수정 API', 64 | }), 65 | ApiResponse({ 66 | type: FolderResponse, 67 | }), 68 | ApiNotFoundResponse({ 69 | description: ['F002', 'F003'].join(', '), 70 | }), 71 | ); 72 | 73 | export const DeleteFolderDocs = applyDecorators( 74 | ApiOperation({ 75 | summary: '폴더 삭제 API', 76 | }), 77 | ApiNotFoundResponse({ 78 | description: ['F002'].join(', '), 79 | }), 80 | ); 81 | -------------------------------------------------------------------------------- /src/modules/folders/docs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.docs'; 2 | export * from './folder-api.docs'; 3 | -------------------------------------------------------------------------------- /src/modules/folders/dto/delete-custom-folder.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class DeleteCustomFolderDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: '삭제할 유저 id', 9 | }) 10 | userId: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/folders/dto/folder-list-service.dto.ts: -------------------------------------------------------------------------------- 1 | export interface FolderListServiceDto { 2 | /** */ 3 | defaultFolders: any[]; 4 | customFolders: any[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/folders/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './delete-custom-folder.dto'; 2 | export * from './folder-list-service.dto'; 3 | export * from './mutate-folder.dto'; 4 | -------------------------------------------------------------------------------- /src/modules/folders/dto/mutate-folder.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class CreateFolderDto { 5 | @IsArray() 6 | @IsString({ each: true }) 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | description: '추가할 폴더 이름들', 10 | }) 11 | readonly names: string[]; 12 | } 13 | 14 | export class UpdateFolderDto { 15 | @IsString() 16 | @IsNotEmpty() 17 | @ApiProperty({ 18 | description: '폴더 이름', 19 | }) 20 | readonly name: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/folders/error/index.ts: -------------------------------------------------------------------------------- 1 | import { createErrorObject } from '@src/common'; 2 | 3 | export const F001 = (folderName: string) => { 4 | return createErrorObject( 5 | 'F001', 6 | `폴더 이름이 중복되었습니다 - ${folderName}`, 7 | ); 8 | }; 9 | 10 | export const F002 = createErrorObject('F002', '폴더가 존재하지 않습니다!'); 11 | 12 | export const F003 = (folderName: string) => { 13 | return createErrorObject('F003', `동일한 폴더이름 입니다: ${folderName}`); 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/folders/folders.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FoldersController } from './folders.controller'; 3 | import { FoldersService } from './folders.service'; 4 | 5 | describe('FoldersController', () => { 6 | let controller: FoldersController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [FoldersController], 11 | providers: [FoldersService], 12 | }).compile(); 13 | 14 | controller = module.get(FoldersController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/modules/folders/folders.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { GetUser } from '@src/common'; 13 | import { ClassificationService } from '@src/modules/classification/classification.service'; 14 | import { GetPostQueryDto } from '../posts/dto/find-in-folder.dto'; 15 | import { PostsService } from '../posts/posts.service'; 16 | import { JwtGuard } from '../users/guards'; 17 | import { 18 | CreateFolderDocs, 19 | DeleteFolderDocs, 20 | FindAFolderListDocs, 21 | FindFolderDocs, 22 | FindLinksInFolderDocs, 23 | FolderControllerDocs, 24 | UpdateFolderDocs, 25 | } from './docs'; 26 | import { CreateFolderDto, DeleteCustomFolderDto, UpdateFolderDto } from './dto'; 27 | import { FoldersService } from './folders.service'; 28 | import { 29 | FolderListResponse, 30 | FolderPostResponse, 31 | FolderResponse, 32 | } from './responses'; 33 | import { PostResponse } from './responses/post.response'; 34 | 35 | @FolderControllerDocs 36 | @UseGuards(JwtGuard) 37 | @Controller('folders') 38 | export class FoldersController { 39 | constructor( 40 | private readonly foldersService: FoldersService, 41 | private readonly postsService: PostsService, 42 | private readonly classificationService: ClassificationService, 43 | ) {} 44 | 45 | @CreateFolderDocs 46 | @Post() 47 | async create( 48 | @GetUser('id') userId: string, 49 | @Body() createFolderDto: CreateFolderDto, 50 | ) { 51 | const folders = await this.foldersService.createMany( 52 | userId, 53 | createFolderDto, 54 | ); 55 | const folderSerializer = folders.map( 56 | (folder) => new FolderResponse(folder), 57 | ); 58 | return { 59 | list: folderSerializer, 60 | }; 61 | } 62 | 63 | @FindAFolderListDocs 64 | @Get() 65 | async findAll(@GetUser() userId: string) { 66 | const result = await this.foldersService.findAll(userId); 67 | return new FolderListResponse(result); 68 | } 69 | 70 | @FindFolderDocs 71 | @Get(':folderId') 72 | async findOne( 73 | @GetUser() userId: string, 74 | @Param('folderId') folderId: string, 75 | ) { 76 | const folder = await this.foldersService.findOne(userId, folderId); 77 | return new FolderResponse(folder); 78 | } 79 | 80 | @FindLinksInFolderDocs 81 | @Get(':folderId/posts') 82 | async findLinksInFolder( 83 | @GetUser() userId: string, 84 | @Param('folderId') folderId: string, 85 | @Query() query: GetPostQueryDto, 86 | ) { 87 | const result = await this.postsService.findByFolderId( 88 | userId, 89 | folderId, 90 | query, 91 | ); 92 | 93 | const posts = result.posts.map((post) => new PostResponse(post)); 94 | return new FolderPostResponse(query.page, query.limit, result.count, posts); 95 | } 96 | 97 | @UpdateFolderDocs 98 | @Patch(':folderId') 99 | async update( 100 | @GetUser() userId: string, 101 | @Param('folderId') folderId: string, 102 | @Body() updateFolderDto: UpdateFolderDto, 103 | ) { 104 | const updatedFolder = await this.foldersService.update( 105 | userId, 106 | folderId, 107 | updateFolderDto, 108 | ); 109 | return new FolderResponse(updatedFolder); 110 | } 111 | 112 | @Delete('/all') 113 | async removeAll(@Query() deleteCustomFolderDto: DeleteCustomFolderDto) { 114 | const folderIdList = await this.postsService.removeAllPostsInCustomFolders( 115 | deleteCustomFolderDto.userId, 116 | ); 117 | await this.foldersService.removeAllCustomFolders( 118 | deleteCustomFolderDto.userId, 119 | ); 120 | await this.classificationService.deleteClassificationBySuggestedFolderId( 121 | folderIdList, 122 | ); 123 | } 124 | 125 | @DeleteFolderDocs 126 | @Delete(':folderId') 127 | async remove(@GetUser() userId: string, @Param('folderId') folderId: string) { 128 | await this.classificationService.deleteClassificationBySuggestedFolderId( 129 | folderId, 130 | ); 131 | await this.foldersService.remove(userId, folderId); 132 | await this.postsService.removePostListByFolderId(userId, folderId); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/modules/folders/folders.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Folder } from '@src/infrastructure/database/entities/folder.entity'; 5 | import { Post } from '@src/infrastructure/database/entities/post.entity'; 6 | import { 7 | Folder as FolderMongoEntity, 8 | FolderSchema, 9 | Post as PostMongoEntity, 10 | PostSchema, 11 | } from '@src/infrastructure/database/schema'; 12 | import { ClassificationModule } from '@src/modules/classification/classification.module'; 13 | import { PostsModule } from '../posts/posts.module'; 14 | import { PostsPGRepository } from '../posts/posts.pg.repository'; 15 | import { FoldersController } from './folders.controller'; 16 | import { FoldersPGRepository } from './folders.pg.repository'; 17 | import { FolderRepository } from './folders.repository'; 18 | import { FoldersService } from './folders.service'; 19 | import { FoldersV2Controller } from './folders.v2.controller'; 20 | import { FoldersV2Service } from './folders.v2.service'; 21 | 22 | @Module({ 23 | imports: [ 24 | ClassificationModule, 25 | TypeOrmModule.forFeature([Folder, Post]), 26 | /** @deprecated */ 27 | MongooseModule.forFeature([ 28 | { name: PostMongoEntity.name, schema: PostSchema }, 29 | { name: FolderMongoEntity.name, schema: FolderSchema }, 30 | ]), 31 | PostsModule, 32 | ], 33 | controllers: [FoldersController, FoldersV2Controller], 34 | providers: [ 35 | FoldersService, 36 | FoldersV2Service, 37 | FoldersPGRepository, 38 | PostsPGRepository, 39 | /** @deprecated */ 40 | FolderRepository, 41 | ], 42 | }) 43 | export class FoldersModule {} 44 | -------------------------------------------------------------------------------- /src/modules/folders/folders.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Folder } from '@src/infrastructure/database/entities/folder.entity'; 4 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 5 | 6 | @Injectable() 7 | export class FoldersPGRepository extends Repository { 8 | constructor(private dataSource: DataSource) { 9 | super(Folder, dataSource.createEntityManager()); 10 | } 11 | 12 | async createOne( 13 | userId: string, 14 | name: string, 15 | type: FolderType, 16 | visible = true, 17 | ) { 18 | const folder = await this.insert({ 19 | userId, 20 | name, 21 | type, 22 | visible, 23 | }); 24 | 25 | return folder; 26 | } 27 | 28 | async createMany( 29 | folderData: { userId: string; name: string; type: FolderType }[], 30 | ) { 31 | const folders = this.create( 32 | folderData.map((folder) => ({ ...folder, visible: true })), 33 | ); 34 | await this.insert(folders); 35 | 36 | return folders; 37 | } 38 | 39 | async findByUserId(userId: string, onlyVisible = true) { 40 | const where = onlyVisible ? { userId, visible: true } : { userId }; 41 | const folders = await this.find({ where }); 42 | 43 | return folders; 44 | } 45 | 46 | async checkUserHasFolder(userId: string, name: string) { 47 | const checkFolder = await this.findOne({ 48 | where: { 49 | userId: userId, 50 | name: name, 51 | visible: true, 52 | }, 53 | }); 54 | 55 | return checkFolder ? true : false; 56 | } 57 | 58 | async deleteAllCustomFolder(userId: string) { 59 | await this.delete({ 60 | userId, 61 | type: FolderType.CUSTOM, 62 | }); 63 | } 64 | 65 | async getDefaultFolder(userId: string) { 66 | const folder = await this.findOne({ 67 | where: { 68 | userId, 69 | type: FolderType.DEFAULT, 70 | }, 71 | }); 72 | 73 | return folder; 74 | } 75 | 76 | async getDefaultFolders(userId: string) { 77 | const folders = await this.find({ 78 | where: { 79 | userId: userId, 80 | type: FolderType.DEFAULT, 81 | }, 82 | }); 83 | return folders; 84 | } 85 | 86 | async makeFolderVisible(folderId: string) { 87 | await this.update(folderId, { visible: true }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/modules/folders/folders.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { FilterQuery, Model } from 'mongoose'; 4 | import { Folder, FolderDocument } from '@src/infrastructure'; 5 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 6 | import { F002 } from './error'; 7 | 8 | @Injectable() 9 | export class FolderRepository { 10 | constructor( 11 | @InjectModel(Folder.name) 12 | private readonly folderModel: Model, 13 | ) {} 14 | 15 | async create(userId: string, name: string, type: FolderType, visible = true) { 16 | const folder = await this.folderModel.create({ 17 | userId, 18 | name, 19 | type, 20 | visible, 21 | }); 22 | 23 | return folder; 24 | } 25 | 26 | async createMany( 27 | folders: { userId: string; name: string; type: FolderType }[], 28 | ) { 29 | const createdFolders = await this.folderModel.insertMany( 30 | folders.map((folder) => { 31 | return { 32 | ...folder, 33 | visible: true, 34 | }; 35 | }), 36 | ); 37 | return createdFolders; 38 | } 39 | 40 | async findByUserId(userId: string, onlyVisible = true) { 41 | const folders = await this.folderModel 42 | .find(onlyVisible ? { userId, visible: true } : { userId }) 43 | .exec(); 44 | return folders; 45 | } 46 | 47 | async checkUserHasFolder(userId: string, name: string) { 48 | const checkFolder = await this.folderModel 49 | .findOne({ 50 | userId: userId, 51 | name: name, 52 | visible: true, 53 | }) 54 | .exec(); 55 | return checkFolder ? true : false; 56 | } 57 | 58 | async findOneOrFail(param: FilterQuery) { 59 | const folder = await this.folderModel.findOne(param).exec(); 60 | if (!folder) { 61 | throw new NotFoundException(F002); 62 | } 63 | 64 | return folder; 65 | } 66 | 67 | async deleteAllCustomFolder(userId: string) { 68 | await this.folderModel.deleteMany({ 69 | userId, 70 | type: FolderType.CUSTOM, 71 | }); 72 | } 73 | 74 | async getDefaultFolder(userId: string) { 75 | const folder = await this.folderModel.findOne({ 76 | userId, 77 | type: FolderType.DEFAULT, 78 | }); 79 | 80 | return folder; 81 | } 82 | 83 | async getDefaultFolders(userId: string) { 84 | const folders = await this.folderModel.find({ 85 | userId: userId, 86 | type: FolderType.DEFAULT, 87 | }); 88 | return folders; 89 | } 90 | 91 | async makeFolderVisible(folderId: string) { 92 | await this.folderModel 93 | .findByIdAndUpdate( 94 | folderId, 95 | { $set: { visible: true } }, 96 | { new: true, runValidators: true }, 97 | ) 98 | .exec(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/modules/folders/folders.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FoldersService } from './folders.service'; 3 | 4 | describe('FoldersService', () => { 5 | let service: FoldersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [FoldersService], 10 | }).compile(); 11 | 12 | service = module.get(FoldersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/folders/folders.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable } from '@nestjs/common'; 2 | import { Types } from 'mongoose'; 3 | import { sum } from '@src/common'; 4 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 5 | import { PostsRepository } from '../posts/posts.repository'; 6 | import { FolderListServiceDto } from './dto/folder-list-service.dto'; 7 | import { CreateFolderDto, UpdateFolderDto } from './dto/mutate-folder.dto'; 8 | import { F001, F003 } from './error'; 9 | import { FolderRepository } from './folders.repository'; 10 | 11 | @Injectable() 12 | export class FoldersService { 13 | constructor( 14 | private readonly folderRepository: FolderRepository, 15 | private readonly postRepository: PostsRepository, 16 | ) {} 17 | 18 | async createMany(userId: string, createFolderDto: CreateFolderDto) { 19 | for (const folderName of createFolderDto.names) { 20 | const isExist = await this.folderRepository.checkUserHasFolder( 21 | userId, 22 | folderName, 23 | ); 24 | if (isExist) { 25 | throw new BadRequestException(F001(folderName)); 26 | } 27 | } 28 | const folders = createFolderDto.names.map((name) => ({ 29 | userId: userId, 30 | name, 31 | type: FolderType.CUSTOM, 32 | })); 33 | const createdFolders = this.folderRepository.createMany(folders); 34 | return createdFolders; 35 | } 36 | 37 | async findAll(userId: string): Promise { 38 | const folders = await this.folderRepository.findByUserId(userId); 39 | const folderIds = folders.map((folder) => folder._id); 40 | 41 | const groupedFolders = 42 | await this.postRepository.getPostCountByFolderIds(folderIds); 43 | 44 | const allPostCount = sum(groupedFolders, (folder) => folder.postCount); 45 | const favoritePostCount = 46 | await this.postRepository.findFavoritePostCount(userId); 47 | 48 | const defaultFolder = folders.find( 49 | (folder) => folder.type === FolderType.DEFAULT, 50 | ); 51 | const customFolders = folders 52 | .filter((folder) => folder.type === FolderType.CUSTOM) 53 | .map((folder) => { 54 | const post = groupedFolders.find((groupedFolder) => 55 | groupedFolder._id.equals(folder._id), 56 | ); 57 | return { 58 | ...folder.toJSON(), 59 | postCount: post?.postCount ?? 0, 60 | }; 61 | }); 62 | const customFoldersPostCount = sum( 63 | customFolders, 64 | (folder) => folder.postCount, 65 | ); 66 | const all = { 67 | id: null, 68 | name: '전체', 69 | type: FolderType.ALL, 70 | userId: new Types.ObjectId(userId), 71 | postCount: allPostCount, 72 | }; 73 | const favorite = { 74 | id: null, 75 | name: '즐겨찾기', 76 | type: FolderType.FAVORITE, 77 | userId: new Types.ObjectId(userId), 78 | postCount: favoritePostCount, 79 | }; 80 | const readLater = { 81 | id: defaultFolder.id, 82 | name: defaultFolder.name, 83 | type: FolderType.DEFAULT, 84 | userId: new Types.ObjectId(userId), 85 | postCount: allPostCount - customFoldersPostCount, 86 | }; 87 | 88 | const defaultFolders = [all, favorite, readLater].filter( 89 | (folder) => !!folder, 90 | ); 91 | return { defaultFolders, customFolders }; 92 | } 93 | 94 | async findOne(userId: string, folderId: string) { 95 | const postCount = await this.postRepository.getCountByFolderId(folderId); 96 | const folder = await this.folderRepository.findOneOrFail({ 97 | _id: folderId, 98 | userId, 99 | }); 100 | 101 | return { ...folder.toJSON(), postCount }; 102 | } 103 | 104 | async update( 105 | userId: string, 106 | folderId: string, 107 | updateFolderDto: UpdateFolderDto, 108 | ) { 109 | const folder = await this.folderRepository.findOneOrFail({ 110 | _id: folderId, 111 | userId, 112 | }); 113 | 114 | if (folder.name === updateFolderDto.name) { 115 | throw new BadRequestException(F003(folder.name)); 116 | } 117 | 118 | folder.name = updateFolderDto.name; 119 | const response = await folder.save(); 120 | return response; 121 | } 122 | 123 | async remove(userId: string, folderId: string) { 124 | const folder = await this.folderRepository.findOneOrFail({ 125 | userId, 126 | _id: folderId, 127 | }); 128 | 129 | await folder.deleteOne().exec(); 130 | } 131 | 132 | async removeAllCustomFolders(userId: string) { 133 | await this.folderRepository.deleteAllCustomFolder(userId); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/modules/folders/folders.v2.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { GetUser } from '@src/common'; 13 | import { ClassificationV2Service } from '../classification/classification.v2.service'; 14 | import { GetPostQueryDto } from '../posts/dto/find-in-folder.dto'; 15 | import { PostsService } from '../posts/posts.service'; 16 | import { JwtGuard } from '../users/guards/jwt.guard'; 17 | import { 18 | CreateFolderDocs, 19 | DeleteFolderDocs, 20 | FindAFolderListDocs, 21 | FindFolderDocs, 22 | FindLinksInFolderDocs, 23 | FolderControllerDocs, 24 | UpdateFolderDocs, 25 | } from './docs'; 26 | import { CreateFolderDto, DeleteCustomFolderDto, UpdateFolderDto } from './dto'; 27 | import { FoldersV2Service } from './folders.v2.service'; 28 | import { 29 | FolderListResponse, 30 | FolderPostResponse, 31 | FolderResponse, 32 | } from './responses'; 33 | import { PostResponse } from './responses/post.response'; 34 | 35 | @FolderControllerDocs 36 | @UseGuards(JwtGuard) 37 | @Controller({ version: '2', path: 'folders' }) 38 | export class FoldersV2Controller { 39 | constructor( 40 | private readonly foldersService: FoldersV2Service, 41 | private readonly postsService: PostsService, 42 | private readonly classificationService: ClassificationV2Service, 43 | ) {} 44 | 45 | @CreateFolderDocs 46 | @Post() 47 | async create( 48 | @GetUser('id') userId: string, 49 | @Body() createFolderDto: CreateFolderDto, 50 | ) { 51 | const folders = await this.foldersService.createMany( 52 | userId, 53 | createFolderDto, 54 | ); 55 | const folderSerializer = folders.map( 56 | (folder) => new FolderResponse(folder), 57 | ); 58 | return { 59 | list: folderSerializer, 60 | }; 61 | } 62 | 63 | @FindAFolderListDocs 64 | @Get() 65 | async findAll(@GetUser() userId: string) { 66 | const result = await this.foldersService.findAll(userId); 67 | return new FolderListResponse(result); 68 | } 69 | 70 | @FindFolderDocs 71 | @Get(':folderId') 72 | async findOne( 73 | @GetUser() userId: string, 74 | @Param('folderId') folderId: string, 75 | ) { 76 | const folder = await this.foldersService.findOne(userId, folderId); 77 | return new FolderResponse(folder); 78 | } 79 | 80 | @FindLinksInFolderDocs 81 | @Get(':folderId/posts') 82 | async findLinksInFolder( 83 | @GetUser() userId: string, 84 | @Param('folderId') folderId: string, 85 | @Query() query: GetPostQueryDto, 86 | ) { 87 | const result = await this.postsService.findByFolderId( 88 | userId, 89 | folderId, 90 | query, 91 | ); 92 | 93 | const posts = result.posts.map((post) => new PostResponse(post)); 94 | return new FolderPostResponse(query.page, query.limit, result.count, posts); 95 | } 96 | 97 | @UpdateFolderDocs 98 | @Patch(':folderId') 99 | async update( 100 | @GetUser() userId: string, 101 | @Param('folderId') folderId: string, 102 | @Body() updateFolderDto: UpdateFolderDto, 103 | ) { 104 | const updatedFolder = await this.foldersService.update( 105 | userId, 106 | folderId, 107 | updateFolderDto, 108 | ); 109 | return new FolderResponse(updatedFolder); 110 | } 111 | 112 | @Delete('/all') 113 | async removeAll(@Query() deleteCustomFolderDto: DeleteCustomFolderDto) { 114 | const folderIdList = await this.postsService.removeAllPostsInCustomFolders( 115 | deleteCustomFolderDto.userId, 116 | ); 117 | await this.foldersService.removeAllCustomFolders( 118 | deleteCustomFolderDto.userId, 119 | ); 120 | await this.classificationService.deleteClassificationBySuggestedFolderId( 121 | folderIdList, 122 | ); 123 | } 124 | 125 | @DeleteFolderDocs 126 | @Delete(':folderId') 127 | async remove(@GetUser() userId: string, @Param('folderId') folderId: string) { 128 | await this.classificationService.deleteClassificationBySuggestedFolderId([ 129 | folderId, 130 | ]); 131 | await this.foldersService.remove(userId, folderId); 132 | await this.postsService.removePostListByFolderId(userId, folderId); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/modules/folders/folders.v2.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import { Types } from 'mongoose'; 7 | import { sum } from '@src/common'; 8 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 9 | import { PostsPGRepository } from '../posts/posts.pg.repository'; 10 | import { FolderListServiceDto } from './dto/folder-list-service.dto'; 11 | import { CreateFolderDto, UpdateFolderDto } from './dto/mutate-folder.dto'; 12 | import { F001, F002, F003 } from './error'; 13 | import { FoldersPGRepository } from './folders.pg.repository'; 14 | 15 | @Injectable() 16 | export class FoldersV2Service { 17 | constructor( 18 | private readonly folderRepository: FoldersPGRepository, 19 | private readonly postRepository: PostsPGRepository, 20 | ) {} 21 | 22 | async createMany(userId: string, createFolderDto: CreateFolderDto) { 23 | for (const folderName of createFolderDto.names) { 24 | const isExist = await this.folderRepository.checkUserHasFolder( 25 | userId, 26 | folderName, 27 | ); 28 | if (isExist) { 29 | throw new BadRequestException(F001(folderName)); 30 | } 31 | } 32 | const folders = createFolderDto.names.map((name) => ({ 33 | userId: userId, 34 | name, 35 | type: FolderType.CUSTOM, 36 | })); 37 | const createdFolders = await this.folderRepository.createMany(folders); 38 | return createdFolders; 39 | } 40 | 41 | async findAll(userId: string): Promise { 42 | const folders = await this.folderRepository.findByUserId(userId); 43 | const folderIds = folders.map((folder) => folder.id); 44 | 45 | const groupedFolders = 46 | await this.postRepository.getPostCountByFolderIds(folderIds); 47 | 48 | const allPostCount = sum(groupedFolders, (folder) => folder.postCount); 49 | const favoritePostCount = 50 | await this.postRepository.findFavoritePostCount(userId); 51 | 52 | const defaultFolder = folders.find( 53 | (folder) => folder.type === FolderType.DEFAULT, 54 | ); 55 | const customFolders = folders 56 | .filter((folder) => folder.type === FolderType.CUSTOM) 57 | .map((folder) => { 58 | const post = groupedFolders.find( 59 | (groupedFolder) => groupedFolder.folderId === folder.id, 60 | ); 61 | return { 62 | ...folder, 63 | postCount: post?.postCount ?? 0, 64 | }; 65 | }); 66 | const customFoldersPostCount = sum( 67 | customFolders, 68 | (folder) => folder.postCount, 69 | ); 70 | const all = { 71 | id: null, 72 | name: '전체', 73 | type: FolderType.ALL, 74 | userId: new Types.ObjectId(userId), 75 | postCount: allPostCount, 76 | }; 77 | const favorite = { 78 | id: null, 79 | name: '즐겨찾기', 80 | type: FolderType.FAVORITE, 81 | userId: new Types.ObjectId(userId), 82 | postCount: favoritePostCount, 83 | }; 84 | const readLater = { 85 | id: defaultFolder.id, 86 | name: defaultFolder.name, 87 | type: FolderType.DEFAULT, 88 | userId: new Types.ObjectId(userId), 89 | postCount: allPostCount - customFoldersPostCount, 90 | }; 91 | 92 | const defaultFolders = [all, favorite, readLater].filter( 93 | (folder) => !!folder, 94 | ); 95 | return { defaultFolders, customFolders }; 96 | } 97 | 98 | async findOne(userId: string, folderId: string) { 99 | const postCount = await this.postRepository.getCountByFolderId(folderId); 100 | const folder = await this.folderRepository.findOneOrFail({ 101 | where: { 102 | id: folderId, 103 | userId, 104 | }, 105 | }); 106 | if (!folder) { 107 | throw new NotFoundException(F002); 108 | } 109 | 110 | return { ...folder, postCount }; 111 | } 112 | 113 | async update( 114 | userId: string, 115 | folderId: string, 116 | updateFolderDto: UpdateFolderDto, 117 | ) { 118 | const folder = await this.folderRepository.findOneOrFail({ 119 | where: { 120 | id: folderId, 121 | userId, 122 | }, 123 | }); 124 | if (!folder) { 125 | throw new NotFoundException(F002); 126 | } 127 | 128 | if (folder.name === updateFolderDto.name) { 129 | throw new BadRequestException(F003(folder.name)); 130 | } 131 | 132 | folder.name = updateFolderDto.name; 133 | const result = await this.folderRepository.save(folder); 134 | return result; 135 | } 136 | 137 | async remove(userId: string, folderId: string) { 138 | const folder = await this.folderRepository.findOneOrFail({ 139 | where: { 140 | id: folderId, 141 | userId, 142 | }, 143 | }); 144 | 145 | if (!folder) { 146 | throw new NotFoundException(F002); 147 | } 148 | 149 | await this.folderRepository.delete(folder.id); 150 | } 151 | 152 | async removeAllCustomFolders(userId: string) { 153 | await this.folderRepository.deleteAllCustomFolder(userId); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/modules/folders/responses/folder-list.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { FolderListServiceDto } from '../dto/folder-list-service.dto'; 3 | import { FolderResponse } from './folder.response'; 4 | 5 | export class FolderListResponse { 6 | @ApiProperty({ isArray: true, type: FolderResponse }) 7 | defaultFolders: FolderResponse[]; 8 | 9 | @ApiProperty({ isArray: true, type: FolderResponse }) 10 | customFolders: FolderResponse[]; 11 | 12 | constructor(data: FolderListServiceDto) { 13 | this.defaultFolders = data.defaultFolders.map( 14 | (folder) => new FolderResponse(folder), 15 | ); 16 | this.customFolders = data.customFolders.map( 17 | (folder) => new FolderResponse(folder), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/folders/responses/folder-post.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { BasePaginationResponse } from '@src/common'; 3 | import { PostResponse } from './post.response'; 4 | 5 | export class FolderPostResponse extends BasePaginationResponse { 6 | @ApiProperty({ 7 | type: PostResponse, 8 | isArray: true, 9 | }) 10 | list: PostResponse[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/folders/responses/folder-summary.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { FolderDocument } from '@src/infrastructure'; 3 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 4 | 5 | export class FolderSummaryResponse { 6 | @ApiProperty() 7 | id: string; 8 | 9 | @ApiProperty() 10 | name: string; 11 | 12 | @ApiProperty({ enum: FolderType }) 13 | type: FolderType; 14 | 15 | @ApiProperty() 16 | createdAt: Date; 17 | 18 | constructor(data: FolderDocument) { 19 | /** @todo postgres로 바꾸면서 수정하기 */ 20 | this.id = data._id ? data._id.toString() : data.id; 21 | this.name = data.name; 22 | this.type = data.type; 23 | this.createdAt = data.createdAt; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/folders/responses/folder.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { FolderSummaryResponse } from './folder-summary.response'; 3 | 4 | export class FolderResponse extends FolderSummaryResponse { 5 | @ApiProperty() 6 | postCount: number; 7 | 8 | constructor(data) { 9 | super(data); 10 | 11 | this.postCount = data.postCount; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/folders/responses/index.ts: -------------------------------------------------------------------------------- 1 | export * from './folder.response'; 2 | export * from './folder-list.response'; 3 | export * from './folder-post.response'; 4 | export * from './folder-summary.response'; 5 | -------------------------------------------------------------------------------- /src/modules/folders/responses/post.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Types } from 'mongoose'; 3 | import { Keyword, Post } from '@src/infrastructure'; 4 | import { PostAiStatus } from '@src/modules/posts/posts.constant'; 5 | import { KeywordItem } from '@src/modules/posts/response/keyword-list.response'; 6 | 7 | /** 8 | * @todo 9 | * 추후 이동 예정 10 | */ 11 | 12 | /** 13 | * @todo 14 | * 추후 post module로 이동 예정 15 | */ 16 | export class PostResponse { 17 | @ApiProperty({ required: true, description: '피드 id', type: String }) 18 | id: string; 19 | 20 | @ApiProperty({ required: true, description: '유저 id', type: String }) 21 | userId: string; 22 | 23 | @ApiProperty({ required: true, description: '폴더 id', type: String }) 24 | folderId: string; 25 | 26 | @ApiProperty({ required: true, description: '피드 URL', type: String }) 27 | url: string; 28 | 29 | @ApiProperty({ required: true, description: '피드 제목', type: String }) 30 | title: string; 31 | 32 | @ApiProperty({ 33 | nullable: true, 34 | description: '요약 정보', 35 | type: String, 36 | }) 37 | description: string; 38 | 39 | @ApiProperty({ description: '즐겨찾기 여부' }) 40 | isFavorite: boolean; 41 | 42 | @ApiProperty({ nullable: true, description: '읽은 시간' }) 43 | readAt: Date; 44 | 45 | @ApiProperty({ description: '생성 시간' }) 46 | createdAt: Date; 47 | 48 | @ApiProperty({ nullable: true, description: 'URL og 이미지' }) 49 | thumbnailImgUrl: string | null; 50 | 51 | @ApiProperty({ 52 | required: true, 53 | enum: PostAiStatus, 54 | description: '피드 게시글의 ai 진행 상태', 55 | }) 56 | aiStatus: PostAiStatus; 57 | 58 | @ApiProperty({ 59 | type: KeywordItem, 60 | isArray: true, 61 | description: 'ai 키워드 리스트', 62 | }) 63 | keywords: KeywordItem[]; 64 | constructor( 65 | data: Post & { 66 | _id: Types.ObjectId; 67 | keywords: (Keyword & { _id: Types.ObjectId })[]; 68 | }, 69 | ) { 70 | this.id = data._id.toString(); 71 | this.userId = data.userId.toString(); 72 | this.folderId = data.folderId.toString(); 73 | this.url = data.url; 74 | this.title = data.title; 75 | this.description = data.description; 76 | this.isFavorite = data.isFavorite; 77 | this.readAt = data.readAt; 78 | this.createdAt = data.createdAt; 79 | this.thumbnailImgUrl = data.thumbnailImgUrl; 80 | this.aiStatus = data.aiStatus; 81 | this.keywords = data.keywords.map((keyword) => new KeywordItem(keyword)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/modules/keywords/keyword.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Keyword } from '@src/infrastructure/database/entities/keyword.entity'; 4 | 5 | @Injectable() 6 | export class KeywordsPGRepository extends Repository { 7 | constructor(private dataSource: DataSource) { 8 | super(Keyword, dataSource.createEntityManager()); 9 | } 10 | 11 | async createMany(keywords: string[]) { 12 | const entities = keywords.map((keyword) => { 13 | const entity = this.create({ 14 | name: keyword, 15 | }); 16 | return entity; 17 | }); 18 | 19 | return await this.save(entities); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/keywords/keyword.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { Keyword } from '@src/infrastructure'; 5 | 6 | @Injectable() 7 | export class KeywordsRepository { 8 | constructor( 9 | @InjectModel(Keyword.name) 10 | private readonly keywordModel: Model, 11 | ) {} 12 | 13 | async createMany(keywords: string[]) { 14 | return await this.keywordModel.insertMany( 15 | keywords.map((keyword) => ({ 16 | name: keyword, 17 | })), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/launching-events/launching-events.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { LaunchingEventsService } from './launching-events.service'; 3 | 4 | @Controller('launching-events') 5 | export class LaunchingEventsController { 6 | constructor( 7 | private readonly launchingEventsService: LaunchingEventsService, 8 | ) {} 9 | 10 | @Get() 11 | public async getResult( 12 | @Query('name') name: string, 13 | @Query('keywords') keywords: string, 14 | @Query('link') link: string, 15 | ) { 16 | const result = await this.launchingEventsService.getResult(keywords, link); 17 | 18 | const maxScore = Math.min(Math.max(...result), 100); 19 | return { name, score: maxScore }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/launching-events/launching-events.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AiModule } from '@src/infrastructure/ai/ai.module'; 3 | import { LaunchingEventsController } from './launching-events.controller'; 4 | import { LaunchingEventsService } from './launching-events.service'; 5 | 6 | @Module({ 7 | imports: [AiModule], 8 | controllers: [LaunchingEventsController], 9 | providers: [LaunchingEventsService], 10 | }) 11 | export class LaunchingEventsModule {} 12 | -------------------------------------------------------------------------------- /src/modules/launching-events/launching-events.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { parseLinkTitleAndContent } from '@src/common'; 3 | import { AiService } from '@src/infrastructure/ai/ai.service'; 4 | 5 | @Injectable() 6 | export class LaunchingEventsService { 7 | constructor(private readonly aiService: AiService) {} 8 | 9 | public async getResult(userKeywords: string, link: string) { 10 | const { content } = await parseLinkTitleAndContent(link); 11 | 12 | const { keywords } = await this.aiService.getKeywords(content); 13 | 14 | const userVector = await this.aiService.getKeywordEmbeddings(userKeywords); 15 | const extractedVectors = await Promise.all( 16 | keywords.map((k) => this.aiService.getKeywordEmbeddings(k)), 17 | ); 18 | 19 | const result = extractedVectors.map( 20 | (v) => this.cosineSimilarity(userVector, v) * 100, 21 | ); 22 | 23 | return result; 24 | } 25 | 26 | private cosineSimilarity(vectorA: number[], vectorB: number[]): number { 27 | const dotProduct = vectorA.reduce( 28 | (acc, val, i) => acc + val * vectorB[i], 29 | 0, 30 | ); 31 | const magnitudeA = Math.sqrt( 32 | vectorA.reduce((acc, val) => acc + val * val, 0), 33 | ); 34 | const magnitudeB = Math.sqrt( 35 | vectorB.reduce((acc, val) => acc + val * val, 0), 36 | ); 37 | return dotProduct / (magnitudeA * magnitudeB); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/links/docs/controller.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | export const LinksControllerDocs = applyDecorators(ApiTags('Link API')); 5 | -------------------------------------------------------------------------------- /src/modules/links/docs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.docs'; 2 | export * from './validate-link.docs'; 3 | -------------------------------------------------------------------------------- /src/modules/links/docs/validate-link.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { ValidateLinkResponse } from '../responses'; 4 | 5 | export const ValidateLinkDocs = applyDecorators( 6 | ApiOperation({ 7 | summary: '링크 유효 체크 API', 8 | description: 9 | '전달받은 링크가 유효한지 체크 후 isValidate라는 플리그 필드로 전닲', 10 | }), 11 | ApiResponse({ 12 | type: ValidateLinkResponse, 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /src/modules/links/links.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { LinksController } from './links.v2.controller'; 3 | import { LinksService } from './links.v2.service'; 4 | 5 | describe('LinksController', () => { 6 | let controller: LinksController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [LinksController], 11 | providers: [LinksService], 12 | }).compile(); 13 | 14 | controller = module.get(LinksController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/modules/links/links.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LinksController } from './links.v2.controller'; 3 | import { LinksService } from './links.v2.service'; 4 | 5 | @Module({ 6 | controllers: [LinksController], 7 | providers: [LinksService], 8 | }) 9 | export class LinksModule {} 10 | -------------------------------------------------------------------------------- /src/modules/links/links.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { LinksService } from './links.v2.service'; 3 | 4 | describe('LinksService', () => { 5 | let service: LinksService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [LinksService], 10 | }).compile(); 11 | 12 | service = module.get(LinksService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/links/links.v2.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { LinksControllerDocs, ValidateLinkDocs } from './docs'; 3 | import { LinksService } from './links.v2.service'; 4 | import { ValidateLinkResponse } from './responses'; 5 | 6 | @LinksControllerDocs 7 | @Controller('links') 8 | export class LinksController { 9 | constructor(private readonly linksService: LinksService) {} 10 | 11 | @ValidateLinkDocs 12 | @Get('/validation') 13 | async validateLink( 14 | @Query('link') link: string, 15 | ): Promise { 16 | const result = await this.linksService.validateLink(link); 17 | 18 | return new ValidateLinkResponse(result); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/links/links.v2.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class LinksService { 5 | async validateLink(link: string): Promise { 6 | try { 7 | new URL(link); 8 | return true; 9 | } catch (err) { 10 | return false; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/links/responses/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validate-link.response'; 2 | -------------------------------------------------------------------------------- /src/modules/links/responses/validate-link.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ValidateLinkResponse { 4 | @ApiProperty() 5 | isValidate: boolean; 6 | 7 | constructor(isValidate: boolean) { 8 | this.isValidate = isValidate; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/metrics/metrics.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { Metrics, MetricsSchema } from '@src/infrastructure'; 4 | import { MetricsRepository } from './metrics.repository'; 5 | import { MetricsService } from './metrics.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forFeature([{ name: Metrics.name, schema: MetricsSchema }]), 10 | ], 11 | providers: [MetricsService, MetricsRepository], 12 | exports: [MetricsService, MetricsRepository], 13 | }) 14 | export class MetricsModule {} 15 | -------------------------------------------------------------------------------- /src/modules/metrics/metrics.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { Metrics } from '@src/infrastructure/database/entities/metrics.entity'; 4 | 5 | @Injectable() 6 | export class MetricsRepository extends Repository { 7 | constructor(private dataSource: DataSource) { 8 | super(Metrics, dataSource.createEntityManager()); 9 | } 10 | 11 | async createMetrics( 12 | isSuccess: boolean, 13 | time: number, 14 | postURL: string, 15 | postId: string, 16 | ) { 17 | const metrics = await this.save({ 18 | isSuccess, 19 | time, 20 | postURL, 21 | postId, 22 | }); 23 | return metrics; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/modules/metrics/metrics.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { Metrics } from '@src/infrastructure'; 5 | 6 | @Injectable() 7 | export class MetricsRepository { 8 | constructor( 9 | @InjectModel(Metrics.name) private readonly metricsModel: Model, 10 | ) {} 11 | 12 | async createMetrics( 13 | isSuccess: boolean, 14 | time: number, 15 | postURL: string, 16 | postId: string, 17 | ) { 18 | const metrics = await this.metricsModel.create({ 19 | isSuccess, 20 | time, 21 | postURL, 22 | postId, 23 | }); 24 | return metrics; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/metrics/metrics.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { MetricsRepository } from './metrics.repository'; 3 | 4 | @Injectable() 5 | export class MetricsService { 6 | constructor(private readonly repository: MetricsRepository) {} 7 | 8 | async createMetrics( 9 | isSuccess: boolean, 10 | time: number, 11 | postURL: string, 12 | postId: string, 13 | ) { 14 | const metric = await this.repository.createMetrics( 15 | isSuccess, 16 | time, 17 | postURL, 18 | postId, 19 | ); 20 | return metric; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/onboard/docs/controller.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | export const OnBoardControllerDocs = applyDecorators(ApiTags('On-Board')); 5 | -------------------------------------------------------------------------------- /src/modules/onboard/docs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.docs'; 2 | export * from './onboard.docs'; 3 | -------------------------------------------------------------------------------- /src/modules/onboard/docs/onboard.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export const ListOnBoardKeywordsDocs = applyDecorators( 5 | ApiProperty({ 6 | description: 7 | 'Onboard 키워드를 반환합니다. 필요에 따라 반환 개수 제한해주시면 됩니다.', 8 | }), 9 | ); 10 | -------------------------------------------------------------------------------- /src/modules/onboard/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './onboard.query'; 2 | -------------------------------------------------------------------------------- /src/modules/onboard/dto/onboard.query.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsNumber, IsOptional, Min } from 'class-validator'; 4 | 5 | export class OnBoardQuery { 6 | @ApiProperty({ 7 | required: false, 8 | description: '온보딩 데이터 받고 싶은 갯수', 9 | }) 10 | @IsOptional() 11 | @IsNumber() 12 | @Type(() => Number) 13 | @Min(1) 14 | limit: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/onboard/http/request.http: -------------------------------------------------------------------------------- 1 | ### get onboard all categories 2 | GET http://localhost:3000/onboard 3 | 4 | ### get onboard limited categories 5 | GET http://localhost:3000/onboard?limit=5 6 | -------------------------------------------------------------------------------- /src/modules/onboard/onboard.const.ts: -------------------------------------------------------------------------------- 1 | export const onBoardCategoryList = [ 2 | '경제', 3 | '디자인', 4 | '개발', 5 | '쇼핑', 6 | '요리법', 7 | '여행', 8 | '음악', 9 | 'UX', 10 | '자기개발', 11 | '금융', 12 | '소셜', 13 | '미디어', 14 | '뉴스', 15 | '오락', 16 | '비즈니스', 17 | '건강', 18 | '부동산', 19 | '세계', 20 | '예술', 21 | '스포츠', 22 | '경영', 23 | '운동', 24 | '기술', 25 | '영화', 26 | '책', 27 | '사진', 28 | '교육', 29 | '과학', 30 | '패션', 31 | '정치', 32 | '생산성', 33 | '환경', 34 | ]; 35 | -------------------------------------------------------------------------------- /src/modules/onboard/onboard.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { ListOnBoardKeywordsDocs, OnBoardControllerDocs } from './docs'; 3 | import { OnBoardQuery } from './dto'; 4 | import { OnboardService } from './onboard.service'; 5 | 6 | @Controller('onboard') 7 | @OnBoardControllerDocs 8 | export class OnboardController { 9 | constructor(private readonly onboardService: OnboardService) {} 10 | 11 | @Get() 12 | @ListOnBoardKeywordsDocs 13 | listOnBoardKeywords(@Query() query: OnBoardQuery) { 14 | return this.onboardService.listOnBoardKeywords(query); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/onboard/onboard.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OnboardController } from './onboard.controller'; 3 | import { OnBoardRepository } from './onboard.pg.repository'; 4 | import { OnboardService } from './onboard.service'; 5 | 6 | @Module({ 7 | providers: [OnboardService, OnBoardRepository], 8 | controllers: [OnboardController], 9 | }) 10 | export class OnboardModule {} 11 | -------------------------------------------------------------------------------- /src/modules/onboard/onboard.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { OnboardCategory } from '@src/infrastructure/database/entities/onboard-category.entity'; 4 | import { onBoardCategoryList } from './onboard.const'; 5 | 6 | @Injectable() 7 | export class OnBoardRepository extends Repository { 8 | constructor(private dataSource: DataSource) { 9 | super(OnboardCategory, dataSource.createEntityManager()); 10 | } 11 | 12 | async addOnboard(onboard: OnboardCategory) { 13 | /// 카테고리 요소에 중복된 값이 없는 경우 14 | if ( 15 | (await this.find({ where: { category: onboard.category } })).length === 0 16 | ) { 17 | await this.save(onboard); 18 | } 19 | } 20 | 21 | private async addOnboardList(): Promise { 22 | const newList = onBoardCategoryList.map((category) => ({ category })); 23 | 24 | await this.createQueryBuilder() 25 | .insert() 26 | .into('onboards') 27 | .values(newList) 28 | .execute(); 29 | 30 | return this.find(); 31 | } 32 | 33 | async getOnboardCategoryList(): Promise { 34 | const categories = await this.find(); 35 | 36 | if (categories.length === 0) { 37 | return this.addOnboardList(); 38 | } 39 | return categories; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/onboard/onboard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import * as _ from 'lodash'; 3 | import { OnboardCategory } from '@src/infrastructure/database/entities/onboard-category.entity'; 4 | import { OnBoardQuery } from './dto'; 5 | import { OnBoardRepository } from './onboard.pg.repository'; 6 | 7 | @Injectable() 8 | export class OnboardService { 9 | constructor(private readonly onBoardRepository: OnBoardRepository) {} 10 | 11 | async listOnBoardKeywords(query: OnBoardQuery) { 12 | const categoryList = await this.onBoardRepository.getOnboardCategoryList(); 13 | return query.limit 14 | ? this.getLimitedOnBoardKeywords(categoryList, query.limit) 15 | : categoryList; 16 | } 17 | 18 | private getLimitedOnBoardKeywords( 19 | keywords: OnboardCategory[], 20 | limit: number, 21 | ) { 22 | return _.sampleSize(keywords, limit); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/posts/docs/controller.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 3 | 4 | export const PostControllerDocs = applyDecorators( 5 | ApiTags('posts'), 6 | ApiBearerAuth(), 7 | ); 8 | -------------------------------------------------------------------------------- /src/modules/posts/docs/countPost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; 3 | 4 | export const CountPostDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: '피드 개수 반환', 7 | description: '반환은 단일 정수타입입니다!', 8 | }), 9 | ApiOkResponse({ 10 | type: Number, 11 | }), 12 | ); 13 | -------------------------------------------------------------------------------- /src/modules/posts/docs/createPost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiNotFoundResponse, 4 | ApiOperation, 5 | ApiResponse, 6 | } from '@nestjs/swagger'; 7 | import { Types } from 'mongoose'; 8 | 9 | export const CreatePostDocs = applyDecorators( 10 | ApiOperation({ 11 | summary: '피드 링크 저장', 12 | }), 13 | ApiResponse({ 14 | type: Types.ObjectId, 15 | }), 16 | ApiNotFoundResponse({ 17 | description: ['F002'].join(', '), 18 | }), 19 | ); 20 | -------------------------------------------------------------------------------- /src/modules/posts/docs/deletePost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiNotFoundResponse, ApiOperation } from '@nestjs/swagger'; 3 | 4 | export const DeletePostDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: '피드 삭제', 7 | }), 8 | ApiNotFoundResponse({ 9 | description: 'P001', 10 | }), 11 | ); 12 | -------------------------------------------------------------------------------- /src/modules/posts/docs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.docs'; 2 | export * from './countPost.docs'; 3 | export * from './createPost.docs'; 4 | export * from './deletePost.docs'; 5 | export * from './listPost.docs'; 6 | export * from './retrievePost.docs'; 7 | export * from './updatePostFolder.docs'; 8 | -------------------------------------------------------------------------------- /src/modules/posts/docs/listPost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { ListPostResponse } from '../response'; 4 | 5 | export const ListPostDocs = applyDecorators( 6 | ApiOperation({ 7 | summary: '전체 피드 조회', 8 | }), 9 | ApiResponse({ 10 | type: ListPostResponse, 11 | }), 12 | ); 13 | -------------------------------------------------------------------------------- /src/modules/posts/docs/retrievePost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { 3 | ApiNotFoundResponse, 4 | ApiOkResponse, 5 | ApiOperation, 6 | } from '@nestjs/swagger'; 7 | import { RetrievePostResponse } from '../response'; 8 | 9 | export const RetrievePostDocs = applyDecorators( 10 | ApiOperation({ 11 | summary: '단일 피드 조회', 12 | }), 13 | ApiOkResponse({ 14 | type: RetrievePostResponse, 15 | }), 16 | ApiNotFoundResponse({ 17 | description: ['P001'].join(','), 18 | }), 19 | ); 20 | -------------------------------------------------------------------------------- /src/modules/posts/docs/updatePost.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiNotFoundResponse, ApiOperation } from '@nestjs/swagger'; 3 | 4 | export const UpdatePostDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: '피드 정보 변경', 7 | }), 8 | ApiNotFoundResponse({ 9 | description: 'P001', 10 | }), 11 | ); 12 | -------------------------------------------------------------------------------- /src/modules/posts/docs/updatePostFolder.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiNotFoundResponse, ApiOperation } from '@nestjs/swagger'; 3 | 4 | export const UpdatePostFolderDocs = applyDecorators( 5 | ApiOperation({ 6 | summary: '피드 폴더 변경', 7 | }), 8 | ApiNotFoundResponse({ 9 | description: ['P001', 'F002'].join(', '), 10 | }), 11 | ); 12 | -------------------------------------------------------------------------------- /src/modules/posts/dto/countPost.query.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsBoolean, IsOptional } from 'class-validator'; 4 | 5 | export class CountPostQueryDto { 6 | @ApiProperty({ 7 | required: false, 8 | description: 9 | '피드를 읽었는지 여부. 해당 QS 주어지지 않으면 전체 피드 개수 반환.', 10 | }) 11 | @Transform(({ value }) => (value === 'true' ? true : false)) 12 | @IsBoolean() 13 | @IsOptional() 14 | isRead: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/posts/dto/create-post.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsMongoId, IsNotEmpty, IsUrl } from 'class-validator'; 3 | 4 | export class CreatePostDto { 5 | @IsMongoId() 6 | @IsNotEmpty() 7 | @ApiProperty({ description: '폴더 id', required: true }) 8 | folderId: string; 9 | 10 | @IsUrl() 11 | @IsNotEmpty() 12 | @ApiProperty({ description: '저장할 url', required: true }) 13 | url: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/posts/dto/find-in-folder.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsBoolean, IsOptional } from 'class-validator'; 4 | import { BasePaginationQuery } from '@src/common'; 5 | 6 | export class GetPostQueryDto extends BasePaginationQuery { 7 | @ApiProperty({ 8 | required: false, 9 | description: '읽음 필터링 여부', 10 | }) 11 | @Transform(({ value }) => (value === 'true' ? true : false)) 12 | @IsBoolean() 13 | @IsOptional() 14 | isRead: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/modules/posts/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './countPost.query'; 2 | export * from './create-post.dto'; 3 | export * from './list-post.dto'; 4 | export * from './updatePost.dto'; 5 | export * from './updatePostFolder.dto'; 6 | -------------------------------------------------------------------------------- /src/modules/posts/dto/list-post.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsBoolean, IsOptional } from 'class-validator'; 4 | import { BasePaginationQuery } from '@src/common'; 5 | 6 | export class ListPostQueryDto extends BasePaginationQuery { 7 | @ApiProperty({ 8 | required: false, 9 | description: '즐겨찾기 필터링 여부 확인', 10 | }) 11 | @Transform(({ value }) => (value === 'true' ? true : false)) 12 | @IsBoolean() 13 | @IsOptional() 14 | favorite: boolean; 15 | 16 | @ApiProperty({ 17 | required: false, 18 | description: '읽음 필터링 여부', 19 | }) 20 | @Transform(({ value }) => (value === 'true' ? true : false)) 21 | @IsBoolean() 22 | @IsOptional() 23 | isRead: boolean; 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/posts/dto/updatePost.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsDateString, IsOptional } from 'class-validator'; 3 | import { PostUpdateableFields } from '../type/type'; 4 | 5 | export class UpdatePostDto implements PostUpdateableFields { 6 | // Temporary ignore in MVP level 7 | title: string; 8 | @ApiProperty({ 9 | required: false, 10 | default: false, 11 | }) 12 | @IsOptional() 13 | @IsBoolean() 14 | isFavorite: boolean; 15 | 16 | @ApiProperty({ 17 | required: false, 18 | }) 19 | @IsOptional() 20 | @IsDateString() 21 | readAt: Date; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/posts/dto/updatePostFolder.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsMongoId, IsNotEmpty } from 'class-validator'; 3 | 4 | export class UpdatePostFolderDto { 5 | @IsNotEmpty() 6 | @IsMongoId() 7 | @ApiProperty() 8 | folderId: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/posts/error/index.ts: -------------------------------------------------------------------------------- 1 | import { createErrorObject } from '@src/common'; 2 | 3 | export const P001 = createErrorObject('P001', '피드가 존재하지 않습니다!'); 4 | -------------------------------------------------------------------------------- /src/modules/posts/postKeywords.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, In } from 'typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { PostKeyword } from '@src/infrastructure/database/entities/post-keyword.entity'; 5 | 6 | @Injectable() 7 | export class PostKeywordsPGRepository extends Repository { 8 | constructor(private dataSource: DataSource) { 9 | super(PostKeyword, dataSource.createEntityManager()); 10 | } 11 | 12 | async createPostKeywords(postId: string, keywordIds: string[]) { 13 | const postKeywords = keywordIds.map((keywordId) => ({ 14 | postId, 15 | keywordId, 16 | })); 17 | 18 | await this.insert(postKeywords); 19 | } 20 | 21 | async findKeywordsByPostId(postId: string): Promise { 22 | return await this.find({ 23 | where: { 24 | postId: postId, 25 | }, 26 | relations: ['keyword'], 27 | }); 28 | } 29 | 30 | async findKeywordsByPostIds(postIds: string[]) { 31 | return await this.find({ 32 | where: { 33 | postId: In(postIds), 34 | }, 35 | relations: ['keyword'], 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modules/posts/postKeywords.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, Types } from 'mongoose'; 4 | import { PostKeyword } from '@src/infrastructure/database/schema/postKeyword.schema'; 5 | 6 | @Injectable() 7 | export class PostKeywordsRepository { 8 | constructor( 9 | @InjectModel(PostKeyword.name) 10 | private readonly postKeywordModel: Model, 11 | ) {} 12 | 13 | async createPostKeywords(postId: string, keywordIds: string[]) { 14 | const postKeywords = keywordIds.map((keywordId) => ({ 15 | postId, 16 | keywordId, 17 | })); 18 | 19 | await this.postKeywordModel.insertMany(postKeywords); 20 | } 21 | 22 | async findKeywordsByPostId( 23 | postId: string, 24 | ): Promise< 25 | (PostKeyword & { keywordId: { _id: Types.ObjectId; name: string } })[] 26 | > { 27 | return await this.postKeywordModel 28 | .find({ 29 | postId: postId, 30 | }) 31 | .populate({ 32 | path: 'keywordId', 33 | model: 'Keyword', 34 | }) 35 | .lean(); 36 | } 37 | 38 | async findKeywordsByPostIds(postIds: string[]) { 39 | return await this.postKeywordModel 40 | .find({ postId: { $in: postIds } }) 41 | .populate({ path: 'keywordId', model: 'Keyword' }) 42 | .lean(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/posts/posts.constant.ts: -------------------------------------------------------------------------------- 1 | export enum PostAiStatus { 2 | IN_PROGRES = 'in_progress', 3 | SUCCESS = 'success', 4 | FAIL = 'fail', 5 | } 6 | -------------------------------------------------------------------------------- /src/modules/posts/posts.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { GetUser } from '@src/common'; 13 | import { CreatePostDto } from '@src/modules/posts/dto/create-post.dto'; 14 | import { PostsService } from '@src/modules/posts/posts.service'; 15 | import { JwtGuard } from '@src/modules/users/guards'; 16 | import { 17 | CountPostDocs, 18 | CreatePostDocs, 19 | DeletePostDocs, 20 | ListPostDocs, 21 | PostControllerDocs, 22 | RetrievePostDocs, 23 | UpdatePostFolderDocs, 24 | } from './docs'; 25 | import { UpdatePostDocs } from './docs/updatePost.docs'; 26 | import { 27 | CountPostQueryDto, 28 | ListPostQueryDto, 29 | UpdatePostDto, 30 | UpdatePostFolderDto, 31 | } from './dto'; 32 | import { 33 | ListPostItem, 34 | ListPostResponse, 35 | RetrievePostResponse, 36 | } from './response'; 37 | import { KeywordItem } from './response/keyword-list.response'; 38 | 39 | @Controller('posts') 40 | @PostControllerDocs 41 | @UseGuards(JwtGuard) 42 | export class PostsController { 43 | constructor(private readonly postsService: PostsService) {} 44 | 45 | @Get() 46 | @ListPostDocs 47 | async listPost(@GetUser() userId: string, @Query() query: ListPostQueryDto) { 48 | const { count, posts } = await this.postsService.listPost(userId, query); 49 | const postResponse = posts.map((post) => new ListPostItem(post)); 50 | 51 | return new ListPostResponse(count, query.page, query.limit, postResponse); 52 | } 53 | 54 | @Get('count') 55 | @CountPostDocs 56 | async countPost( 57 | @GetUser() userId: string, 58 | @Query() query: CountPostQueryDto, 59 | ) { 60 | const count = await this.postsService.countPost(userId, query); 61 | return count; 62 | } 63 | 64 | @Post() 65 | @CreatePostDocs 66 | async createPost( 67 | @Body() createPostDto: CreatePostDto, 68 | @GetUser('id') userId: string, 69 | ) { 70 | const post = await this.postsService.createPost(createPostDto, userId); 71 | const postSerialize = new ListPostItem(post); 72 | return postSerialize; 73 | } 74 | 75 | @Get(':postId') 76 | @RetrievePostDocs 77 | async getPost(@GetUser() userId: string, @Param('postId') postId: string) { 78 | const { post, keywords } = await this.postsService.readPost(userId, postId); 79 | const postKeywords = keywords.map( 80 | (keyword) => new KeywordItem(keyword.keywordId), 81 | ); 82 | const response = new RetrievePostResponse({ 83 | ...post, 84 | keywords: postKeywords, 85 | }); 86 | return response; 87 | } 88 | 89 | @Patch(':postId') 90 | @UpdatePostDocs 91 | async updatePost( 92 | @GetUser() userId: string, 93 | @Param('postId') postId: string, 94 | @Body() dto: UpdatePostDto, 95 | ) { 96 | const post = await this.postsService.updatePost(userId, postId, dto); 97 | const postSerialize = new ListPostItem(post); 98 | return postSerialize; 99 | } 100 | 101 | @Patch(':postId/move') 102 | @UpdatePostFolderDocs 103 | async updatePostFolder( 104 | @GetUser() userId: string, 105 | @Param('postId') postId: string, 106 | @Body() dto: UpdatePostFolderDto, 107 | ) { 108 | return await this.postsService.updatePostFolder(userId, postId, dto); 109 | } 110 | 111 | @Delete(':postId') 112 | @DeletePostDocs 113 | async deletePost(@GetUser() userId: string, @Param('postId') postId: string) { 114 | return await this.postsService.deletePost(userId, postId); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/modules/posts/posts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { 4 | AIClassification, 5 | AIClassificationSchema, 6 | Folder, 7 | FolderSchema, 8 | Post, 9 | PostSchema, 10 | } from '@src/infrastructure'; 11 | import { AwsLambdaModule } from '@src/infrastructure/aws-lambda/aws-lambda.module'; 12 | import { AwsLambdaService } from '@src/infrastructure/aws-lambda/aws-lambda.service'; 13 | import { 14 | PostKeyword, 15 | PostKeywordSchema, 16 | } from '@src/infrastructure/database/schema/postKeyword.schema'; 17 | import { PostsRepository } from '@src/modules/posts/posts.repository'; 18 | import { UsersModule } from '@src/modules/users/users.module'; 19 | import { AiClassificationModule } from '../ai-classification/ai-classification.module'; 20 | import { FolderRepository } from '../folders/folders.repository'; 21 | import { PostKeywordsRepository } from './postKeywords.repository'; 22 | import { PostsController } from './posts.controller'; 23 | import { PostsPGRepository } from './posts.pg.repository'; 24 | import { PostsService } from './posts.service'; 25 | 26 | @Module({ 27 | imports: [ 28 | MongooseModule.forFeature([ 29 | { name: Post.name, schema: PostSchema }, 30 | { name: Folder.name, schema: FolderSchema }, 31 | { name: AIClassification.name, schema: AIClassificationSchema }, 32 | { name: PostKeyword.name, schema: PostKeywordSchema }, 33 | ]), 34 | UsersModule, 35 | AwsLambdaModule, 36 | AiClassificationModule, 37 | ], 38 | controllers: [PostsController], 39 | providers: [ 40 | PostsService, 41 | PostsRepository, 42 | FolderRepository, 43 | AwsLambdaService, 44 | PostKeywordsRepository, 45 | PostsPGRepository, 46 | ], 47 | exports: [PostsService, PostsRepository, PostsPGRepository], 48 | }) 49 | export class PostsModule {} 50 | -------------------------------------------------------------------------------- /src/modules/posts/response/index.ts: -------------------------------------------------------------------------------- 1 | export * from './listPost.response'; 2 | export * from './retrievePost.response'; 3 | -------------------------------------------------------------------------------- /src/modules/posts/response/keyword-list.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Types } from 'mongoose'; 3 | import { Keyword } from '@src/infrastructure'; 4 | 5 | export class KeywordItem { 6 | @ApiProperty({ description: '키워드 id' }) 7 | id: string; 8 | 9 | @ApiProperty({ description: '키워드 이름' }) 10 | name: string; 11 | 12 | constructor(keyword: Keyword & { _id: Types.ObjectId }) { 13 | this.id = keyword._id.toString(); 14 | this.name = keyword.name; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/posts/response/listPost.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Types } from 'mongoose'; 3 | import { BasePaginationResponse } from '@src/common'; 4 | import { Keyword, Post } from '@src/infrastructure'; 5 | import { PostAiStatus } from '@src/modules/posts/posts.constant'; 6 | import { KeywordItem } from './keyword-list.response'; 7 | 8 | export type PostItemDto = Post & { 9 | _id: Types.ObjectId; 10 | keywords: (Keyword & { _id: Types.ObjectId })[]; 11 | }; 12 | 13 | export class ListPostItem { 14 | @ApiProperty({ required: true, description: '피드 id', type: String }) 15 | id: string; 16 | 17 | @ApiProperty({ required: true, description: '폴더 id', type: String }) 18 | folderId: string; 19 | 20 | @ApiProperty({ required: true, description: '피드 URL', type: String }) 21 | url: string; 22 | 23 | @ApiProperty({ required: true, description: '피드 제목', type: String }) 24 | title: string; 25 | 26 | @ApiProperty({ 27 | nullable: true, 28 | description: '요약 정보', 29 | type: String, 30 | }) 31 | description: string; 32 | 33 | @ApiProperty() 34 | keywords: KeywordItem[]; 35 | 36 | @ApiProperty({ required: true, description: '즐겨찾기 여부', type: Boolean }) 37 | isFavorite: boolean; 38 | 39 | @ApiProperty({ required: true, description: '생성 시간', type: Date }) 40 | createdAt: Date; 41 | 42 | @ApiProperty({ nullable: true, description: '읽음 시간' }) 43 | readAt: Date | null; 44 | 45 | @ApiProperty({ nullable: true, description: 'URL og 이미지' }) 46 | thumbnailImgUrl: string | null; 47 | 48 | @ApiProperty({ 49 | required: true, 50 | enum: PostAiStatus, 51 | description: '피드 게시글의 ai 진행 상태', 52 | }) 53 | aiStatus: PostAiStatus; 54 | 55 | constructor(data: PostItemDto) { 56 | this.id = data._id.toString(); 57 | this.folderId = data.folderId.toString(); 58 | this.url = data.url; 59 | this.title = data.title; 60 | this.description = data.description; 61 | this.keywords = data.keywords.map((keyword) => new KeywordItem(keyword)); 62 | this.isFavorite = data.isFavorite; 63 | this.createdAt = data.createdAt; 64 | this.readAt = data.readAt; 65 | this.createdAt = data.createdAt; 66 | this.thumbnailImgUrl = data.thumbnailImgUrl; 67 | this.aiStatus = data.aiStatus; 68 | } 69 | } 70 | 71 | export class ListPostResponse extends BasePaginationResponse { 72 | @ApiProperty({ 73 | type: ListPostItem, 74 | isArray: true, 75 | }) 76 | list: ListPostItem[]; 77 | } 78 | -------------------------------------------------------------------------------- /src/modules/posts/response/retrievePost.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Types } from 'mongoose'; 3 | import { Post } from '@src/infrastructure'; 4 | import { PostAiStatus } from '../posts.constant'; 5 | import { KeywordItem } from './keyword-list.response'; 6 | 7 | export type RetrievePostItemDto = Post & { 8 | _id: Types.ObjectId; 9 | keywords: KeywordItem[]; 10 | }; 11 | 12 | export class RetrievePostResponse { 13 | @ApiProperty({ required: true, description: '피드 id', type: String }) 14 | id?: string; 15 | 16 | @ApiProperty({ required: true, description: '폴더 id', type: String }) 17 | folderId: Types.ObjectId | string; 18 | 19 | @ApiProperty({ required: true, description: '피드 URL', type: String }) 20 | url: string; 21 | 22 | @ApiProperty({ required: true, description: '피드 제목', type: String }) 23 | title: string; 24 | 25 | @ApiProperty({ 26 | nullable: true, 27 | description: '요약 정보', 28 | type: String, 29 | }) 30 | description: string; 31 | 32 | @ApiProperty({ 33 | type: KeywordItem, 34 | isArray: true, 35 | }) 36 | keywords: KeywordItem[]; 37 | 38 | @ApiProperty({ required: true, description: '즐겨찾기 여부', type: Boolean }) 39 | isFavorite: boolean; 40 | 41 | @ApiProperty({ required: true, description: '생성 시간', type: Date }) 42 | createdAt: Date; 43 | 44 | @ApiProperty({ nullable: true, description: '읽음 시간' }) 45 | readAt: Date | null; 46 | 47 | @ApiProperty({ nullable: true, description: 'URL og 이미지' }) 48 | thumbnailImgUrl: string | null; 49 | 50 | @ApiProperty({ 51 | required: true, 52 | enum: PostAiStatus, 53 | description: '피드 게시글의 ai 진행 상태', 54 | }) 55 | aiStatus: PostAiStatus; 56 | 57 | constructor(data: RetrievePostItemDto & { _id: Types.ObjectId }) { 58 | this.id = data._id.toString(); 59 | this.folderId = data.folderId.toString(); 60 | this.url = data.url; 61 | this.title = data.title; 62 | this.description = data.description; 63 | this.keywords = data.keywords; 64 | this.isFavorite = data.isFavorite; 65 | this.createdAt = data.createdAt; 66 | this.readAt = data.readAt; 67 | this.thumbnailImgUrl = data.thumbnailImgUrl; 68 | this.aiStatus = data.aiStatus; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/modules/posts/type/type.d.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@src/infrastructure'; 2 | 3 | export interface PostUpdateableFields 4 | extends Pick {} 5 | -------------------------------------------------------------------------------- /src/modules/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | // import { Global, Module } from '@nestjs/common'; 2 | // import { PrismaService } from './prisma.service'; 3 | 4 | // @Global() 5 | // @Module({ 6 | // providers: [PrismaService], 7 | // exports: [PrismaService], 8 | // }) 9 | // export class PrismaModule {} 10 | -------------------------------------------------------------------------------- /src/modules/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | // import { 2 | // Injectable, 3 | // Logger, 4 | // OnModuleDestroy, 5 | // OnModuleInit, 6 | // } from '@nestjs/common'; 7 | // import { Prisma, PrismaClient } from '@prisma/client'; 8 | 9 | // @Injectable() 10 | // export class PrismaService 11 | // extends PrismaClient 12 | // implements OnModuleInit, OnModuleDestroy 13 | // { 14 | // private context = 'Prisma-Debug'; 15 | // private logger = new Logger(this.context); 16 | 17 | // // Prisma Logging Reference: https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging#event-based-logging 18 | // constructor() { 19 | // super({ 20 | // log: [ 21 | // { 22 | // emit: 'event', 23 | // level: 'query', 24 | // }, 25 | // { 26 | // emit: 'event', 27 | // level: 'error', 28 | // }, 29 | // { 30 | // emit: 'event', 31 | // level: 'warn', 32 | // }, 33 | // ], 34 | // }); 35 | // } 36 | 37 | // async onModuleDestroy() { 38 | // await this.$disconnect(); 39 | // } 40 | 41 | // async onModuleInit() { 42 | // // Prisma Raw Query logging Config 43 | // Object.assign( 44 | // this, 45 | // this.$on('query', (event) => { 46 | // this.logger.debug( 47 | // `Query - [${event.timestamp}] - ${event.query}, Duration - ${event.duration}ms`, 48 | // ); 49 | // }), 50 | // this.$on('error', (event) => { 51 | // this.logger.error(`Error - [${event.timestamp}] - ${event.message}`); 52 | // }), 53 | // this.$on('warn', (event) => { 54 | // this.logger.warn(`Warn - [${event.timestamp}] - ${event.message}`); 55 | // }), 56 | // ); 57 | // await this.$connect(); 58 | // } 59 | // } 60 | -------------------------------------------------------------------------------- /src/modules/users/docs/controller.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | export const UserControllerDocs = applyDecorators(ApiTags('User API')); 5 | -------------------------------------------------------------------------------- /src/modules/users/docs/createUser.docs.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; 3 | import { CreateUserResponse } from '../response'; 4 | 5 | export const CreateUserDocs = applyDecorators( 6 | ApiOperation({ 7 | summary: '신규 User생성 및 JWT 토큰 발급', 8 | description: 9 | 'deviceToken이 등록된 적 없는 경우에만 유저를 생성합니다. 기존에 사용자가 없는 경우 있는 경우 모두 JWT Token을 발급합니다.', 10 | }), 11 | ApiResponse({ 12 | type: CreateUserResponse, 13 | }), 14 | ); 15 | -------------------------------------------------------------------------------- /src/modules/users/docs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.docs'; 2 | export * from './createUser.docs'; 3 | -------------------------------------------------------------------------------- /src/modules/users/dto/createUser.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class CreateUserDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | description: '모바일 기기 device token', 9 | }) 10 | readonly deviceToken: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/users/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createUser.dto'; 2 | -------------------------------------------------------------------------------- /src/modules/users/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt.guard'; 2 | -------------------------------------------------------------------------------- /src/modules/users/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { PublicRouteToken } from '@src/common'; 5 | import { JWT_STRATEGY_TOKEN } from '@src/modules/users/guards/strategy/strategy.token'; 6 | 7 | @Injectable() 8 | export class JwtGuard extends AuthGuard(JWT_STRATEGY_TOKEN) { 9 | constructor(private readonly reflector: Reflector) { 10 | super(); 11 | } 12 | 13 | async canActivate(context: ExecutionContext): Promise { 14 | // Handler 혹은 Class중 하나만 정의되어있다면 boolean으로 둘다 정의되어있다면 array([boolean,boolean])로 옵니다 15 | const publicMetadata = this.reflector.getAllAndMerge(PublicRouteToken, [ 16 | context.getClass(), 17 | context.getHandler(), 18 | ]); 19 | 20 | // Handler와 Class모두 메타데이터가 정의되어있는 경우 21 | if ( 22 | (Array.isArray(publicMetadata) && publicMetadata.length) || 23 | typeof publicMetadata === 'boolean' 24 | ) { 25 | try { 26 | return (await super.canActivate(context)) as boolean; 27 | } catch (err) { 28 | return true; 29 | } 30 | } 31 | return (await super.canActivate(context)) as boolean; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/users/guards/strategy/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt.strategy'; 2 | -------------------------------------------------------------------------------- /src/modules/users/guards/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { PassportStrategy } from '@nestjs/passport'; 5 | import { Model } from 'mongoose'; 6 | import { ExtractJwt, Strategy } from 'passport-jwt'; 7 | import { JwtPayload, ReqUserPayload } from '@src/common/types/type'; 8 | import { User } from '@src/infrastructure'; 9 | import { JWT_STRATEGY_TOKEN } from './strategy.token'; 10 | 11 | @Injectable() 12 | export class JwtStrategy extends PassportStrategy( 13 | Strategy, 14 | JWT_STRATEGY_TOKEN, 15 | ) { 16 | constructor( 17 | @InjectModel(User.name) private readonly userModel: Model, 18 | private readonly config: ConfigService, 19 | ) { 20 | super({ 21 | secretOrKey: config.get('JWT_SECRET'), 22 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 23 | }); 24 | } 25 | 26 | async validate(payload: JwtPayload): Promise { 27 | const user = await this.userModel.findById(payload.id); 28 | if (!user) { 29 | throw new UnauthorizedException('인증에 실패하였습니다.'); 30 | } 31 | return { 32 | id: payload.id, 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/users/guards/strategy/strategy.token.ts: -------------------------------------------------------------------------------- 1 | export const JWT_STRATEGY_TOKEN = 'jwt'; 2 | -------------------------------------------------------------------------------- /src/modules/users/response/createUser.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateUserResponse { 4 | @ApiProperty({ 5 | description: '사용자 ID입니다', 6 | }) 7 | userId: string; 8 | 9 | @ApiProperty({ 10 | description: 'Access Token 입니다', 11 | }) 12 | accessToken: string; 13 | 14 | constructor(userId: string, accessToken: string) { 15 | this.userId = userId; 16 | this.accessToken = accessToken; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/users/response/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createUser.response'; 2 | -------------------------------------------------------------------------------- /src/modules/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersController } from './users.controller'; 3 | 4 | describe('UsersController', () => { 5 | let controller: UsersController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UsersController], 10 | }).compile(); 11 | 12 | controller = module.get(UsersController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from '@nestjs/common'; 2 | import { CreateUserDocs, UserControllerDocs } from './docs'; 3 | import { CreateUserDto } from './dto'; 4 | import { CreateUserResponse } from './response'; 5 | import { UsersService } from './users.service'; 6 | 7 | @Controller('users') 8 | @UserControllerDocs 9 | export class UsersController { 10 | constructor(private readonly userService: UsersService) {} 11 | 12 | @Post() 13 | @CreateUserDocs 14 | async createUser(@Body() dto: CreateUserDto): Promise { 15 | const { userId, token } = await this.userService.createUser(dto); 16 | const response = new CreateUserResponse(userId, token); 17 | return response; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { Folder, FolderSchema, User, UserSchema } from '@src/infrastructure'; 4 | import { AuthModule } from '../auth/auth.module'; 5 | import { FolderRepository } from '../folders/folders.repository'; 6 | import { JwtStrategy } from './guards/strategy'; 7 | import { UsersController } from './users.controller'; 8 | import { UsersRepository } from './users.repository'; 9 | import { UsersService } from './users.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | AuthModule, 14 | MongooseModule.forFeature([ 15 | { name: User.name, schema: UserSchema }, 16 | { name: Folder.name, schema: FolderSchema }, 17 | ]), 18 | ], 19 | controllers: [UsersController], 20 | providers: [UsersService, UsersRepository, FolderRepository, JwtStrategy], 21 | }) 22 | export class UsersModule {} 23 | -------------------------------------------------------------------------------- /src/modules/users/users.pg.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { DataSource, Repository } from 'typeorm'; 3 | import { User } from '@src/infrastructure/database/entities/user.entity'; 4 | 5 | @Injectable() 6 | export class UsersRepository extends Repository { 7 | constructor(private dataSource: DataSource) { 8 | super(User, dataSource.createEntityManager()); 9 | } 10 | 11 | async findUserByDeviceToken(deviceToken: string) { 12 | const user = await this.findOne({ 13 | where: { 14 | deviceToken: deviceToken, 15 | }, 16 | }); 17 | return user; 18 | } 19 | 20 | async findOrCreate(deviceToken: string) { 21 | const newUser = await this.save({ 22 | deviceToken: deviceToken, 23 | }); 24 | 25 | return newUser; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/users/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { User } from '@src/infrastructure'; 5 | 6 | @Injectable() 7 | export class UsersRepository { 8 | constructor( 9 | @InjectModel(User.name) private readonly userModel: Model, 10 | ) {} 11 | 12 | async findUserByDeviceToken(deviceToken: string) { 13 | const user = await this.userModel 14 | .findOne({ 15 | deviceToken: deviceToken, 16 | }) 17 | .lean(); 18 | return user; 19 | } 20 | 21 | async findOrCreate(deviceToken: string) { 22 | const newUser = await this.userModel.create({ 23 | deviceToken: deviceToken, 24 | }); 25 | return newUser; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | 4 | describe('UsersService', () => { 5 | let service: UsersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UsersService], 10 | }).compile(); 11 | 12 | service = module.get(UsersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/modules/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtPayload } from 'src/common/types/type'; 3 | import { DEFAULT_FOLDER_NAME } from '@src/common/constant'; 4 | import { FolderType } from '@src/infrastructure/database/types/folder-type.enum'; 5 | import { AuthService } from '../auth/auth.service'; 6 | import { FolderRepository } from '../folders/folders.repository'; 7 | import { CreateUserDto } from './dto'; 8 | import { UsersRepository } from './users.repository'; 9 | 10 | @Injectable() 11 | export class UsersService { 12 | constructor( 13 | private readonly userRepository: UsersRepository, 14 | private readonly folderRepository: FolderRepository, 15 | private readonly authService: AuthService, 16 | ) {} 17 | 18 | async createUser( 19 | dto: CreateUserDto, 20 | ): Promise<{ userId: string; token: string }> { 21 | let user = await this.userRepository.findUserByDeviceToken(dto.deviceToken); 22 | if (!user) { 23 | // 새로운 user의 ID 24 | user = await this.userRepository.findOrCreate(dto.deviceToken); 25 | await this.folderRepository.create( 26 | user._id.toString(), 27 | DEFAULT_FOLDER_NAME, 28 | FolderType.DEFAULT, 29 | ); 30 | } 31 | 32 | const userId = user._id.toString(); 33 | // JWT Token Payload 34 | const tokenPayload: JwtPayload = { 35 | id: userId, 36 | }; 37 | // JWT Token 발급 38 | const token = await this.authService.issueAccessToken(tokenPayload); 39 | 40 | return { 41 | userId: userId, 42 | token: token, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | 3 | import { INestApplication } from '@nestjs/common'; 4 | import { Test, TestingModule } from '@nestjs/testing'; 5 | 6 | import { AppModule } from './../src/app.module'; 7 | 8 | describe('AppController (e2e)', () => { 9 | let app: INestApplication; 10 | 11 | beforeEach(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule], 14 | }).compile(); 15 | 16 | app = moduleFixture.createNestApplication(); 17 | await app.init(); 18 | }); 19 | 20 | it('/ (GET)', () => { 21 | return request(app.getHttpServer()) 22 | .get('/') 23 | .expect(200) 24 | .expect('Hello World!'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | "strict": true, 21 | "esModuleInterop": true, 22 | "paths": { 23 | "@src/*": ["./src/*"] 24 | } 25 | }, 26 | "exclude": ["./test", "./dist"] 27 | } 28 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import CopyPlugin from 'copy-webpack-plugin'; 2 | import * as path from 'path'; 3 | import * as webpack from 'webpack'; 4 | import { sentryWebpackPlugin } from '@sentry/webpack-plugin'; 5 | 6 | const swaggerUiModulePath = path.dirname(require.resolve('swagger-ui-dist')); 7 | 8 | module.exports = { 9 | entry: './src/handler.ts', 10 | mode: 'none', 11 | target: 'node', 12 | devtool: 'source-map', 13 | plugins: [ 14 | // sentryWebpackPlugin({ 15 | // org: 'mashup-linkit', 16 | // project: 'linkit-tracker', 17 | // authToken: process.env.SENTRY_AUTH_TOKEN, 18 | // }), 19 | new webpack.IgnorePlugin({ 20 | checkResource(resource) { 21 | const lazyImports = [ 22 | './swagger-ui-bundle.js', 23 | './swagger-ui-standalone-preset.js', 24 | 'class-validator', 25 | 'class-transformer/storage', 26 | '@nestjs/swagger', 27 | 'fastify-swagger', 28 | '@nestjs/microservices', 29 | '@nestjs/platform-fastify', 30 | '@nestjs/websockets/socket-module', 31 | '@nestjs/microservices/microservices-module', 32 | // mongodb optionals 33 | 'mongodb-client-encryption', 34 | 'bson-ext', 35 | 'kerberos', 36 | 'aws4', 37 | 'snappy', 38 | 'gcp-metadata', 39 | '@mongodb-js/zstd', 40 | 'snappy/package.json', 41 | ]; 42 | if (!lazyImports.includes(resource)) { 43 | return false; 44 | } 45 | try { 46 | require.resolve(resource); 47 | } catch (err) { 48 | return true; 49 | } 50 | return false; 51 | }, 52 | }), 53 | new CopyPlugin({ 54 | patterns: [ 55 | { 56 | from: `${swaggerUiModulePath}/swagger-ui.css`, 57 | to: 'swagger-ui.css', 58 | }, 59 | { 60 | from: `${swaggerUiModulePath}/swagger-ui-bundle.js`, 61 | to: 'swagger-ui-bundle.js', 62 | }, 63 | { 64 | from: `${swaggerUiModulePath}/swagger-ui-standalone-preset.js`, 65 | to: 'swagger-ui-standalone-preset.js', 66 | }, 67 | { 68 | from: `${swaggerUiModulePath}/favicon-32x32.png`, 69 | to: 'favicon-32x32.png', 70 | }, 71 | { 72 | from: `${swaggerUiModulePath}/favicon-16x16.png`, 73 | to: 'favicon-16x16.png', 74 | }, 75 | { 76 | from: `${swaggerUiModulePath}/oauth2-redirect.html`, 77 | to: 'src/oauth2-redirect.html', 78 | }, 79 | ], 80 | }), 81 | ], 82 | externals: { 83 | '@aws-sdk': '@aws-sdk', 84 | }, 85 | resolve: { 86 | extensions: ['.ts', '.js'], 87 | alias: { 88 | '@src': path.resolve(__dirname, 'src'), 89 | }, 90 | }, 91 | output: { 92 | path: path.resolve(__dirname, 'dist'), 93 | filename: 'index.js', 94 | libraryTarget: 'commonjs', 95 | }, 96 | module: { 97 | rules: [{ test: /\.ts$/, loader: 'swc-loader' }], 98 | }, 99 | stats: { 100 | warningsFilter: [ 101 | 'optional-require', 102 | 'load-package.util', 103 | 'load-adapter', 104 | () => false, 105 | ], 106 | }, 107 | }; 108 | -------------------------------------------------------------------------------- /worker-webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as webpack from 'webpack'; 3 | 4 | module.exports = { 5 | entry: './src/ai_handler.ts', 6 | mode: 'none', 7 | target: 'node', 8 | devtool: 'source-map', 9 | plugins: [ 10 | new webpack.IgnorePlugin({ 11 | checkResource(resource) { 12 | const lazyImports = [ 13 | './swagger-ui-bundle.js', 14 | './swagger-ui-standalone-preset.js', 15 | 'class-validator', 16 | 'class-transformer/storage', 17 | '@nestjs/swagger', 18 | 'fastify-swagger', 19 | '@nestjs/microservices', 20 | '@nestjs/platform-fastify', 21 | '@nestjs/websockets/socket-module', 22 | '@nestjs/microservices/microservices-module', 23 | // mongodb optionals 24 | 'mongodb-client-encryption', 25 | 'bson-ext', 26 | 'kerberos', 27 | 'aws4', 28 | 'snappy', 29 | 'gcp-metadata', 30 | '@mongodb-js/zstd', 31 | 'snappy/package.json', 32 | ]; 33 | if (!lazyImports.includes(resource)) { 34 | return false; 35 | } 36 | try { 37 | require.resolve(resource); 38 | } catch (err) { 39 | return true; 40 | } 41 | return false; 42 | }, 43 | }), 44 | ], 45 | externals: { 46 | '@aws-sdk': '@aws-sdk', 47 | }, 48 | resolve: { 49 | extensions: ['.ts', '.js'], 50 | alias: { 51 | '@src': path.resolve(__dirname, 'src'), 52 | }, 53 | }, 54 | output: { 55 | path: path.resolve(__dirname, 'worker-dist'), 56 | filename: 'index.js', 57 | libraryTarget: 'commonjs', 58 | }, 59 | module: { 60 | rules: [{ test: /\.ts$/, loader: 'swc-loader' }], 61 | }, 62 | stats: { 63 | warningsFilter: [ 64 | 'optional-require', 65 | 'load-package.util', 66 | 'load-adapter', 67 | () => false, 68 | ], 69 | }, 70 | }; 71 | --------------------------------------------------------------------------------