diff --git a/.github/workflows/auto-assign-merge.yml b/.github/workflows/auto-assign-merge.yml
new file mode 100644
index 00000000..6f72afed
--- /dev/null
+++ b/.github/workflows/auto-assign-merge.yml
@@ -0,0 +1,193 @@
+name: Auto Assign, Review, and Merge
+
+on:
+ pull_request:
+ types:
+ - opened
+ - labeled
+ - unlabeled
+ - review_requested
+ - review_request_removed
+ pull_request_review:
+ types:
+ - submitted
+
+jobs:
+ auto-assign:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v2
+ with:
+ node-version: "16"
+
+ - name: Assign PR creator as Assignee
+ uses: actions/github-script@v6
+ with:
+ script: |
+ const prNumber = context.payload.pull_request.number;
+ const currentAssignees = context.payload.pull_request.assignees.map(a => a.login);
+
+ // PR 작성자가 이미 담당자로 지정되어 있지 않은 경우에만 할당
+ if (!currentAssignees.includes(context.actor)) {
+ await github.rest.issues.addAssignees({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ assignees: [context.actor]
+ });
+ }
+
+ auto-reviewers:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ needs: auto-assign
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v2
+ with:
+ node-version: "16"
+
+ - name: Add reviewers based on labels
+ uses: actions/github-script@v6
+ with:
+ script: |
+ const prNumber = context.payload.pull_request.number;
+ const prAuthor = context.payload.pull_request.user.login;
+ const assignees = context.payload.pull_request.assignees.map(a => a.login);
+
+ // 현재 리뷰어 목록 가져오기
+ const currentReviewers = context.payload.pull_request.requested_reviewers
+ ? context.payload.pull_request.requested_reviewers.map(r => r.login)
+ : [];
+
+ const BE_reviewers = ['summersummerwhy', 'ezcolin2', 'Tolerblanc'];
+ const FE_reviewers = ['yewonJin', 'djk01281'];
+ const doc_reviewers = ['summersummerwhy', 'ezcolin2', 'Tolerblanc', 'yewonJin', 'djk01281'];
+
+ // Function to filter out assignees, PR author, and current reviewers
+ const filterReviewers = (reviewers) => {
+ return reviewers.filter(r =>
+ !assignees.includes(r) &&
+ r !== prAuthor &&
+ !currentReviewers.includes(r)
+ );
+ };
+
+ // Check the labels on the PR and assign appropriate reviewers
+ const labels = context.payload.pull_request.labels.map(label => label.name);
+ let reviewersToAdd = [];
+
+ if (labels.includes('🐧🚀😶🌫️ BE')) {
+ reviewersToAdd.push(...filterReviewers(BE_reviewers));
+ }
+ if (labels.includes('🐳🐣 FE')) {
+ reviewersToAdd.push(...filterReviewers(FE_reviewers));
+ }
+ if (labels.includes('📚 Documentation')) {
+ reviewersToAdd.push(...filterReviewers(doc_reviewers));
+ }
+
+ // Remove duplicates and limit the number of reviewers
+ reviewersToAdd = [...new Set(reviewersToAdd)];
+ const maxReviewers = 4; // 최대 리뷰어 수 제한
+ reviewersToAdd = reviewersToAdd.slice(0, maxReviewers);
+
+ // Request reviewers only if there are new reviewers to add
+ if (reviewersToAdd.length > 0) {
+ try {
+ await github.rest.pulls.requestReviewers({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber,
+ reviewers: reviewersToAdd
+ });
+ console.log(`Added reviewers: ${reviewersToAdd.join(', ')}`);
+ } catch (error) {
+ console.error('Failed to add reviewers:', error);
+ // 실패해도 워크플로우는 계속 진행
+ }
+ } else {
+ console.log('No new reviewers to add');
+ }
+
+ auto-merge:
+ if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ contents: write
+ steps:
+ - name: Merge and Close PR if Approved
+ uses: actions/github-script@v6
+ with:
+ script: |
+ const prNumber = context.payload.pull_request.number || context.payload.review.pull_request_number;
+
+ try {
+ // PR 정보 가져오기
+ const pr = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber
+ });
+
+ if (pr.data.merged) {
+ console.log('PR이 이미 머지되었습니다.');
+ return;
+ }
+
+ // 머지 가능 상태 확인
+ if (!pr.data.mergeable) {
+ console.log('PR에 충돌이 있습니다. 수동 확인이 필요합니다.');
+ core.setFailed('PR에 충돌이 있어 자동 머지를 진행할 수 없습니다.');
+ return;
+ }
+
+ // 모든 리뷰 확인
+ const reviews = await github.rest.pulls.listReviews({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber
+ });
+
+ // 각 리뷰어의 최신 리뷰 상태만 확인
+ const latestReviews = new Map();
+ reviews.data.forEach(review => {
+ latestReviews.set(review.user.login, review.state);
+ });
+
+ const hasRejection = Array.from(latestReviews.values()).includes('CHANGES_REQUESTED');
+ const approvalCount = Array.from(latestReviews.values()).filter(state => state === 'APPROVED').length;
+
+ if (!hasRejection && approvalCount >= 1) {
+ console.log('PR 머지를 시도합니다...');
+ await github.rest.pulls.merge({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber
+ });
+
+ // 브랜치 삭제
+ const branchName = pr.data.head.ref;
+ if (branchName !== 'main' && branchName !== 'master') {
+ await github.rest.git.deleteRef({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ ref: `heads/${branchName}`
+ });
+ }
+ } else {
+ console.log('머지 조건이 충족되지 않았습니다.');
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ core.setFailed(error.message);
+ }
diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml
new file mode 100644
index 00000000..a9133b5f
--- /dev/null
+++ b/.github/workflows/ci-pipeline.yml
@@ -0,0 +1,110 @@
+name: OctoDocs CI Pipeline
+
+on:
+ pull_request:
+ branches:
+ - develop
+ push:
+ branches:
+ - develop
+
+jobs:
+ setup:
+ runs-on: ubuntu-latest
+ outputs:
+ cache-key: ${{ steps.cache-backend-deps.outputs.cache-hit }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "23"
+
+ # turbo 의존성 캐시 설정
+ - name: Cache Yarn dependencies for backend
+ id: cache-deps
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
+
+ # turbo 의존성 설치
+ - name: Install backend dependencies
+ if: steps.cache-deps.outputs.cache-hit != 'true'
+ run: yarn install
+
+ lint:
+ runs-on: ubuntu-latest
+ needs: setup
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "23"
+
+ # 의존성 캐시 복원
+ - name: Restore Yarn dependencies for backend
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
+
+ # 백엔드 린트 실행
+ - name: Run lint
+ run: yarn lint
+ continue-on-error: true
+
+ # 백엔드 린트 경고 포스트
+ - name: Post backend lint warning if any
+ if: failure()
+ run: echo "⚠️ lint 실행 도중 경고가 발생했습니다. 확인해주세요."
+
+ build:
+ runs-on: ubuntu-latest
+ needs: setup
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "23"
+ # 루트 의존성 캐시 설정
+ - name: Cache Yarn dependencies for root
+ id: cache-deps
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
+
+ # 빌드 실행
+ - name: Run build
+ run: yarn build
+ test:
+ runs-on: ubuntu-latest
+ needs: [setup, build]
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "23"
+
+ # 의존성 캐시 복원
+ - name: Restore Yarn dependencies
+ uses: actions/cache@v3
+ with:
+ path: node_modules
+ key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}
+
+ # 테스트 실행
+ - name: Run tests
+ run: yarn test
diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml
new file mode 100644
index 00000000..e27d3331
--- /dev/null
+++ b/.github/workflows/develop.yml
@@ -0,0 +1,105 @@
+name: Create Directory on Remote Server
+on:
+ push:
+ branches:
+ - develop
+
+jobs:
+ env:
+ runs-on: ubuntu-latest
+
+ steps:
+ # 코드 체크아웃
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ # .env 파일 생성 후 붙여넣기
+ - name: Create .env file
+ run: |
+ echo "${{secrets.DEVELOPMENT_ENV}}" > ./.env
+
+ # sh 실행
+ - name: Connect to Remote Server and Run Commands
+ env:
+ REMOTE_HOST: ${{ secrets.REMOTE_DEV_IP }}
+ REMOTE_USER: ${{ secrets.REMOTE_USER }}
+ SSH_KEY: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ BRANCH_NAME: "develop"
+ run: |
+ mkdir ~/.ssh
+ echo "$SSH_KEY" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ ssh -o StrictHostKeyChecking=no $REMOTE_USER@$REMOTE_HOST << 'EOF'
+ DIR="/home/root/app/octodocs"
+
+ # Check if directory exists
+ if [ -d "$DIR" ]; then
+ echo "$DIR 디렉토리가 존재합니다. 최신 버전으로 업데이트 중..."
+ cd "$DIR"
+ git switch -c develop
+ git pull origin develop
+
+
+ else
+ echo "$DIR 디렉토리가 존재하지 않습니다. 클론 중..."
+ git clone https://github.com/boostcampwm-2024/web15-OctoDocs.git "$DIR"
+ cd "$DIR"
+ git switch -c develop
+ git pull origin develop
+ fi
+
+
+ # Install dependencies
+ echo "의존성 설치"
+ yarn -v
+ yarn install
+
+ # build
+ yarn build
+
+ # Check and kill existing process
+ EXISTING_PID=$(lsof -ti :3000)
+
+ if [ -n "$EXISTING_PID" ]; then
+ echo "3000 프로세스 종료 중...: $EXISTING_PID"
+ kill -9 "$EXISTING_PID"
+ echo "$EXISTING_PID 프로세스 종료"
+ else
+ echo "실행 중인 프로세스가 없습니다."
+ fi
+ EXISTING_PID=$(lsof -ti :1234)
+
+ if [ -n "$EXISTING_PID" ]; then
+ echo "1234 프로세스 종료 중...: $EXISTING_PID"
+ kill -9 "$EXISTING_PID"
+ echo "$EXISTING_PID 프로세스 종료"
+ else
+ echo "실행 중인 프로세스가 없습니다."
+ fi
+ EOF
+ # .env 파일 전송
+ - name: Copy .env to remote server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.REMOTE_DEV_IP }}
+ username: ${{ secrets.REMOTE_USER }}
+ key: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ source: ./.env
+ target: /home/root/app/octodocs/apps/backend
+ - name: Copy .env to remote server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.REMOTE_DEV_IP }}
+ username: ${{ secrets.REMOTE_USER }}
+ key: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ source: ./.env
+ target: /home/root/app/octodocs/apps/frontend
+
+ # yarn start
+ - name: yarn start
+ env:
+ REMOTE_HOST: ${{ secrets.REMOTE_DEV_IP }}
+ REMOTE_USER: ${{ secrets.REMOTE_USER }}
+ SSH_KEY: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ run: |
+ ssh -o StrictHostKeyChecking=no $REMOTE_USER@$REMOTE_HOST "nohup node /home/root/app/octodocs/apps/backend/dist/main.js > nohup.out 2> nohup.err < /dev/null &"
\ No newline at end of file
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 7cf6ba9d..81ce6dfa 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,12 +1,11 @@
name: Create Directory on Remote Server
-
on:
push:
branches:
- main
jobs:
- create-directory:
+ env:
runs-on: ubuntu-latest
steps:
@@ -14,42 +13,93 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- # Node.js 설치
- - name: Set up Node.js
- uses: actions/setup-node@v3
- with:
- node-version: "23"
- # 패키지 설치 및 React 빌드
- - name: Install dependencies and build
+ # .env 파일 생성 후 붙여넣기
+ - name: Create .env file
run: |
- cd frontend
- npm install
- npm run build
+ echo "${{secrets.PRODUCTION_ENV}}" > ./.env
- # aws cli를 통해 ncloud object storage 업로드
- - name: Configure AWS credentials
+ # sh 실행
+ - name: Connect to Remote Server and Run Commands
env:
- NCLOUD_ACCESS_KEY_ID: ${{ secrets.NCLOUD_ACCESS_KEY_ID }}
- NCLOUD_SECRET_ACCESS_KEY: ${{ secrets.NCLOUD_SECRET_ACCESS_KEY }}
+ REMOTE_HOST: ${{ secrets.REMOTE_PROD_IP }}
+ REMOTE_USER: ${{ secrets.REMOTE_USER }}
+ SSH_KEY: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ BRANCH_NAME: "main"
run: |
- aws configure set aws_access_key_id $NCLOUD_ACCESS_KEY_ID
- aws configure set aws_secret_access_key $NCLOUD_SECRET_ACCESS_KEY
- aws configure set region ap-northeast-2
- aws --endpoint-url=https://kr.object.ncloudstorage.com s3 cp ./frontend/dist s3://octodocs/ --recursive --debug
+ mkdir ~/.ssh
+ echo "$SSH_KEY" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ ssh -o StrictHostKeyChecking=no $REMOTE_USER@$REMOTE_HOST << 'EOF'
+ DIR="/home/root/app/octodocs"
+
+ # Check if directory exists
+ if [ -d "$DIR" ]; then
+ echo "$DIR 디렉토리가 존재합니다. 최신 버전으로 업데이트 중..."
+ cd "$DIR"
+ git switch -c main
+ git pull origin main
- # 패키지 설치 및 Nest.js 빌드
- - name: Install dependencies and build
- run: |
- cd backend
- npm install
- npm run build
- # 배포용 쉘 스크립트 파일 전송
- - name: Copy deploy.sh to remote server
- uses: appleboy/scp-action@v0.1.1
+ else
+ echo "$DIR 디렉토리가 존재하지 않습니다. 클론 중..."
+ git clone https://github.com/boostcampwm-2024/web15-OctoDocs.git "$DIR"
+ cd "$DIR"
+ git switch -c main
+ git pull origin main
+ fi
+
+
+ # Install dependencies
+ echo "의존성 설치"
+ yarn -v
+ yarn install
+
+ # build
+ yarn build
+
+ # Check and kill existing process
+ EXISTING_PID=$(lsof -ti :3000)
+
+ if [ -n "$EXISTING_PID" ]; then
+ echo "3000 프로세스 종료 중...: $EXISTING_PID"
+ kill -9 "$EXISTING_PID"
+ echo "$EXISTING_PID 프로세스 종료"
+ else
+ echo "실행 중인 프로세스가 없습니다."
+ fi
+ EXISTING_PID=$(lsof -ti :1234)
+
+ if [ -n "$EXISTING_PID" ]; then
+ echo "1234 프로세스 종료 중...: $EXISTING_PID"
+ kill -9 "$EXISTING_PID"
+ echo "$EXISTING_PID 프로세스 종료"
+ else
+ echo "실행 중인 프로세스가 없습니다."
+ fi
+ EOF
+ # .env 파일 전송
+ - name: Copy .env to remote server
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.REMOTE_PROD_IP }}
+ username: ${{ secrets.REMOTE_USER }}
+ key: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ source: ./.env
+ target: /home/root/app/octodocs/apps/backend
+ - name: Copy .env to remote server
+ uses: appleboy/scp-action@master
with:
- host: ${{ secrets.REMOTE_IP }}
+ host: ${{ secrets.REMOTE_PROD_IP }}
username: ${{ secrets.REMOTE_USER }}
key: ${{ secrets.REMOTE_PRIVATE_KEY }}
- source: ./deploy.sh
- target: /home/root/deploy.sh
+ source: ./.env
+ target: /home/root/app/octodocs/apps/frontend
+
+ # yarn start
+ - name: yarn start
+ env:
+ REMOTE_HOST: ${{ secrets.REMOTE_PROD_IP }}
+ REMOTE_USER: ${{ secrets.REMOTE_USER }}
+ SSH_KEY: ${{ secrets.REMOTE_PRIVATE_KEY }}
+ run: |
+ ssh -o StrictHostKeyChecking=no $REMOTE_USER@$REMOTE_HOST "nohup node /home/root/app/octodocs/apps/backend/dist/main.js > nohup.out 2> nohup.err < /dev/null &"
\ No newline at end of file
diff --git a/backend/.gitignore b/.gitignore
similarity index 72%
rename from backend/.gitignore
rename to .gitignore
index 319a2a6d..b15e8fb1 100644
--- a/backend/.gitignore
+++ b/.gitignore
@@ -1,10 +1,15 @@
# env
.env
.env*
+*.local
+.turbo
# compiled output
-/dist
-/node_modules
+*/dist
+*/node_modules
+dist
+node_modules
+dist-ssr
# Logs
logs
@@ -24,16 +29,23 @@ lerna-debug.log*
# IDEs and editors
/.idea
+.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
-!.vscode/extensions.json
\ No newline at end of file
+!.vscode/extensions.json
+db.sqlite
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 684d8274..00000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "files.associations": {
- "*.ttml": "xml",
- "*.ttss": "css"
- }
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index 0f2917bf..5c814335 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,45 @@
-[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fboostcampwm-2024%2Fweb15-OctoDocs&count_bg=%23FF9782&title_bg=%23231F20&icon=&icon_color=%23E7E7E7&title=views&edge_flat=false)](https://hits.seeyoufarm.com)
+![Sprint 33](https://github.com/user-attachments/assets/2b23184d-90ed-458d-9dc4-dab9579c1e48)
+
+🪝 [**배포 링크**](http://211.188.48.107:3000/)
+
+
+
+
+
+
+
+
+
+
+ ![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fboostcampwm-2024%2Fweb15-OctoDocs&count_bg=%23000000&title_bg=%23000000&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false) [![Group 83 (2)](https://github.com/user-attachments/assets/2d106d94-430c-47bc-a9e2-1f0026f76c2f)](https://github.com/boostcampwm-2024/web15-OctoDocs/wiki) [![Group 84 (2)](https://github.com/user-attachments/assets/b29b191b-8172-42a9-b541-40fdb8f165f3)](https://github.com/orgs/boostcampwm-2024/projects/120)
+
+
## 🐙 프로젝트 소개
-![project-intro](https://github.com/user-attachments/assets/0df1909c-436d-4516-a639-78c4088e9871)
+
+> “Notion을 쓰고 있는데, 문서끼리 관계 표현이 너무 어려워요…”
+>
+> “Obsidian으로 노트 정리를 잘 하고 있는데, 공유하기가 너무 불편해요…”
+
+이런 고민, 이제 **OctoDocs**로 해결해보세요!!
+
+- **실시간 협업**이 지원되는 **연결형 지식관리 도구**입니다.
+- **실시간 동시편집** 과 **마크다운** 형식 문서편집을 지원합니다.
+
+## 📢 핵심 기능
+
+![image](https://github.com/user-attachments/assets/4c0010db-d4a3-455f-ab26-03e04c85e46e)
+
+## 🛠️ 시스템 아키텍처
+
+v.241115
+
+![octodocs-architecture](https://github.com/user-attachments/assets/18461bff-25ad-439a-ada0-73f4ea37e4d7)
## 🧸 팀원 소개
| [J032_김동준](https://github.com/djk01281) | [J075_김현준](https://github.com/Tolerblanc) | [J097_민서진](https://github.com/summersummerwhy) | [J162_유성민](https://github.com/ezcolin2) | [J248_진예원](https://github.com/yewonJin) |
|:----------------------------------------:|:------------------------------------------:|:------------------------------------------------:|:----------------------------------------:|:----------------------------------------:|
-|
|
|
|
|
|
-| **INFJ** | **INFJ** | **INTP** | **INFP** | **ISTJ** |
-| **`FE`** | **`BE`** | **`BE`** | **`BE`** | **`FE`** |
+|
|
|
|
|
|
+| **INFJ** | **INFJ** | **INTP** | **INFP** | **ISTJ** |
+| **`FE`** | **`BE`** | **`BE`** | **`BE`** | **`FE`** |
diff --git a/backend/.eslintrc.js b/apps/backend/.eslintrc.js
similarity index 100%
rename from backend/.eslintrc.js
rename to apps/backend/.eslintrc.js
diff --git a/backend/.prettierrc b/apps/backend/.prettierrc
similarity index 100%
rename from backend/.prettierrc
rename to apps/backend/.prettierrc
diff --git a/backend/README.md b/apps/backend/README.md
similarity index 100%
rename from backend/README.md
rename to apps/backend/README.md
diff --git a/backend/nest-cli.json b/apps/backend/nest-cli.json
similarity index 100%
rename from backend/nest-cli.json
rename to apps/backend/nest-cli.json
diff --git a/backend/package.json b/apps/backend/package.json
similarity index 72%
rename from backend/package.json
rename to apps/backend/package.json
index a25fe78f..464a684e 100644
--- a/backend/package.json
+++ b/apps/backend/package.json
@@ -9,7 +9,7 @@
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
- "start:dev": "nest start --watch",
+ "dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
@@ -20,18 +20,41 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
+ "@aws-sdk/client-s3": "^3.693.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0",
+ "@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "*",
+ "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
+ "@nestjs/platform-socket.io": "^10.4.8",
+ "@nestjs/platform-ws": "^10.4.7",
+ "@nestjs/serve-static": "^4.0.2",
+ "@nestjs/swagger": "^8.0.5",
"@nestjs/typeorm": "^10.0.2",
+ "@nestjs/websockets": "^10.4.8",
+ "@types/multer": "^1.4.12",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
+ "lib0": "^0.2.98",
+ "node-ts-cache": "^4.4.0",
+ "node-ts-cache-storage-memory": "^4.4.0",
+ "passport": "^0.7.0",
+ "passport-kakao": "^1.0.1",
+ "passport-naver": "^1.0.6",
+ "path": "^0.12.7",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
+ "socket.io": "^4.8.1",
"sqlite3": "^5.1.7",
- "typeorm": "^0.3.20"
+ "typeorm": "^0.3.20",
+ "ws": "^8.14.2",
+ "y-prosemirror": "^1.2.12",
+ "y-protocols": "^1.0.6",
+ "y-socket.io": "^1.1.3",
+ "y-websocket": "^1.5.0",
+ "yjs": "^13.6.8"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
@@ -41,6 +64,7 @@
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^2.0.12",
+ "@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
diff --git a/backend/src/app.controller.spec.ts b/apps/backend/src/app.controller.spec.ts
similarity index 100%
rename from backend/src/app.controller.spec.ts
rename to apps/backend/src/app.controller.spec.ts
diff --git a/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts
similarity index 100%
rename from backend/src/app.controller.ts
rename to apps/backend/src/app.controller.ts
diff --git a/backend/src/app.module.ts b/apps/backend/src/app.module.ts
similarity index 61%
rename from backend/src/app.module.ts
rename to apps/backend/src/app.module.ts
index 16b0b65b..38d17ba3 100644
--- a/backend/src/app.module.ts
+++ b/apps/backend/src/app.module.ts
@@ -9,27 +9,41 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Page } from './page/page.entity';
import { Edge } from './edge/edge.entity';
import { Node } from './node/node.entity';
+import { User } from './user/user.entity';
+import { YjsModule } from './yjs/yjs.module';
+import * as path from 'path';
+import { ServeStaticModule } from '@nestjs/serve-static';
+import { UploadModule } from './upload/upload.module';
+import { AuthModule } from './auth/auth.module';
+import { UserModule } from './user/user.module';
@Module({
imports: [
+ ServeStaticModule.forRoot({
+ rootPath: path.join(__dirname, '..', '..', 'frontend', 'dist'),
+ }),
ConfigModule.forRoot({
isGlobal: true,
- envFilePath: '../.env', // * nest 디렉터리 기준
+ envFilePath: path.join(__dirname, '..', '.env'), // * nest 디렉터리 기준
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
+ inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'sqlite',
database: configService.get('DB_NAME'),
- entities: [Node, Page, Edge],
+ entities: [Node, Page, Edge, User],
logging: true,
synchronize: true,
}),
- inject: [ConfigService],
}),
NodeModule,
PageModule,
EdgeModule,
+ YjsModule,
+ UploadModule,
+ AuthModule,
+ UserModule,
],
controllers: [AppController],
providers: [AppService],
diff --git a/backend/src/app.service.ts b/apps/backend/src/app.service.ts
similarity index 100%
rename from backend/src/app.service.ts
rename to apps/backend/src/app.service.ts
diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts
new file mode 100644
index 00000000..cd50d500
--- /dev/null
+++ b/apps/backend/src/auth/auth.controller.spec.ts
@@ -0,0 +1,80 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { AuthController } from './auth.controller';
+import { AuthService } from './auth.service';
+import { JwtService } from '@nestjs/jwt';
+import { InvalidTokenException } from '../exception/invalid.exception';
+// import { LoginRequiredException } from '../exception/login.exception';
+// TODO: 테스트 코드 개선
+describe('AuthController', () => {
+ let authController: AuthController;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [AuthController],
+ providers: [
+ AuthService,
+ JwtService,
+ {
+ provide: AuthService,
+ useValue: {
+ findUser: jest.fn(),
+ createUser: jest.fn(),
+ findUserById: jest.fn(),
+ },
+ },
+ {
+ provide: JwtService,
+ useValue: {
+ sign: jest.fn().mockReturnValue('test-token'),
+ verify: jest.fn((token: string) => {
+ if (token === 'invalid-token') {
+ throw new InvalidTokenException();
+ }
+ return { sub: 1, provider: 'naver' };
+ }),
+ },
+ },
+ ],
+ }).compile();
+
+ authController = module.get(AuthController);
+ });
+
+ it('컨트롤러 클래스가 정상적으로 인스턴스화된다.', () => {
+ expect(authController).toBeDefined();
+ });
+
+ describe('getProfile', () => {
+ it('JWT 토큰이 유효한 경우 profile을 return한다.', async () => {
+ const req = {
+ user: { sub: 1, email: 'test@naver.com', provider: 'naver' },
+ } as any;
+ const result = await authController.getProfile(req);
+ expect(result).toEqual({
+ message: '인증된 사용자 정보',
+ user: req.user,
+ });
+ });
+
+ // it('JWT 토큰이 유효가지 않은 경우 InvalidTokenException을 throw한다.', async () => {
+ // const req = {
+ // headers: { authorization: 'Bearer invalid-token' },
+ // user: undefined,
+ // } as any;
+ // try {
+ // await authController.getProfile(req);
+ // } catch (error) {
+ // expect(error).toBeInstanceOf(InvalidTokenException);
+ // }
+ // });
+
+ // it('JWT 토큰이 없는 경우 LoginRequiredException을 throw한다.', async () => {
+ // const req = { headers: {}, user: undefined } as any;
+ // try {
+ // await authController.getProfile(req);
+ // } catch (error) {
+ // expect(error).toBeInstanceOf(LoginRequiredException);
+ // }
+ // });
+ });
+});
diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts
new file mode 100644
index 00000000..e9507aba
--- /dev/null
+++ b/apps/backend/src/auth/auth.controller.ts
@@ -0,0 +1,69 @@
+import { Controller, Get, UseGuards, Req } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+import { AuthService } from './auth.service';
+import { JwtService } from '@nestjs/jwt';
+import { JwtAuthGuard } from './guards/jwt-auth.guard';
+
+@Controller('auth')
+export class AuthController {
+ constructor(
+ private readonly authService: AuthService,
+ private readonly jwtService: JwtService,
+ ) {}
+
+ @Get('naver')
+ @UseGuards(AuthGuard('naver'))
+ async naverLogin() {
+ // 네이버 로그인 페이지로 리디렉션
+ // Passport가 리디렉션 처리
+ }
+
+ @Get('naver/callback')
+ @UseGuards(AuthGuard('naver'))
+ async naverCallback(@Req() req) {
+ // 네이버 인증 후 사용자 정보 반환
+ const user = req.user;
+ // TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
+ const payload = { sub: user.id, provider: user.provider };
+ const token = this.jwtService.sign(payload);
+ return {
+ message: '네이버 로그인 성공',
+ user,
+ accessToken: token,
+ };
+ }
+
+ @Get('kakao')
+ @UseGuards(AuthGuard('kakao'))
+ async kakaoLogin() {
+ // 카카오 로그인 페이지로 리디렉션
+ // Passport가 리디렉션 처리
+ }
+
+ @Get('kakao/callback')
+ @UseGuards(AuthGuard('kakao'))
+ async kakaoCallback(@Req() req) {
+ // 카카오 인증 후 사용자 정보 반환
+ const user = req.user;
+ // TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
+ const payload = { sub: user.id, provider: user.provider };
+ const token = this.jwtService.sign(payload);
+ return {
+ message: '카카오 로그인 성공',
+ user,
+ accessToken: token,
+ };
+ }
+
+ // Example: 로그인한 사용자만 접근할 수 있는 엔드포인트
+ // auth/profile
+ @Get('profile')
+ @UseGuards(JwtAuthGuard) // JWT 인증 검사
+ async getProfile(@Req() req) {
+ // JWT 토큰을 검증하고 사용자 정보 반환
+ return {
+ message: '인증된 사용자 정보',
+ user: req.user,
+ };
+ }
+}
diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts
new file mode 100644
index 00000000..cddd162e
--- /dev/null
+++ b/apps/backend/src/auth/auth.module.ts
@@ -0,0 +1,34 @@
+import { Module } from '@nestjs/common';
+import { UserRepository } from '../user/user.repository';
+import { UserModule } from 'src/user/user.module';
+import { AuthService } from './auth.service';
+import { AuthController } from './auth.controller';
+import { NaverStrategy } from './strategies/naver.strategy';
+import { KakaoStrategy } from './strategies/kakao.strategy';
+import { JwtModule } from '@nestjs/jwt';
+import { JwtAuthGuard } from './guards/jwt-auth.guard';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+
+@Module({
+ imports: [
+ UserModule,
+ ConfigModule.forRoot({ isGlobal: true }),
+ JwtModule.registerAsync({
+ imports: [ConfigModule],
+ inject: [ConfigService],
+ useFactory: async (configService: ConfigService) => ({
+ secret: configService.get('JWT_SECRET'),
+ signOptions: { expiresIn: '1h' },
+ }),
+ }),
+ ],
+ controllers: [AuthController],
+ providers: [
+ AuthService,
+ NaverStrategy,
+ KakaoStrategy,
+ UserRepository,
+ JwtAuthGuard,
+ ],
+})
+export class AuthModule {}
diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts
new file mode 100644
index 00000000..4b42bf0e
--- /dev/null
+++ b/apps/backend/src/auth/auth.service.spec.ts
@@ -0,0 +1,78 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { AuthService } from './auth.service';
+import { UserRepository } from '../user/user.repository';
+import { CreateUserDto } from './dto/createUser.dto';
+import { User } from '../user/user.entity';
+
+describe('AuthService', () => {
+ let authService: AuthService;
+ let userRepository: UserRepository;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ AuthService,
+ {
+ provide: UserRepository,
+ useValue: {
+ findOne: jest.fn(),
+ create: jest.fn(),
+ save: jest.fn(),
+ },
+ },
+ ],
+ }).compile();
+
+ authService = module.get(AuthService);
+ userRepository = module.get(UserRepository);
+ });
+
+ it('서비스 클래스가 정상적으로 인스턴스화된다.', () => {
+ expect(authService).toBeDefined();
+ });
+
+ describe('findUser', () => {
+ it('id에 해당하는 사용자를 찾아 성공적으로 반환한다.', async () => {
+ const dto: CreateUserDto = {
+ providerId: 'test-provider-id',
+ provider: 'naver',
+ email: 'test@naver.com',
+ };
+ const user = new User();
+ jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
+
+ const result = await authService.findUser(dto);
+ expect(result).toEqual(user);
+ });
+
+ it('id에 해당하는 사용자가 없을 경우 null을 return한다.', async () => {
+ jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
+ const dto: CreateUserDto = {
+ providerId: 'unknown-id',
+ provider: 'naver',
+ email: 'unknown@naver.com',
+ };
+
+ const result = await authService.findUser(dto);
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('createUser', () => {
+ it('사용자를 성공적으로 생성한다', async () => {
+ const dto: CreateUserDto = {
+ providerId: 'new-provider-id',
+ provider: 'naver',
+ email: 'new@naver.com',
+ };
+ const user = new User();
+ jest.spyOn(userRepository, 'create').mockReturnValue(user);
+ jest.spyOn(userRepository, 'save').mockResolvedValue(user);
+
+ const result = await authService.createUser(dto);
+ expect(result).toEqual(user);
+ expect(userRepository.create).toHaveBeenCalledWith(dto);
+ expect(userRepository.save).toHaveBeenCalledWith(user);
+ });
+ });
+});
diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts
new file mode 100644
index 00000000..1524b102
--- /dev/null
+++ b/apps/backend/src/auth/auth.service.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@nestjs/common';
+import { UserRepository } from '../user/user.repository';
+import { User } from '../user/user.entity';
+import { CreateUserDto } from './dto/createUser.dto';
+
+@Injectable()
+export class AuthService {
+ constructor(private readonly userRepository: UserRepository) {}
+
+ async findUser(dto: CreateUserDto): Promise {
+ const { providerId, provider } = dto;
+
+ const user = await this.userRepository.findOne({
+ where: { providerId, provider },
+ });
+
+ return user;
+ }
+
+ async createUser(dto: CreateUserDto): Promise {
+ const user = this.userRepository.create(dto);
+ return this.userRepository.save(user);
+ }
+}
diff --git a/apps/backend/src/auth/dto/createUser.dto.ts b/apps/backend/src/auth/dto/createUser.dto.ts
new file mode 100644
index 00000000..8e53b4fa
--- /dev/null
+++ b/apps/backend/src/auth/dto/createUser.dto.ts
@@ -0,0 +1,28 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsEmail, IsIn } from 'class-validator';
+
+export class CreateUserDto {
+ @IsString()
+ @ApiProperty({
+ example: 'abc1234',
+ description: '사용자의 카카오/네이버 아이디',
+ })
+ providerId: string;
+
+ @IsString()
+ @IsIn(['naver', 'kakao'], {
+ message: 'provider는 naver 또는 kakao 중 하나여야 합니다.',
+ })
+ @ApiProperty({
+ example: 'naver',
+ description: '연동되는 서비스: 네이버/카카오',
+ })
+ provider: string;
+
+ @IsEmail()
+ @ApiProperty({
+ example: 'abc1234@naver.com',
+ description: '사용자의 이메일 주소(카카오, 네이버 외에도) 가능',
+ })
+ email: string;
+}
diff --git a/apps/backend/src/auth/guards/jwt-auth.guard.ts b/apps/backend/src/auth/guards/jwt-auth.guard.ts
new file mode 100644
index 00000000..e74c45a0
--- /dev/null
+++ b/apps/backend/src/auth/guards/jwt-auth.guard.ts
@@ -0,0 +1,32 @@
+import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { LoginRequiredException } from '../../exception/login.exception';
+import { InvalidTokenException } from '../../exception/invalid.exception';
+
+@Injectable()
+export class JwtAuthGuard implements CanActivate {
+ constructor(private readonly jwtService: JwtService) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+ const authorizationHeader = request.headers['authorization'];
+
+ if (!authorizationHeader) {
+ // console.log('Authorization header missing');
+ throw new LoginRequiredException();
+ }
+
+ const token = authorizationHeader.split(' ')[1];
+
+ try {
+ const decodedToken = this.jwtService.verify(token, {
+ secret: process.env.JWT_SECRET,
+ });
+ request.user = decodedToken;
+ return true;
+ } catch (error) {
+ // console.log('Invalid token');
+ throw new InvalidTokenException();
+ }
+ }
+}
diff --git a/apps/backend/src/auth/strategies/kakao.strategy.ts b/apps/backend/src/auth/strategies/kakao.strategy.ts
new file mode 100644
index 00000000..01e1392d
--- /dev/null
+++ b/apps/backend/src/auth/strategies/kakao.strategy.ts
@@ -0,0 +1,31 @@
+// src/auth/strategies/kakao.strategy.ts
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { Profile, Strategy } from 'passport-kakao';
+import { AuthService } from '../auth.service';
+import { CreateUserDto } from '../dto/createUser.dto';
+
+@Injectable()
+export class KakaoStrategy extends PassportStrategy(Strategy, 'kakao') {
+ constructor(private authService: AuthService) {
+ super({
+ clientID: process.env.KAKAO_CLIENT_ID,
+ clientSecret: process.env.KAKAO_CLIENT_SECRET,
+ callbackURL: process.env.KAKAO_CALLBACK_URL,
+ });
+ }
+
+ async validate(accessToken: string, refreshToken: string, profile: Profile) {
+ // 카카오 인증 이후 사용자 정보 처리
+ const createUserDto: CreateUserDto = {
+ providerId: profile.id,
+ provider: 'kakao',
+ email: profile._json.kakao_account.email,
+ };
+ let user = await this.authService.findUser(createUserDto);
+ if (!user) {
+ user = await this.authService.createUser(createUserDto);
+ }
+ return user; // req.user로 반환
+ }
+}
diff --git a/apps/backend/src/auth/strategies/naver.strategy.ts b/apps/backend/src/auth/strategies/naver.strategy.ts
new file mode 100644
index 00000000..177ad5ce
--- /dev/null
+++ b/apps/backend/src/auth/strategies/naver.strategy.ts
@@ -0,0 +1,31 @@
+// src/auth/strategies/naver.strategy.ts
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { Profile, Strategy } from 'passport-naver';
+import { AuthService } from '../auth.service';
+import { CreateUserDto } from '../dto/createUser.dto';
+
+@Injectable()
+export class NaverStrategy extends PassportStrategy(Strategy, 'naver') {
+ constructor(private authService: AuthService) {
+ super({
+ clientID: process.env.NAVER_CLIENT_ID, // 환경 변수로 관리
+ clientSecret: process.env.NAVER_CLIENT_SECRET,
+ callbackURL: process.env.NAVER_CALLBACK_URL,
+ });
+ }
+
+ async validate(accessToken: string, refreshToken: string, profile: Profile) {
+ // 네이버 인증 이후 사용자 정보 처리
+ const createUserDto: CreateUserDto = {
+ providerId: profile.id,
+ provider: 'naver',
+ email: profile._json.email,
+ };
+ let user = await this.authService.findUser(createUserDto);
+ if (!user) {
+ user = await this.authService.createUser(createUserDto);
+ }
+ return user; // req.user로 반환
+ }
+}
diff --git a/apps/backend/src/edge/dtos/createEdge.dto.ts b/apps/backend/src/edge/dtos/createEdge.dto.ts
new file mode 100644
index 00000000..c60cd810
--- /dev/null
+++ b/apps/backend/src/edge/dtos/createEdge.dto.ts
@@ -0,0 +1,18 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsNumber } from 'class-validator';
+
+export class CreateEdgeDto {
+ @IsNumber()
+ @ApiProperty({
+ example: 1,
+ description: '출발 노드의 ID',
+ })
+ fromNode: number;
+
+ @IsNumber()
+ @ApiProperty({
+ example: 1,
+ description: '도착 노드의 ID',
+ })
+ toNode: number;
+}
diff --git a/apps/backend/src/edge/dtos/findEdgesResponse.dto.ts b/apps/backend/src/edge/dtos/findEdgesResponse.dto.ts
new file mode 100644
index 00000000..dca101bf
--- /dev/null
+++ b/apps/backend/src/edge/dtos/findEdgesResponse.dto.ts
@@ -0,0 +1,25 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsArray } from 'class-validator';
+import { Edge } from '../edge.entity';
+
+export class FindEdgesResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+
+ @ApiProperty({
+ example: [
+ {
+ id: 1,
+ fromNode: 2,
+ toNode: 7,
+ },
+ ],
+ description: '모든 Edge 배열',
+ })
+ @IsArray()
+ nodes: Partial[];
+}
diff --git a/apps/backend/src/edge/dtos/messageResponse.dto.ts b/apps/backend/src/edge/dtos/messageResponse.dto.ts
new file mode 100644
index 00000000..555c4329
--- /dev/null
+++ b/apps/backend/src/edge/dtos/messageResponse.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString } from 'class-validator';
+
+export class MessageResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+}
diff --git a/apps/backend/src/edge/edge.controller.spec.ts b/apps/backend/src/edge/edge.controller.spec.ts
new file mode 100644
index 00000000..7f4b370a
--- /dev/null
+++ b/apps/backend/src/edge/edge.controller.spec.ts
@@ -0,0 +1,120 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { EdgeController } from './edge.controller';
+import { EdgeService } from './edge.service';
+import { CreateEdgeDto } from './dtos/createEdge.dto';
+import { EdgeResponseMessage } from './edge.controller';
+import { EdgeNotFoundException } from '../exception/edge.exception';
+import { Edge } from './edge.entity';
+import { Node } from '../node/node.entity';
+
+describe('EdgeController', () => {
+ let controller: EdgeController;
+ let edgeService: EdgeService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [EdgeController],
+ providers: [
+ {
+ provide: EdgeService,
+ useValue: {
+ createEdge: jest.fn(),
+ deleteEdge: jest.fn(),
+ findEdges: jest.fn(),
+ },
+ },
+ ],
+ }).compile();
+
+ controller = module.get(EdgeController);
+ edgeService = module.get(EdgeService);
+ });
+
+ it('컨트롤러 클래스가 정상적으로 인스턴스화된다.', () => {
+ expect(controller).toBeDefined();
+ });
+
+ describe('createEdge', () => {
+ it('엣지가 성공적으로 만들어진다', async () => {
+ const dto: CreateEdgeDto = { fromNode: 1, toNode: 3 };
+ const expectedResponse = {
+ message: EdgeResponseMessage.EDGE_CREATED,
+ };
+
+ jest.spyOn(edgeService, 'createEdge').mockResolvedValue(undefined);
+ const result = await controller.createEdge(dto);
+
+ expect(edgeService.createEdge).toHaveBeenCalledWith(dto);
+ expect(result).toEqual(expectedResponse);
+ });
+ });
+
+ describe('deleteEdge', () => {
+ it('id에 해당하는 엣지를 찾아 삭제한다.', async () => {
+ const id = 2;
+ const expectedResponse = {
+ message: EdgeResponseMessage.EDGE_DELETED,
+ };
+
+ const result = await controller.deleteEdge(id);
+
+ expect(edgeService.deleteEdge).toHaveBeenCalledWith(id);
+ expect(result).toEqual(expectedResponse);
+ });
+
+ it('id에 해당하는 엣지가 존재하지 않으면 NodeNotFoundException을 throw한다.', async () => {
+ jest
+ .spyOn(edgeService, 'deleteEdge')
+ .mockRejectedValue(new EdgeNotFoundException());
+
+ await expect(controller.deleteEdge(1)).rejects.toThrow(
+ EdgeNotFoundException,
+ );
+ });
+ });
+
+ describe('findEdges', () => {
+ it('모든 엣지 목록을 반환한다.', async () => {
+ const node3 = {
+ id: 3,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const node4 = {
+ id: 4,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const node5 = {
+ id: 5,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+
+ const expectedEdges = [
+ { id: 1, fromNode: node3, toNode: node5 },
+ { id: 2, fromNode: node3, toNode: node4 },
+ ] as Edge[];
+ node3.outgoingEdges = [];
+
+ jest.spyOn(edgeService, 'findEdges').mockResolvedValue(expectedEdges);
+
+ await expect(controller.findEdges()).resolves.toEqual({
+ message: EdgeResponseMessage.EDGE_ALL_RETURNED,
+ edges: expectedEdges,
+ });
+ });
+ });
+});
diff --git a/apps/backend/src/edge/edge.controller.ts b/apps/backend/src/edge/edge.controller.ts
new file mode 100644
index 00000000..4ce7b75f
--- /dev/null
+++ b/apps/backend/src/edge/edge.controller.ts
@@ -0,0 +1,67 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Delete,
+ Param,
+ Body,
+ HttpCode,
+ HttpStatus,
+ ParseIntPipe,
+} from '@nestjs/common';
+import { EdgeService } from './edge.service';
+import { CreateEdgeDto } from './dtos/createEdge.dto';
+import { ApiOperation, ApiResponse } from '@nestjs/swagger';
+import { MessageResponseDto } from './dtos/messageResponse.dto';
+import { FindEdgesResponseDto } from './dtos/findEdgesResponse.dto';
+
+export enum EdgeResponseMessage {
+ EDGE_ALL_RETURNED = '모든 엣지를 가져왔습니다.',
+ EDGE_CREATED = '엣지를 생성했습니다.',
+ EDGE_DELETED = '엣지를 삭제했습니다.',
+}
+
+@Controller('edge')
+export class EdgeController {
+ constructor(private readonly edgeService: EdgeService) {}
+
+ @ApiResponse({
+ type: FindEdgesResponseDto,
+ })
+ @ApiOperation({
+ summary: '모든 엣지 정보를 가져옵니다.',
+ })
+ @Get('/')
+ @HttpCode(HttpStatus.OK)
+ async findEdges() {
+ const edges = await this.edgeService.findEdges();
+ return {
+ message: EdgeResponseMessage.EDGE_ALL_RETURNED,
+ edges: edges,
+ };
+ }
+
+ @ApiResponse({ type: MessageResponseDto })
+ @ApiOperation({ summary: '엣지를 생성합니다.' })
+ @Post('/')
+ @HttpCode(HttpStatus.CREATED)
+ async createEdge(@Body() body: CreateEdgeDto) {
+ await this.edgeService.createEdge(body);
+ return {
+ message: EdgeResponseMessage.EDGE_CREATED,
+ };
+ }
+
+ @ApiResponse({ type: MessageResponseDto })
+ @ApiOperation({ summary: '엣지를 삭제합니다.' })
+ @Delete('/:id')
+ @HttpCode(HttpStatus.OK)
+ async deleteEdge(
+ @Param('id', ParseIntPipe) id: number,
+ ): Promise<{ message: string }> {
+ await this.edgeService.deleteEdge(id);
+ return {
+ message: EdgeResponseMessage.EDGE_DELETED,
+ };
+ }
+}
diff --git a/backend/src/edge/edge.entity.ts b/apps/backend/src/edge/edge.entity.ts
similarity index 75%
rename from backend/src/edge/edge.entity.ts
rename to apps/backend/src/edge/edge.entity.ts
index b5f52535..50541f37 100644
--- a/backend/src/edge/edge.entity.ts
+++ b/apps/backend/src/edge/edge.entity.ts
@@ -2,7 +2,7 @@
import {
Entity,
PrimaryGeneratedColumn,
- Column,
+ // Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
@@ -21,15 +21,9 @@ export class Edge {
@JoinColumn({ name: 'to_node_id' })
toNode: Node;
- @Column()
- fromPoint: string;
+ // @Column({ nullable: true })
+ // type: string;
- @Column()
- toPoint: string;
-
- @Column({ nullable: true })
- type: string;
-
- @Column({ nullable: true })
- color: string;
+ // @Column({ nullable: true })
+ // color: string;
}
diff --git a/backend/src/edge/edge.module.ts b/apps/backend/src/edge/edge.module.ts
similarity index 63%
rename from backend/src/edge/edge.module.ts
rename to apps/backend/src/edge/edge.module.ts
index 0d3db8c2..d9848dd5 100644
--- a/backend/src/edge/edge.module.ts
+++ b/apps/backend/src/edge/edge.module.ts
@@ -1,13 +1,15 @@
-import { Module } from '@nestjs/common';
+import { Module, forwardRef } from '@nestjs/common';
import { EdgeService } from './edge.service';
import { EdgeController } from './edge.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Edge } from './edge.entity';
import { EdgeRepository } from './edge.repository';
+import { NodeModule } from 'src/node/node.module';
@Module({
- imports: [TypeOrmModule.forFeature([Edge])],
+ imports: [TypeOrmModule.forFeature([Edge]), forwardRef(() => NodeModule)],
controllers: [EdgeController],
providers: [EdgeService, EdgeRepository],
+ exports: [EdgeService]
})
export class EdgeModule {}
diff --git a/backend/src/edge/edge.repository.ts b/apps/backend/src/edge/edge.repository.ts
similarity index 68%
rename from backend/src/edge/edge.repository.ts
rename to apps/backend/src/edge/edge.repository.ts
index bb44fe94..94a76c61 100644
--- a/backend/src/edge/edge.repository.ts
+++ b/apps/backend/src/edge/edge.repository.ts
@@ -1,10 +1,11 @@
import { DataSource, Repository } from 'typeorm';
import { Edge } from './edge.entity';
import { Injectable } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
@Injectable()
export class EdgeRepository extends Repository {
- constructor(private dataSource: DataSource) {
+ constructor(@InjectDataSource() private dataSource: DataSource) {
super(Edge, dataSource.createEntityManager());
}
}
diff --git a/apps/backend/src/edge/edge.service.spec.ts b/apps/backend/src/edge/edge.service.spec.ts
new file mode 100644
index 00000000..a08bed60
--- /dev/null
+++ b/apps/backend/src/edge/edge.service.spec.ts
@@ -0,0 +1,192 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { EdgeService } from './edge.service';
+import { EdgeRepository } from './edge.repository';
+import { NodeRepository } from '../node/node.repository';
+import { CreateEdgeDto } from './dtos/createEdge.dto';
+import { Edge } from './edge.entity';
+import { Node } from '../node/node.entity';
+import { EdgeNotFoundException } from '../exception/edge.exception';
+
+describe('EdgeService', () => {
+ let service: EdgeService;
+ let edgeRepository: jest.Mocked;
+ let nodeRepository: jest.Mocked;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ EdgeService,
+ {
+ provide: EdgeRepository,
+ useValue: {
+ create: jest.fn(),
+ save: jest.fn(),
+ delete: jest.fn(),
+ findOneBy: jest.fn(),
+ find: jest.fn(),
+ },
+ },
+ {
+ provide: NodeRepository,
+ useValue: {
+ save: jest.fn(),
+ findOneBy: jest.fn(),
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(EdgeService);
+ edgeRepository = module.get(EdgeRepository);
+ nodeRepository = module.get(NodeRepository);
+ });
+
+ it('서비스 클래스가 정상적으로 인스턴스화된다.', () => {
+ expect(service).toBeDefined();
+ });
+
+ describe('createEdge', () => {
+ it('새로운 엣지를 만들어 노드와 노드를 연결하는 연결한다.', async () => {
+ const dto: CreateEdgeDto = { fromNode: 3, toNode: 5 };
+ const fromNode = {
+ id: 3,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const toNode = {
+ id: 5,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const edge = {
+ id: 1,
+ fromNode: fromNode,
+ toNode: toNode,
+ } as Edge;
+
+ jest
+ .spyOn(nodeRepository, 'findOneBy')
+ .mockResolvedValueOnce(fromNode) // 첫 번째 호출: fromNode
+ .mockResolvedValueOnce(toNode); // 두 번째 호출: toNode
+ jest.spyOn(edgeRepository, 'save').mockResolvedValue(edge);
+
+ const result = await service.createEdge(dto);
+
+ expect(result).toEqual(edge);
+ expect(edgeRepository.save).toHaveBeenCalledTimes(1);
+ expect(nodeRepository.findOneBy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('deleteEdge', () => {
+ it('엣지를 성공적으로 삭제한다.', async () => {
+ jest
+ .spyOn(edgeRepository, 'delete')
+ .mockResolvedValue({ affected: true } as any);
+ jest.spyOn(edgeRepository, 'findOneBy').mockResolvedValue(new Edge());
+
+ await service.deleteEdge(1);
+
+ expect(edgeRepository.delete).toHaveBeenCalledWith(1);
+ });
+
+ it('삭제할 엣지가 존재하지 않으면 EdgeNotFoundException을 throw한다.', async () => {
+ jest
+ .spyOn(edgeRepository, 'delete')
+ .mockResolvedValue({ affected: false } as any);
+ await expect(service.deleteEdge(1)).rejects.toThrow(
+ EdgeNotFoundException,
+ );
+ });
+ });
+
+ describe('findEdges', () => {
+ it('존재하는 모든 엣지를 반환한다.', async () => {
+ const node3 = {
+ id: 3,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const node4 = {
+ id: 4,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const node5 = {
+ id: 5,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+ const node7 = {
+ id: 7,
+ x: 0,
+ y: 0,
+ title: 'Node Title',
+ page: null,
+ outgoingEdges: [],
+ incomingEdges: [],
+ } as Node;
+
+ const expectedEdgeList = [
+ {
+ id: 1,
+ fromNode: node3,
+ toNode: node5,
+ } as Edge,
+ {
+ id: 2,
+ fromNode: node3,
+ toNode: node4,
+ } as Edge,
+ {
+ id: 3,
+ fromNode: node3,
+ toNode: node7,
+ } as Edge,
+ ];
+
+ jest.spyOn(edgeRepository, 'find').mockResolvedValue(expectedEdgeList);
+ const result = await service.findEdges();
+ expect(result).toEqual(expectedEdgeList);
+ expect(edgeRepository.find).toHaveBeenCalledTimes(1);
+ expect(edgeRepository.find).toHaveBeenCalledWith({
+ relations: ['fromNode', 'toNode'],
+ select: {
+ id: true,
+ fromNode: {
+ id: true,
+ },
+ toNode: {
+ id: true,
+ },
+ },
+ });
+ });
+
+ it('엣지가 없을 경우, 빈 배열을 던진다.', async () => {
+ jest.spyOn(edgeRepository, 'find').mockResolvedValue([]);
+ const result = await service.findEdges();
+ expect(result).toEqual([]);
+ });
+ });
+});
diff --git a/apps/backend/src/edge/edge.service.ts b/apps/backend/src/edge/edge.service.ts
new file mode 100644
index 00000000..29e673e4
--- /dev/null
+++ b/apps/backend/src/edge/edge.service.ts
@@ -0,0 +1,70 @@
+import { Injectable } from '@nestjs/common';
+import { EdgeRepository } from './edge.repository';
+import { NodeRepository } from '../node/node.repository';
+import { Edge } from './edge.entity';
+import { CreateEdgeDto } from './dtos/createEdge.dto';
+import { EdgeNotFoundException } from '../exception/edge.exception';
+
+@Injectable()
+export class EdgeService {
+ constructor(
+ private readonly edgeRepository: EdgeRepository,
+ private readonly nodeRepository: NodeRepository,
+ ) {}
+
+ async createEdge(dto: CreateEdgeDto): Promise {
+ const { fromNode, toNode } = dto;
+
+ // 출발 노드를 조회한다.
+ const existingFromNode = await this.nodeRepository.findOneBy({
+ id: fromNode,
+ });
+ // 도착 노드를 조회한다.
+ const existingToNode = await this.nodeRepository.findOneBy({ id: toNode });
+
+ // 엣지를 생성한다.
+ return await this.edgeRepository.save({
+ fromNode: existingFromNode,
+ toNode: existingToNode,
+ });
+ }
+
+ async deleteEdge(id: number): Promise {
+ // 엣지를 삭제한다
+ const deleteResult = await this.edgeRepository.delete(id);
+
+ // 삭제된 엣지가 없으면 노드를 찾지 못한 것
+ if (!deleteResult.affected) {
+ throw new EdgeNotFoundException();
+ }
+ }
+
+ async findEdges(): Promise {
+ // 모든 엣지들을 조회한다.
+ const edges = await this.edgeRepository.find({
+ relations: ['fromNode', 'toNode'],
+ select: {
+ id: true,
+ fromNode: {
+ id: true,
+ },
+ toNode: {
+ id: true,
+ },
+ },
+ });
+ // 엣지가 없으면 NotFound 에러
+ if (!edges) {
+ throw new EdgeNotFoundException();
+ }
+ return edges;
+ }
+ async findEdgeByFromNodeAndToNode(fromNodeId: number, toNodeId: number){
+ return this.edgeRepository.findOne({
+ where: {
+ fromNode: { id: fromNodeId },
+ toNode: { id: toNodeId },
+ },
+ relations: ['fromNode', 'toNode'],
+ }); }
+}
diff --git a/backend/src/exception/edge.exception.ts b/apps/backend/src/exception/edge.exception.ts
similarity index 100%
rename from backend/src/exception/edge.exception.ts
rename to apps/backend/src/exception/edge.exception.ts
diff --git a/apps/backend/src/exception/invalid.exception.ts b/apps/backend/src/exception/invalid.exception.ts
new file mode 100644
index 00000000..2d0b29a2
--- /dev/null
+++ b/apps/backend/src/exception/invalid.exception.ts
@@ -0,0 +1,7 @@
+import { ForbiddenException } from '@nestjs/common';
+
+export class InvalidTokenException extends ForbiddenException {
+ constructor() {
+ super(`유효하지 않은 JWT 토큰입니다.`);
+ }
+}
diff --git a/apps/backend/src/exception/login.exception.ts b/apps/backend/src/exception/login.exception.ts
new file mode 100644
index 00000000..bd0964f3
--- /dev/null
+++ b/apps/backend/src/exception/login.exception.ts
@@ -0,0 +1,7 @@
+import { ForbiddenException } from '@nestjs/common';
+
+export class LoginRequiredException extends ForbiddenException {
+ constructor() {
+ super(`로그인이 필요한 서비스입니다.`);
+ }
+}
diff --git a/backend/src/exception/node.exception.ts b/apps/backend/src/exception/node.exception.ts
similarity index 100%
rename from backend/src/exception/node.exception.ts
rename to apps/backend/src/exception/node.exception.ts
diff --git a/backend/src/exception/page.exception.ts b/apps/backend/src/exception/page.exception.ts
similarity index 100%
rename from backend/src/exception/page.exception.ts
rename to apps/backend/src/exception/page.exception.ts
diff --git a/apps/backend/src/exception/upload.exception.ts b/apps/backend/src/exception/upload.exception.ts
new file mode 100644
index 00000000..3c1c111c
--- /dev/null
+++ b/apps/backend/src/exception/upload.exception.ts
@@ -0,0 +1,7 @@
+import { BadRequestException } from '@nestjs/common';
+
+export class InvalidFileException extends BadRequestException {
+ constructor() {
+ super(`유효하지 않은 파일입니다.`);
+ }
+}
diff --git a/backend/src/filter/http-exception.filter.ts b/apps/backend/src/filter/http-exception.filter.ts
similarity index 100%
rename from backend/src/filter/http-exception.filter.ts
rename to apps/backend/src/filter/http-exception.filter.ts
diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts
new file mode 100644
index 00000000..45326980
--- /dev/null
+++ b/apps/backend/src/main.ts
@@ -0,0 +1,31 @@
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+import { HttpExceptionFilter } from './filter/http-exception.filter';
+import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+import { IoAdapter } from '@nestjs/platform-socket.io';
+import * as express from 'express';
+
+import * as dotenv from 'dotenv';
+dotenv.config();
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+
+ app.useWebSocketAdapter(new IoAdapter(app));
+ app.useGlobalFilters(new HttpExceptionFilter());
+ app.setGlobalPrefix('api');
+ app.use(express.urlencoded({ extended: true }));
+
+ const config = new DocumentBuilder()
+ .setTitle('OctoDocs')
+ .setDescription('OctoDocs API 명세서')
+ .build();
+
+ const documentFactory = () => SwaggerModule.createDocument(app, config);
+ SwaggerModule.setup('api', app, documentFactory);
+ app.enableCors({
+ origin: process.env.origin,
+ });
+ await app.listen(3000);
+}
+bootstrap();
diff --git a/apps/backend/src/node-cache/node-cache.module.ts b/apps/backend/src/node-cache/node-cache.module.ts
new file mode 100644
index 00000000..8624df51
--- /dev/null
+++ b/apps/backend/src/node-cache/node-cache.module.ts
@@ -0,0 +1,9 @@
+import { Module, Global } from '@nestjs/common';
+import { NodeCacheService } from './node-cache.service';
+
+@Global()
+@Module({
+ providers: [NodeCacheService],
+ exports: [NodeCacheService],
+})
+export class NodeCacheModule {}
diff --git a/apps/backend/src/node-cache/node-cache.service.ts b/apps/backend/src/node-cache/node-cache.service.ts
new file mode 100644
index 00000000..4ecba872
--- /dev/null
+++ b/apps/backend/src/node-cache/node-cache.service.ts
@@ -0,0 +1,43 @@
+import { Injectable } from '@nestjs/common';
+import { CacheContainer } from 'node-ts-cache';
+import { MemoryStorage } from 'node-ts-cache-storage-memory';
+
+type CacheValue = { title: string; isHolding: boolean };
+
+@Injectable()
+export class NodeCacheService {
+ private cache: CacheContainer;
+ private ttlTime: number;
+
+ constructor() {
+ this.ttlTime = 10;
+ this.cache = new CacheContainer(new MemoryStorage());
+ }
+
+ async set(nodeId: number, value: CacheValue): Promise {
+ const config = { ttl: this.ttlTime };
+ await this.cache.setItem(nodeId.toString(), value, config);
+ }
+
+ async get(nodeId: number): Promise {
+ return await this.cache.getItem(nodeId.toString());
+ }
+
+ async has(nodeId: number): Promise {
+ const item = await this.cache.getItem(nodeId.toString());
+ return item !== undefined;
+ }
+
+ async hasSameTitle(nodeId: number, title: string): Promise {
+ const savedCacheValue = await this.get(nodeId);
+ return !!savedCacheValue && savedCacheValue.title === title;
+ }
+
+ async isHoldingStatusChanged(
+ nodeId: number,
+ isHolding: boolean,
+ ): Promise {
+ const savedCacheValue = await this.get(nodeId);
+ return !!savedCacheValue && savedCacheValue.isHolding !== isHolding;
+ }
+}
diff --git a/apps/backend/src/node/dtos/coordinateResponse.dto.ts b/apps/backend/src/node/dtos/coordinateResponse.dto.ts
new file mode 100644
index 00000000..dc7e1475
--- /dev/null
+++ b/apps/backend/src/node/dtos/coordinateResponse.dto.ts
@@ -0,0 +1,26 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsNumber, IsObject } from 'class-validator';
+class Coordinate {
+ @IsNumber()
+ x: number;
+ @IsNumber()
+ y: number;
+}
+export class CoordinateResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+
+ @ApiProperty({
+ example: {
+ x: 14,
+ y: 14,
+ },
+ description: 'api 요청 결과 메시지',
+ })
+ @IsObject()
+ coordinate: Coordinate;
+}
diff --git a/apps/backend/src/node/dtos/createNode.dto.ts b/apps/backend/src/node/dtos/createNode.dto.ts
new file mode 100644
index 00000000..71e1b113
--- /dev/null
+++ b/apps/backend/src/node/dtos/createNode.dto.ts
@@ -0,0 +1,25 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsNumber } from 'class-validator';
+
+export class CreateNodeDto {
+ @ApiProperty({
+ example: '노드 제목',
+ description: '노드 제목',
+ })
+ @IsString()
+ title: string;
+
+ @ApiProperty({
+ example: '14',
+ description: 'x 좌표입니다.',
+ })
+ @IsNumber()
+ x: number;
+
+ @ApiProperty({
+ example: '14',
+ description: 'y 좌표입니다.',
+ })
+ @IsNumber()
+ y: number;
+}
diff --git a/apps/backend/src/node/dtos/findNodeResponse.dto.ts b/apps/backend/src/node/dtos/findNodeResponse.dto.ts
new file mode 100644
index 00000000..6259ee48
--- /dev/null
+++ b/apps/backend/src/node/dtos/findNodeResponse.dto.ts
@@ -0,0 +1,10 @@
+import { IsString, IsObject } from 'class-validator';
+import { Node } from '../node.entity';
+
+export class FindNodeResponseDto {
+ @IsString()
+ message: string;
+
+ @IsObject()
+ node: Node;
+}
diff --git a/apps/backend/src/node/dtos/findNodesResponse.dto..ts b/apps/backend/src/node/dtos/findNodesResponse.dto..ts
new file mode 100644
index 00000000..3829fdc6
--- /dev/null
+++ b/apps/backend/src/node/dtos/findNodesResponse.dto..ts
@@ -0,0 +1,10 @@
+import { IsString, IsArray } from 'class-validator';
+import { Node } from '../node.entity';
+
+export class FindNodesResponseDto {
+ @IsString()
+ message: string;
+
+ @IsArray()
+ nodes: Node[];
+}
diff --git a/apps/backend/src/node/dtos/messageResponse.dto.ts b/apps/backend/src/node/dtos/messageResponse.dto.ts
new file mode 100644
index 00000000..555c4329
--- /dev/null
+++ b/apps/backend/src/node/dtos/messageResponse.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString } from 'class-validator';
+
+export class MessageResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+}
diff --git a/apps/backend/src/node/dtos/moveNode.dto.ts b/apps/backend/src/node/dtos/moveNode.dto.ts
new file mode 100644
index 00000000..ce1d6f45
--- /dev/null
+++ b/apps/backend/src/node/dtos/moveNode.dto.ts
@@ -0,0 +1,9 @@
+import { IsNumber } from 'class-validator';
+
+export class MoveNodeDto {
+ @IsNumber()
+ x: number;
+
+ @IsNumber()
+ y: number;
+}
diff --git a/apps/backend/src/node/dtos/updateNode.dto.ts b/apps/backend/src/node/dtos/updateNode.dto.ts
new file mode 100644
index 00000000..7ec37597
--- /dev/null
+++ b/apps/backend/src/node/dtos/updateNode.dto.ts
@@ -0,0 +1,25 @@
+import { IsString, IsNumber } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class UpdateNodeDto {
+ @ApiProperty({
+ example: '노드 제목',
+ description: '노드 제목',
+ })
+ @IsString()
+ title: string;
+
+ @ApiProperty({
+ example: '14',
+ description: 'x 좌표입니다.',
+ })
+ @IsNumber()
+ x: number;
+
+ @ApiProperty({
+ example: '14',
+ description: 'y 좌표입니다.',
+ })
+ @IsNumber()
+ y: number;
+}
diff --git a/backend/src/node/node.controller.spec.ts b/apps/backend/src/node/node.controller.spec.ts
similarity index 82%
rename from backend/src/node/node.controller.spec.ts
rename to apps/backend/src/node/node.controller.spec.ts
index 6db96d6c..491b8bf3 100644
--- a/backend/src/node/node.controller.spec.ts
+++ b/apps/backend/src/node/node.controller.spec.ts
@@ -4,6 +4,7 @@ import { NodeService } from './node.service';
import { NodeResponseMessage } from './node.controller';
import { CreateNodeDto } from './dtos/createNode.dto';
import { UpdateNodeDto } from './dtos/updateNode.dto';
+import { MoveNodeDto } from './dtos/moveNode.dto';
import { NodeNotFoundException } from '../exception/node.exception';
describe('NodeController', () => {
@@ -23,6 +24,7 @@ describe('NodeController', () => {
updateNode: jest.fn(),
findNodeById: jest.fn(),
getCoordinates: jest.fn(),
+ moveNode: jest.fn(),
},
},
],
@@ -121,4 +123,29 @@ describe('NodeController', () => {
);
});
});
+
+ describe('moveNode', () => {
+ it('id에 해당하는 노드를 찾아 이동시킨다.', async () => {
+ const id = 2;
+ const dto: MoveNodeDto = { x: 3, y: 4 };
+ const expectedResponse = {
+ message: NodeResponseMessage.NODE_MOVED,
+ };
+
+ await expect(controller.moveNode(id, dto)).resolves.toEqual(
+ expectedResponse,
+ );
+ expect(nodeService.moveNode).toHaveBeenCalledWith(id, dto);
+ });
+
+ it('id에 해당하는 노드가 존재하지 않으면 NodeNotFoundException을 throw한다.', async () => {
+ jest
+ .spyOn(nodeService, 'moveNode')
+ .mockRejectedValue(new NodeNotFoundException());
+
+ await expect(controller.moveNode(1, new MoveNodeDto())).rejects.toThrow(
+ NodeNotFoundException,
+ );
+ });
+ });
});
diff --git a/apps/backend/src/node/node.controller.ts b/apps/backend/src/node/node.controller.ts
new file mode 100644
index 00000000..3863af1f
--- /dev/null
+++ b/apps/backend/src/node/node.controller.ts
@@ -0,0 +1,142 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Delete,
+ Patch,
+ Param,
+ Body,
+ HttpCode,
+ HttpStatus,
+ ParseIntPipe,
+} from '@nestjs/common';
+import { NodeService } from './node.service';
+import { CreateNodeDto } from './dtos/createNode.dto';
+import { UpdateNodeDto } from './dtos/updateNode.dto';
+import { MoveNodeDto } from './dtos/moveNode.dto';
+import { ApiOperation, ApiResponse } from '@nestjs/swagger';
+import { MessageResponseDto } from './dtos/messageResponse.dto';
+import { CoordinateResponseDto } from './dtos/coordinateResponse.dto';
+import { FindNodeResponseDto } from './dtos/findNodeResponse.dto';
+import { FindNodesResponseDto } from './dtos/findNodesResponse.dto.';
+
+export enum NodeResponseMessage {
+ NODE_RETURNED = '노드와 페이지를 가져왔습니다.',
+ NODE_ALL_RETURNED = '모든 노드를 가져왔습니다.',
+ NODE_CREATED = '노드와 페이지를 생성했습니다.',
+ NODE_UPDATED = '노드와 페이지를 갱신했습니다.',
+ NODE_DELETED = '노드와 페이지를 삭제했습니다.',
+ NODE_GET_COORDINAE = '노드의 현재 좌표를 가져왔습니다.',
+ NODE_MOVED = '노드의 위치를 이동했습니다.',
+}
+
+@Controller('node')
+export class NodeController {
+ constructor(private readonly nodeService: NodeService) {}
+
+ @ApiResponse({
+ type: FindNodesResponseDto,
+ })
+ @ApiOperation({
+ summary:
+ '모든 노드 정보를 가져옵니다. (페이지 정보 중 id와 title만 가져옵니다.)',
+ })
+ @Get('/')
+ @HttpCode(HttpStatus.OK)
+ async getNodes() {
+ const nodes = await this.nodeService.findNodes();
+ return {
+ message: NodeResponseMessage.NODE_ALL_RETURNED,
+ nodes: nodes,
+ };
+ }
+
+ @ApiResponse({
+ type: FindNodeResponseDto,
+ })
+ @ApiOperation({
+ summary:
+ '노드와 페이지 정보를 가져옵니다. (페이지 정보 중 id와 title만 가져옵니다.)',
+ })
+ @Get('/:id')
+ @HttpCode(HttpStatus.OK)
+ async getNodeById(@Param('id', ParseIntPipe) id: number) {
+ const node = await this.nodeService.findNodeById(id);
+ return {
+ message: NodeResponseMessage.NODE_RETURNED,
+ node: node,
+ };
+ }
+
+ @ApiResponse({
+ type: MessageResponseDto,
+ })
+ @ApiOperation({ summary: '노드를 생성하면서 페이지도 함께 생성합니다.' })
+ @Post('/')
+ @HttpCode(HttpStatus.CREATED)
+ async createNode(@Body() body: CreateNodeDto) {
+ await this.nodeService.createNode(body);
+ return {
+ message: NodeResponseMessage.NODE_CREATED,
+ };
+ }
+
+ @ApiResponse({
+ type: MessageResponseDto,
+ })
+ @ApiOperation({
+ summary: '노드를 삭제하면서 페이지도 삭제합니다. (delete: cascade)',
+ })
+ @Delete('/:id')
+ @HttpCode(HttpStatus.OK)
+ async deleteNode(
+ @Param('id', ParseIntPipe) id: number,
+ ): Promise<{ message: string }> {
+ await this.nodeService.deleteNode(id);
+ return {
+ message: NodeResponseMessage.NODE_DELETED,
+ };
+ }
+
+ @ApiResponse({
+ type: MessageResponseDto,
+ })
+ @ApiOperation({ summary: '노드의 제목, 좌표를 수정합니다.' })
+ @Patch('/:id')
+ @HttpCode(HttpStatus.OK)
+ async updateNode(
+ @Param('id', ParseIntPipe) id: number,
+ @Body() body: UpdateNodeDto,
+ ): Promise<{ message: string }> {
+ await this.nodeService.updateNode(id, body);
+ return {
+ message: NodeResponseMessage.NODE_UPDATED,
+ };
+ }
+
+ @ApiResponse({
+ type: CoordinateResponseDto,
+ })
+ @ApiOperation({ summary: '노드의 좌표를 가져옵니다.' })
+ @Get(':id/coordinates')
+ @HttpCode(HttpStatus.OK)
+ async getCoordinates(@Param('id', ParseIntPipe) id: number) {
+ const coordinate = await this.nodeService.getCoordinates(id);
+ return {
+ message: NodeResponseMessage.NODE_GET_COORDINAE,
+ coordinate: coordinate,
+ };
+ }
+
+ @Patch('/:id/move')
+ @HttpCode(HttpStatus.OK)
+ async moveNode(
+ @Param('id', ParseIntPipe) id: number,
+ @Body() body: MoveNodeDto,
+ ) {
+ await this.nodeService.moveNode(id, body);
+ return {
+ message: NodeResponseMessage.NODE_MOVED,
+ };
+ }
+}
diff --git a/backend/src/node/node.entity.ts b/apps/backend/src/node/node.entity.ts
similarity index 100%
rename from backend/src/node/node.entity.ts
rename to apps/backend/src/node/node.entity.ts
diff --git a/backend/src/node/node.module.ts b/apps/backend/src/node/node.module.ts
similarity index 100%
rename from backend/src/node/node.module.ts
rename to apps/backend/src/node/node.module.ts
diff --git a/backend/src/node/node.repository.ts b/apps/backend/src/node/node.repository.ts
similarity index 74%
rename from backend/src/node/node.repository.ts
rename to apps/backend/src/node/node.repository.ts
index be226f11..b09e05a8 100644
--- a/backend/src/node/node.repository.ts
+++ b/apps/backend/src/node/node.repository.ts
@@ -1,10 +1,11 @@
import { DataSource, Repository } from 'typeorm';
import { Node } from './node.entity';
import { Injectable } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
@Injectable()
export class NodeRepository extends Repository {
- constructor(private dataSource: DataSource) {
+ constructor(@InjectDataSource() private dataSource: DataSource) {
super(Node, dataSource.createEntityManager());
}
diff --git a/backend/src/node/node.service.spec.ts b/apps/backend/src/node/node.service.spec.ts
similarity index 70%
rename from backend/src/node/node.service.spec.ts
rename to apps/backend/src/node/node.service.spec.ts
index 92e510a5..e7f4ab2a 100644
--- a/backend/src/node/node.service.spec.ts
+++ b/apps/backend/src/node/node.service.spec.ts
@@ -7,6 +7,7 @@ import { Node } from './node.entity';
import { Page } from '../page/page.entity';
import { CreateNodeDto } from './dtos/createNode.dto';
import { UpdateNodeDto } from './dtos/updateNode.dto';
+import { MoveNodeDto } from './dtos/moveNode.dto';
describe('NodeService', () => {
let service: NodeService;
@@ -24,6 +25,8 @@ describe('NodeService', () => {
save: jest.fn(),
delete: jest.fn(),
findOneBy: jest.fn(),
+ findOne: jest.fn(),
+ update: jest.fn(),
},
},
{
@@ -101,7 +104,7 @@ describe('NodeService', () => {
const node = new Node();
node.page = new Page();
- jest.spyOn(nodeRepository, 'findOneBy').mockResolvedValue(node);
+ jest.spyOn(nodeRepository, 'findOne').mockResolvedValue(node);
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(node.page);
jest.spyOn(nodeRepository, 'save').mockResolvedValue(node);
@@ -119,7 +122,7 @@ describe('NodeService', () => {
});
it('업데이트할 노드가 존재하지 않으면 NodeNotFoundException을 throw한다.', async () => {
- jest.spyOn(nodeRepository, 'findOneBy').mockResolvedValue(undefined);
+ jest.spyOn(nodeRepository, 'findOne').mockResolvedValue(undefined);
await expect(service.updateNode(1, {} as any)).rejects.toThrow(
NodeNotFoundException,
@@ -131,12 +134,24 @@ describe('NodeService', () => {
it('노드 아이디를 받아 해당 노드의 좌표를 반환한다.', async () => {
const node = { id: 1, x: 1, y: 2 } as Node;
- nodeRepository.findOneBy.mockResolvedValue(node);
+ jest.spyOn(nodeRepository, 'findOne').mockResolvedValue(node);
const coordinates = await service.getCoordinates(1);
expect(coordinates).toEqual({ x: 1, y: 2 });
- expect(nodeRepository.findOneBy).toHaveBeenCalledWith({ id: 1 });
+ expect(nodeRepository.findOne).toHaveBeenCalledWith({
+ relations: ['page'],
+ select: {
+ id: true,
+ page: {
+ id: true,
+ title: true,
+ },
+ },
+ where: {
+ id: 1,
+ },
+ });
});
it('노드를 찾을 수 없으면 NodeNotFoundException을 throw한다.', async () => {
@@ -159,20 +174,71 @@ describe('NodeService', () => {
outgoingEdges: [],
incomingEdges: [],
} as Node;
- jest.spyOn(nodeRepository, 'findOneBy').mockResolvedValue(node);
+ jest.spyOn(nodeRepository, 'findOne').mockResolvedValue(node);
const result = await service.findNodeById(1);
expect(result).toEqual(node);
- expect(nodeRepository.findOneBy).toHaveBeenCalledWith({ id: 1 });
+ expect(nodeRepository.findOne).toHaveBeenCalledWith({
+ relations: ['page'],
+ select: {
+ id: true,
+ page: {
+ id: true,
+ title: true,
+ },
+ },
+ where: {
+ id: 1,
+ },
+ });
});
it('노드를 찾을 수 없으면 NodeNotFoundException을 던진다.', async () => {
- jest.spyOn(nodeRepository, 'findOneBy').mockResolvedValue(undefined);
+ jest.spyOn(nodeRepository, 'findOne').mockResolvedValue(undefined);
await expect(service.findNodeById(1)).rejects.toThrow(
NodeNotFoundException,
);
- expect(nodeRepository.findOneBy).toHaveBeenCalledWith({ id: 1 });
+ expect(nodeRepository.findOne).toHaveBeenCalledWith({
+ relations: ['page'],
+ select: {
+ id: true,
+ page: {
+ id: true,
+ title: true,
+ },
+ },
+ where: {
+ id: 1,
+ },
+ });
+ });
+ });
+
+ describe('moveNode', () => {
+ it('노드를 성공적으로 이동시킨다.', async () => {
+ const id = 1;
+ const dto: MoveNodeDto = { x: 3, y: 4 };
+ const node = { id: 1, x: 0, y: 0 } as Node;
+
+ jest.spyOn(service, 'findNodeById').mockResolvedValue(node);
+ jest
+ .spyOn(nodeRepository, 'update')
+ .mockResolvedValue({ affected: true } as any);
+
+ await service.moveNode(id, dto);
+
+ expect(nodeRepository.update).toHaveBeenCalledWith(id, {
+ x: dto.x,
+ y: dto.y,
+ });
+ });
+
+ it('노드를 찾을 수 없으면 NodeNotFoundException을 던진다.', async () => {
+ jest.spyOn(nodeRepository, 'update').mockRejectedValue(new Error());
+ await expect(service.moveNode(1, {} as any)).rejects.toThrow(
+ NodeNotFoundException,
+ );
});
});
});
diff --git a/backend/src/node/node.service.ts b/apps/backend/src/node/node.service.ts
similarity index 63%
rename from backend/src/node/node.service.ts
rename to apps/backend/src/node/node.service.ts
index 340b7278..fcb674c3 100644
--- a/backend/src/node/node.service.ts
+++ b/apps/backend/src/node/node.service.ts
@@ -5,6 +5,8 @@ import { Node } from './node.entity';
import { CreateNodeDto } from './dtos/createNode.dto';
import { UpdateNodeDto } from './dtos/updateNode.dto';
import { NodeNotFoundException } from '../exception/node.exception';
+import { MoveNodeDto } from './dtos/moveNode.dto';
+
@Injectable()
export class NodeService {
constructor(
@@ -47,9 +49,25 @@ export class NodeService {
}
async updateNode(id: number, dto: UpdateNodeDto): Promise {
- // 갱신할 노드를 조회한다.
- const node = await this.findNodeById(id);
+ // 노드를 조회한다.
+ const node = await this.nodeRepository.findOne({
+ relations: ['page'],
+ select: {
+ id: true,
+ page: {
+ id: true,
+ title: true, // content 제외하고 title만 선택
+ },
+ },
+ where: {
+ id,
+ },
+ });
+ // 노드가 없으면 NotFound 에러
+ if (!node) {
+ throw new NodeNotFoundException();
+ }
// 노드와 연결된 페이지를 조회한다.
const linkedPage = await this.pageRepository.findOneBy({
id: node.page.id,
@@ -66,7 +84,19 @@ export class NodeService {
async findNodeById(id: number): Promise {
// 노드를 조회한다.
- const node = await this.nodeRepository.findOneBy({ id });
+ const node = await this.nodeRepository.findOne({
+ relations: ['page'],
+ select: {
+ id: true,
+ page: {
+ id: true,
+ title: true, // content 제외하고 title만 선택
+ },
+ },
+ where: {
+ id,
+ },
+ });
// 노드가 없으면 NotFound 에러
if (!node) {
@@ -75,6 +105,27 @@ export class NodeService {
return node;
}
+ async findNodes(): Promise {
+ // 노드를 조회한다.
+ const nodes = await this.nodeRepository.find({
+ relations: ['page'],
+ select: {
+ id: true,
+ x: true,
+ y: true,
+ page: {
+ id: true,
+ title: true, // content 제외하고 title만 선택
+ },
+ },
+ });
+ // 노드가 없으면 NotFound 에러
+ if (!nodes) {
+ throw new NodeNotFoundException();
+ }
+ return nodes;
+ }
+
async getCoordinates(id: number): Promise<{ x: number; y: number }> {
// 노드를 조회한다.
const node = await this.findNodeById(id);
@@ -85,4 +136,18 @@ export class NodeService {
y: node.y,
};
}
+
+ async moveNode(id: number, dto: MoveNodeDto): Promise {
+ const { x, y } = dto;
+ // 갱신할 노드를 조회한다.
+ const node = await this.findNodeById(id);
+
+ // 노드가 없으면 NotFound 에러
+ if (!node) {
+ throw new NodeNotFoundException();
+ }
+
+ // UPDATE 쿼리를 실행한다.
+ await this.nodeRepository.update(id, { x, y });
+ }
}
diff --git a/apps/backend/src/page/dtos/createPage.dto.ts b/apps/backend/src/page/dtos/createPage.dto.ts
new file mode 100644
index 00000000..691c8c55
--- /dev/null
+++ b/apps/backend/src/page/dtos/createPage.dto.ts
@@ -0,0 +1,41 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsNumber, IsJSON, IsOptional } from 'class-validator';
+
+export class CreatePageDto {
+ @ApiProperty({
+ example: 'nest.js 사용법',
+ description: '페이지 제목입니다.',
+ })
+ @IsString()
+ title: string;
+
+ @ApiProperty({
+ example: 'nest를 설치합니다.',
+ description: '페이지 내용입니다.',
+ })
+ @IsJSON()
+ content: JSON;
+
+ @ApiProperty({
+ example: '14',
+ description: 'x 좌표입니다.',
+ })
+ @IsNumber()
+ x: number;
+
+ @ApiProperty({
+ example: '14',
+ description: 'y 좌표입니다.',
+ })
+ @IsNumber()
+ y: number;
+
+ @ApiProperty({
+ example: '📝',
+ description: '페이지 이모지',
+ required: false,
+ })
+ @IsString()
+ @IsOptional()
+ emoji?: string;
+}
diff --git a/apps/backend/src/page/dtos/createPageResponse.dto.ts b/apps/backend/src/page/dtos/createPageResponse.dto.ts
new file mode 100644
index 00000000..b03b6719
--- /dev/null
+++ b/apps/backend/src/page/dtos/createPageResponse.dto.ts
@@ -0,0 +1,18 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsInt, IsString } from 'class-validator';
+
+export class CreatePageResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+
+ @ApiProperty({
+ example: 1,
+ description: '페이지의 PK',
+ })
+ @IsInt()
+ pageId: number;
+}
diff --git a/apps/backend/src/page/dtos/findPageResponse.dto.ts b/apps/backend/src/page/dtos/findPageResponse.dto.ts
new file mode 100644
index 00000000..6350e65d
--- /dev/null
+++ b/apps/backend/src/page/dtos/findPageResponse.dto.ts
@@ -0,0 +1,22 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsObject } from 'class-validator';
+import { Page } from '../page.entity';
+
+export class FindPageResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+
+ @ApiProperty({
+ example: {
+ type: 'doc',
+ content: {},
+ },
+ description: '모든 Page 배열',
+ })
+ @IsObject()
+ page: Partial;
+}
diff --git a/apps/backend/src/page/dtos/findPagesResponse.dto.ts b/apps/backend/src/page/dtos/findPagesResponse.dto.ts
new file mode 100644
index 00000000..adb38a58
--- /dev/null
+++ b/apps/backend/src/page/dtos/findPagesResponse.dto.ts
@@ -0,0 +1,28 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, IsArray } from 'class-validator';
+import { Page } from '../page.entity';
+
+export class FindPagesResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+
+ @ApiProperty({
+ example: [
+ {
+ id: 1,
+ title: '페이지 제목',
+ content: {
+ type: 'doc',
+ content: {},
+ },
+ },
+ ],
+ description: '모든 Page 배열',
+ })
+ @IsArray()
+ pages: Partial[];
+}
diff --git a/apps/backend/src/page/dtos/messageResponse.dto.ts b/apps/backend/src/page/dtos/messageResponse.dto.ts
new file mode 100644
index 00000000..555c4329
--- /dev/null
+++ b/apps/backend/src/page/dtos/messageResponse.dto.ts
@@ -0,0 +1,11 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString } from 'class-validator';
+
+export class MessageResponseDto {
+ @ApiProperty({
+ example: 'OO 생성에 성공했습니다.',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+}
diff --git a/apps/backend/src/page/dtos/updatePage.dto.ts b/apps/backend/src/page/dtos/updatePage.dto.ts
new file mode 100644
index 00000000..100f3206
--- /dev/null
+++ b/apps/backend/src/page/dtos/updatePage.dto.ts
@@ -0,0 +1,27 @@
+import { IsString, IsJSON, IsOptional } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class UpdatePageDto {
+ @ApiProperty({
+ example: '페이지 제목입니다.',
+ description: '페이지 제목.',
+ })
+ @IsString()
+ title: string;
+
+ @ApiProperty({
+ example: "{'doc' : 'type'}",
+ description: '페이지 내용 JSON 형태',
+ })
+ @IsJSON()
+ content: JSON;
+
+ @ApiProperty({
+ example: '📝',
+ description: '페이지 이모지',
+ required: false,
+ })
+ @IsString()
+ @IsOptional()
+ emoji?: string;
+}
diff --git a/backend/src/page/page.controller.spec.ts b/apps/backend/src/page/page.controller.spec.ts
similarity index 91%
rename from backend/src/page/page.controller.spec.ts
rename to apps/backend/src/page/page.controller.spec.ts
index ed2c492a..2851dda6 100644
--- a/backend/src/page/page.controller.spec.ts
+++ b/apps/backend/src/page/page.controller.spec.ts
@@ -47,8 +47,19 @@ describe('PageController', () => {
};
const expectedResponse = {
message: PageResponseMessage.PAGE_CREATED,
+ pageId: 1,
};
-
+ const newDate = new Date();
+ jest.spyOn(pageService, 'createPage').mockResolvedValue({
+ id: 1,
+ title: 'New Page',
+ content: {} as JSON,
+ createdAt: newDate,
+ updatedAt: newDate,
+ version: 1,
+ node: null,
+ emoji: null,
+ });
const result = await controller.createPage(dto);
expect(pageService.createPage).toHaveBeenCalledWith(dto);
@@ -128,6 +139,10 @@ describe('PageController', () => {
title: 'title',
content: {} as JSON,
node: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ version: 1,
+ emoji: null,
};
jest.spyOn(pageService, 'findPageById').mockResolvedValue(expectedPage);
diff --git a/backend/src/page/page.controller.ts b/apps/backend/src/page/page.controller.ts
similarity index 50%
rename from backend/src/page/page.controller.ts
rename to apps/backend/src/page/page.controller.ts
index e4ff6c0c..aa32bdbb 100644
--- a/backend/src/page/page.controller.ts
+++ b/apps/backend/src/page/page.controller.ts
@@ -8,11 +8,16 @@ import {
Body,
HttpCode,
HttpStatus,
+ ParseIntPipe,
} from '@nestjs/common';
-import { Page } from './page.entity';
import { PageService } from './page.service';
import { CreatePageDto } from './dtos/createPage.dto';
import { UpdatePageDto } from './dtos/updatePage.dto';
+import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger';
+import { MessageResponseDto } from './dtos/messageResponse.dto';
+import { FindPagesResponseDto } from './dtos/findPagesResponse.dto';
+import { FindPageResponseDto } from './dtos/findPageResponse.dto';
+import { CreatePageResponseDto } from './dtos/createPageResponse.dto';
export enum PageResponseMessage {
PAGE_CREATED = '페이지와 노드를 생성했습니다.',
@@ -26,50 +31,81 @@ export enum PageResponseMessage {
export class PageController {
constructor(private readonly pageService: PageService) {}
+ @ApiResponse({
+ type: CreatePageResponseDto,
+ })
+ @ApiOperation({ summary: '페이지를 생성하고 노드도 생성합니다.' })
+ @ApiBody({
+ description: 'post',
+ type: CreatePageDto,
+ })
@Post('/')
@HttpCode(HttpStatus.CREATED)
- async createPage(@Body() body: CreatePageDto): Promise<{ message: string }> {
- await this.pageService.createPage(body);
+ async createPage(
+ @Body() body: CreatePageDto,
+ ): Promise {
+ const newPage = await this.pageService.createPage(body);
return {
message: PageResponseMessage.PAGE_CREATED,
+ pageId: newPage.id,
};
}
+ @ApiResponse({
+ type: MessageResponseDto,
+ })
+ @ApiOperation({
+ summary: '페이지를 삭제하고 노드도 삭제합니다. (cascade delete)',
+ })
@Delete('/:id')
@HttpCode(HttpStatus.OK)
- async deletePage(@Param('id') id: number): Promise<{ message: string }> {
+ async deletePage(
+ @Param('id', ParseIntPipe) id: number,
+ ): Promise {
await this.pageService.deletePage(id);
return {
message: PageResponseMessage.PAGE_DELETED,
};
}
+ @ApiResponse({
+ type: MessageResponseDto,
+ })
+ @ApiOperation({ summary: '페이지 제목, 내용을 수정합니다.' })
@Patch('/:id')
@HttpCode(HttpStatus.OK)
async updatePage(
- @Param('id') id: number,
+ @Param('id', ParseIntPipe) id: number,
@Body() body: UpdatePageDto,
- ): Promise<{ message: string }> {
+ ): Promise {
await this.pageService.updatePage(id, body);
return {
message: PageResponseMessage.PAGE_UPDATED,
};
}
+ @ApiResponse({
+ type: FindPagesResponseDto,
+ })
+ @ApiOperation({ summary: '모든 페이지를 가져옵니다.' })
@Get()
@HttpCode(HttpStatus.OK)
- async findPages(): Promise<{ message: string; pages: Partial[] }> {
+ async findPages(): Promise {
return {
message: PageResponseMessage.PAGE_LIST_RETURNED,
pages: await this.pageService.findPages(),
};
}
+ @ApiResponse({
+ type: FindPageResponseDto,
+ })
+ @ApiOperation({ summary: '특정 페이지를 가져옵니다.' })
@Get('/:id')
@HttpCode(HttpStatus.OK)
async findPage(
- @Param('id') id: number,
- ): Promise<{ message: string; page: Page }> {
+ @Param('id', ParseIntPipe) id: number,
+ ): Promise {
return {
message: PageResponseMessage.PAGE_RETURNED,
page: await this.pageService.findPageById(id),
diff --git a/apps/backend/src/page/page.entity.ts b/apps/backend/src/page/page.entity.ts
new file mode 100644
index 00000000..8567ee7d
--- /dev/null
+++ b/apps/backend/src/page/page.entity.ts
@@ -0,0 +1,48 @@
+import {
+ Column,
+ Entity,
+ OneToOne,
+ PrimaryGeneratedColumn,
+ JoinColumn,
+ CreateDateColumn,
+ UpdateDateColumn,
+ VersionColumn,
+} from 'typeorm';
+import { Node } from '../node/node.entity';
+
+@Entity()
+export class Page {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column()
+ title: string;
+
+ @Column('json') //TODO: Postgres에서는 jsonb로 변경
+ content: JSON;
+
+ @CreateDateColumn()
+ createdAt: Date;
+
+ @UpdateDateColumn()
+ updatedAt: Date;
+
+ @VersionColumn()
+ version: number;
+
+ @Column({ nullable: true })
+ emoji: string | null;
+
+ // TODO:추가적인 메타데이터 컬럼들(user 기능 추가할때)
+ // @Column('created_by')
+ // createdBy: string;
+
+ // @Column('updated_by')
+ // updatedBy: string;
+
+ @OneToOne(() => Node, (node) => node.page, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ node: Node;
+}
diff --git a/backend/src/page/page.module.ts b/apps/backend/src/page/page.module.ts
similarity index 100%
rename from backend/src/page/page.module.ts
rename to apps/backend/src/page/page.module.ts
diff --git a/backend/src/page/page.repository.ts b/apps/backend/src/page/page.repository.ts
similarity index 74%
rename from backend/src/page/page.repository.ts
rename to apps/backend/src/page/page.repository.ts
index b7b510ff..c83a7c9c 100644
--- a/backend/src/page/page.repository.ts
+++ b/apps/backend/src/page/page.repository.ts
@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Page } from './page.entity';
+import { InjectDataSource } from '@nestjs/typeorm';
@Injectable()
export class PageRepository extends Repository {
- constructor(private dataSource: DataSource) {
+ constructor(@InjectDataSource() private dataSource: DataSource) {
super(Page, dataSource.createEntityManager());
}
@@ -13,6 +14,7 @@ export class PageRepository extends Repository {
select: {
id: true,
title: true,
+ emoji: true,
},
});
}
diff --git a/backend/src/page/page.service.spec.ts b/apps/backend/src/page/page.service.spec.ts
similarity index 91%
rename from backend/src/page/page.service.spec.ts
rename to apps/backend/src/page/page.service.spec.ts
index 0a5a9a52..f1020ee9 100644
--- a/backend/src/page/page.service.spec.ts
+++ b/apps/backend/src/page/page.service.spec.ts
@@ -58,13 +58,17 @@ describe('PageService', () => {
x: 1,
y: 1,
};
-
+ const newDate = new Date();
// 페이지 엔티티
const newPage: Page = {
id: 1,
title: 'new page',
content: {} as JSON,
+ createdAt: newDate,
+ updatedAt: newDate,
+ version: 1,
node: null,
+ emoji: null,
};
// 노드 엔티티
@@ -118,18 +122,29 @@ describe('PageService', () => {
const dto: UpdatePageDto = {
title: 'Updated Title',
content: {} as JSON,
+ emoji: '📝',
};
+ const originDate = new Date();
const originPage: Page = {
id: 1,
title: 'origin title',
content: {} as JSON,
node: null,
+ createdAt: originDate,
+ updatedAt: originDate,
+ version: 1,
+ emoji: null,
};
+ const newDate = new Date();
const newPage: Page = {
id: 1,
title: 'Updated Title',
content: {} as JSON,
node: null,
+ createdAt: newDate,
+ updatedAt: newDate,
+ version: 1,
+ emoji: '📝',
};
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(originPage);
@@ -157,11 +172,16 @@ describe('PageService', () => {
describe('findPageById', () => {
it('id에 해당하는 페이지를 찾아 성공적으로 반환한다.', async () => {
+ const newDate = new Date();
const expectedPage: Page = {
id: 1,
title: 'title',
content: {} as JSON,
node: null,
+ createdAt: newDate,
+ updatedAt: newDate,
+ version: 1,
+ emoji: null,
};
jest.spyOn(pageRepository, 'findOneBy').mockResolvedValue(expectedPage);
diff --git a/backend/src/page/page.service.ts b/apps/backend/src/page/page.service.ts
similarity index 83%
rename from backend/src/page/page.service.ts
rename to apps/backend/src/page/page.service.ts
index 73cbcc4f..cdb7fc52 100644
--- a/backend/src/page/page.service.ts
+++ b/apps/backend/src/page/page.service.ts
@@ -14,17 +14,18 @@ export class PageService {
) {}
async createPage(dto: CreatePageDto): Promise {
- const { title, content, x, y } = dto;
+ const { title, x, y, emoji } = dto;
- // 페이지부터 생성한다.
- const page = await this.pageRepository.save({ title, content });
+ // 노드부터 생성한다.
+ const node = await this.nodeRepository.save({ title, x, y });
- // 노드를 생성한다.
- const node = await this.nodeRepository.save({ id: page.id, x, y });
+ // 페이지를 생성한다.
+ const page = await this.pageRepository.save({ title, content: {}, emoji });
- // 노드와 페이지를 서로 연결하여 저장한다.
- page.node = node;
- return await this.pageRepository.save(page);
+ // 페이지와 노드를 서로 연결하여 저장한다.
+ node.page = page;
+ await this.nodeRepository.save(node);
+ return page;
}
async createLinkedPage(title: string, nodeId: number): Promise {
@@ -57,9 +58,12 @@ export class PageService {
throw new PageNotFoundException();
}
// 페이지 정보를 갱신한다.
- const { title, content } = dto;
+ const { title, content, emoji } = dto;
page.title = title;
page.content = content;
+ if (emoji !== undefined) {
+ page.emoji = emoji;
+ }
return await this.pageRepository.save(page);
}
diff --git a/apps/backend/src/upload/dtos/imageUploadResponse.dto.ts b/apps/backend/src/upload/dtos/imageUploadResponse.dto.ts
new file mode 100644
index 00000000..89c6e026
--- /dev/null
+++ b/apps/backend/src/upload/dtos/imageUploadResponse.dto.ts
@@ -0,0 +1,18 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString } from 'class-validator';
+
+export class ImageUploadResponseDto {
+ @ApiProperty({
+ example: '이미지 업로드 성공',
+ description: 'api 요청 결과 메시지',
+ })
+ @IsString()
+ message: string;
+
+ @ApiProperty({
+ example: 'https://kr.object.ncloudstorage.com/octodocs-static/uploads/name',
+ description: '업로드된 이미지 url',
+ })
+ @IsString()
+ url: string;
+}
diff --git a/apps/backend/src/upload/s3-client.provider.ts b/apps/backend/src/upload/s3-client.provider.ts
new file mode 100644
index 00000000..eaa6eec0
--- /dev/null
+++ b/apps/backend/src/upload/s3-client.provider.ts
@@ -0,0 +1,20 @@
+import { S3Client } from '@aws-sdk/client-s3';
+import { Provider } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+export const S3_CLIENT = 'S3_CLIENT';
+
+export const s3ClientProvider: Provider = {
+ provide: S3_CLIENT,
+ useFactory: (configService: ConfigService) => {
+ return new S3Client({
+ region: configService.get('CLOUD_REGION'),
+ endpoint: configService.get('CLOUD_ENDPOINT'),
+ credentials: {
+ accessKeyId: configService.get('CLOUD_ACCESS_KEY_ID'),
+ secretAccessKey: configService.get('CLOUD_SECRET_ACCESS_KEY'),
+ },
+ });
+ },
+ inject: [ConfigService],
+};
diff --git a/apps/backend/src/upload/upload.config.ts b/apps/backend/src/upload/upload.config.ts
new file mode 100644
index 00000000..528cd8e1
--- /dev/null
+++ b/apps/backend/src/upload/upload.config.ts
@@ -0,0 +1,21 @@
+import { InvalidFileException } from '../exception/upload.exception';
+
+export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 5MB
+
+export const imageFileFilter = (
+ req: any,
+ file: Express.Multer.File,
+ callback: (error: Error | null, acceptFile: boolean) => void,
+) => {
+ if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
+ return callback(new InvalidFileException(), false);
+ }
+ callback(null, true);
+};
+
+export const uploadOptions = {
+ fileFilter: imageFileFilter,
+ limits: {
+ fileSize: MAX_FILE_SIZE,
+ },
+};
diff --git a/apps/backend/src/upload/upload.controller.ts b/apps/backend/src/upload/upload.controller.ts
new file mode 100644
index 00000000..893deadf
--- /dev/null
+++ b/apps/backend/src/upload/upload.controller.ts
@@ -0,0 +1,31 @@
+import {
+ Controller,
+ Post,
+ UploadedFile,
+ UseInterceptors,
+} from '@nestjs/common';
+import { FileInterceptor } from '@nestjs/platform-express';
+import { UploadService } from './upload.service';
+import { uploadOptions } from './upload.config';
+import { ImageUploadResponseDto } from './dtos/imageUploadResponse.dto';
+
+export enum UploadResponseMessage {
+ UPLOAD_IMAGE_SUCCESS = '이미지 업로드 성공',
+}
+
+@Controller('upload')
+export class UploadController {
+ constructor(private readonly uploadService: UploadService) {}
+
+ @Post('image')
+ @UseInterceptors(FileInterceptor('file', uploadOptions))
+ async uploadImage(
+ @UploadedFile() file: Express.Multer.File,
+ ): Promise {
+ const result = await this.uploadService.uploadImageToCloud(file);
+ return {
+ message: UploadResponseMessage.UPLOAD_IMAGE_SUCCESS,
+ url: result,
+ };
+ }
+}
diff --git a/apps/backend/src/upload/upload.module.ts b/apps/backend/src/upload/upload.module.ts
new file mode 100644
index 00000000..387163c4
--- /dev/null
+++ b/apps/backend/src/upload/upload.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { UploadService } from './upload.service';
+import { UploadController } from './upload.controller';
+import { ConfigModule } from '@nestjs/config';
+import { s3ClientProvider } from './s3-client.provider';
+
+@Module({
+ imports: [ConfigModule],
+ controllers: [UploadController],
+ providers: [UploadService, s3ClientProvider],
+ exports: [UploadService],
+})
+export class UploadModule {}
diff --git a/apps/backend/src/upload/upload.service.spec.ts b/apps/backend/src/upload/upload.service.spec.ts
new file mode 100644
index 00000000..399b9e62
--- /dev/null
+++ b/apps/backend/src/upload/upload.service.spec.ts
@@ -0,0 +1,100 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { UploadService } from './upload.service';
+import { ConfigService } from '@nestjs/config';
+import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
+import { S3_CLIENT } from './s3-client.provider';
+
+describe('UploadService', () => {
+ let service: UploadService;
+ let s3Client: jest.Mocked;
+ let configService: jest.Mocked;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ UploadService,
+ {
+ provide: S3_CLIENT,
+ useValue: {
+ send: jest.fn(),
+ },
+ },
+ {
+ provide: ConfigService,
+ useValue: {
+ get: jest.fn(),
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(UploadService);
+ s3Client = module.get(S3_CLIENT);
+ configService = module.get(ConfigService);
+ });
+
+ it('서비스 클래스가 정상적으로 인스턴스화된다.', () => {
+ expect(service).toBeDefined();
+ });
+
+ describe('uploadImageToCloud', () => {
+ it('이미지를 성공적으로 업로드하고 URL을 반환한다.', async () => {
+ // Given
+ const mockFile = {
+ originalname: 'test.jpg',
+ buffer: Buffer.from('test'),
+ mimetype: 'image/jpeg',
+ } as Express.Multer.File;
+
+ const mockBucketName = 'test-bucket';
+ const mockEndpoint = 'https://test-endpoint';
+
+ jest
+ .spyOn(configService, 'get')
+ .mockReturnValueOnce(mockBucketName) // CLOUD_BUCKET_NAME
+ .mockReturnValueOnce(mockEndpoint) // CLOUD_ENDPOINT
+ .mockReturnValueOnce(mockBucketName); // CLOUD_BUCKET_NAME again
+
+ jest.spyOn(s3Client, 'send').mockResolvedValue({} as never);
+
+ // Mock Date.now()
+ const mockDate = 1234567890;
+ jest.spyOn(Date, 'now').mockReturnValue(mockDate);
+
+ // When
+ const result = await service.uploadImageToCloud(mockFile);
+
+ // Then
+ expect(s3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand));
+
+ const expectedUrl = `${mockEndpoint}/${mockBucketName}/uploads/${mockDate}-${mockFile.originalname}`;
+ expect(result).toBe(expectedUrl);
+
+ const putObjectCommand = (s3Client.send as jest.Mock).mock.calls[0][0];
+ expect(putObjectCommand.input).toEqual({
+ Bucket: mockBucketName,
+ Key: `uploads/${mockDate}-${mockFile.originalname}`,
+ Body: mockFile.buffer,
+ ContentType: mockFile.mimetype,
+ ACL: 'public-read',
+ });
+ });
+
+ it('S3 업로드 실패 시 에러를 전파한다.', async () => {
+ // Given
+ const mockFile = {
+ originalname: 'test.jpg',
+ buffer: Buffer.from('test'),
+ mimetype: 'image/jpeg',
+ } as Express.Multer.File;
+
+ const mockError = new Error('Upload failed');
+ jest.spyOn(s3Client, 'send').mockRejectedValue(mockError as never);
+
+ // When & Then
+ await expect(service.uploadImageToCloud(mockFile)).rejects.toThrow(
+ mockError,
+ );
+ });
+ });
+});
diff --git a/apps/backend/src/upload/upload.service.ts b/apps/backend/src/upload/upload.service.ts
new file mode 100644
index 00000000..b912c89c
--- /dev/null
+++ b/apps/backend/src/upload/upload.service.ts
@@ -0,0 +1,27 @@
+import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
+import { Inject, Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { S3_CLIENT } from './s3-client.provider';
+
+@Injectable()
+export class UploadService {
+ constructor(
+ @Inject(S3_CLIENT) private readonly s3Client: S3Client,
+ private readonly configService: ConfigService,
+ ) {}
+
+ async uploadImageToCloud(file: Express.Multer.File) {
+ const key = `uploads/${Date.now()}-${file.originalname}`;
+
+ const command = new PutObjectCommand({
+ Bucket: this.configService.get('CLOUD_BUCKET_NAME'),
+ Key: key,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ ACL: 'public-read',
+ });
+
+ await this.s3Client.send(command);
+ return `${this.configService.get('CLOUD_ENDPOINT')}/${this.configService.get('CLOUD_BUCKET_NAME')}/${key}`;
+ }
+}
diff --git a/apps/backend/src/user/user.entity.ts b/apps/backend/src/user/user.entity.ts
new file mode 100644
index 00000000..01543f50
--- /dev/null
+++ b/apps/backend/src/user/user.entity.ts
@@ -0,0 +1,31 @@
+// user.entity.ts
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ CreateDateColumn,
+} from 'typeorm';
+
+@Entity()
+export class User {
+ @PrimaryGeneratedColumn('increment')
+ id: number;
+
+ @Column({ unique: true })
+ providerId: string; // 네이버/카카오 ID
+
+ @Column()
+ provider: string; // 'naver' 또는 'kakao'
+
+ @Column()
+ email: string;
+
+ @Column({ nullable: true })
+ nickname: string;
+
+ @Column({ nullable: true })
+ profileImage: string;
+
+ @CreateDateColumn()
+ createdAt: Date;
+}
diff --git a/apps/backend/src/user/user.module.ts b/apps/backend/src/user/user.module.ts
new file mode 100644
index 00000000..abde4bdb
--- /dev/null
+++ b/apps/backend/src/user/user.module.ts
@@ -0,0 +1,11 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { User } from './user.entity';
+import { UserRepository } from './user.repository';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([User])],
+ providers: [UserRepository],
+ exports: [TypeOrmModule, UserRepository],
+})
+export class UserModule {}
diff --git a/apps/backend/src/user/user.repository.ts b/apps/backend/src/user/user.repository.ts
new file mode 100644
index 00000000..b3fdf580
--- /dev/null
+++ b/apps/backend/src/user/user.repository.ts
@@ -0,0 +1,12 @@
+// user.repository.ts
+import { DataSource, Repository } from 'typeorm';
+import { User } from './user.entity';
+import { Injectable } from '@nestjs/common';
+import { InjectDataSource } from '@nestjs/typeorm';
+
+@Injectable()
+export class UserRepository extends Repository {
+ constructor(@InjectDataSource() private dataSource: DataSource) {
+ super(User, dataSource.createEntityManager());
+ }
+}
diff --git a/apps/backend/src/yjs/yjs.class.ts b/apps/backend/src/yjs/yjs.class.ts
new file mode 100644
index 00000000..c071a055
--- /dev/null
+++ b/apps/backend/src/yjs/yjs.class.ts
@@ -0,0 +1,11 @@
+import * as Y from 'yjs';
+
+// Y.Doc에는 name 컬럼이 없어서 생성했습니다.
+export class CustomDoc extends Y.Doc {
+ name: string;
+
+ constructor(name: string) {
+ super();
+ this.name = name;
+ }
+}
diff --git a/apps/backend/src/yjs/yjs.module.ts b/apps/backend/src/yjs/yjs.module.ts
new file mode 100644
index 00000000..1d498885
--- /dev/null
+++ b/apps/backend/src/yjs/yjs.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { YjsService } from './yjs.service';
+import { NodeModule } from 'src/node/node.module';
+import { NodeCacheModule } from 'src/node-cache/node-cache.module';
+import { PageModule } from '../page/page.module';
+import { EdgeModule } from '../edge/edge.module';
+
+@Module({
+ imports: [NodeModule, PageModule, EdgeModule, NodeCacheModule],
+ providers: [YjsService],
+})
+export class YjsModule {}
diff --git a/apps/backend/src/yjs/yjs.schema.ts b/apps/backend/src/yjs/yjs.schema.ts
new file mode 100644
index 00000000..a2df703a
--- /dev/null
+++ b/apps/backend/src/yjs/yjs.schema.ts
@@ -0,0 +1,178 @@
+import { Schema } from 'prosemirror-model';
+
+export const novelEditorSchema = new Schema({
+ nodes: {
+ doc: { content: 'block+' }, // 문서 루트 노드
+
+ paragraph: {
+ content: 'text*',
+ group: 'block',
+ toDOM: () => ['p', 0],
+ parseDOM: [{ tag: 'p' }],
+ },
+
+ text: { group: 'inline' }, // 텍스트 노드
+
+ taskList: {
+ content: 'taskItem+',
+ group: 'block',
+ toDOM: () => ['ul', 0],
+ parseDOM: [{ tag: 'ul' }],
+ },
+
+ taskItem: {
+ content: 'paragraph*',
+ attrs: { checked: { default: false } },
+ toDOM: (node) => [
+ 'li',
+ { class: node.attrs.checked ? 'checked' : '' },
+ 0,
+ ],
+ parseDOM: [
+ {
+ tag: 'li',
+ getAttrs(dom) {
+ return { checked: dom.classList.contains('checked') };
+ },
+ },
+ ],
+ },
+
+ heading: {
+ attrs: { level: { default: 1 } },
+ content: 'text*',
+ group: 'block',
+ toDOM: (node) => [`h${node.attrs.level}`, 0],
+ parseDOM: [
+ { tag: 'h1', attrs: { level: 1 } },
+ { tag: 'h2', attrs: { level: 2 } },
+ { tag: 'h3', attrs: { level: 3 } },
+ ],
+ },
+
+ bulletList: {
+ content: 'listItem+',
+ group: 'block',
+ attrs: { tight: { default: false } },
+ toDOM: () => ['ul', 0],
+ parseDOM: [{ tag: 'ul' }],
+ },
+
+ orderedList: {
+ content: 'listItem+',
+ group: 'block',
+ attrs: { tight: { default: false }, start: { default: 1 } },
+ toDOM: (node) => ['ol', { start: node.attrs.start }, 0],
+ parseDOM: [{ tag: 'ol' }],
+ },
+
+ listItem: {
+ content: 'paragraph*',
+ toDOM: () => ['li', 0],
+ parseDOM: [{ tag: 'li' }],
+ },
+
+ codeBlock: {
+ content: 'text*',
+ attrs: { language: { default: null } },
+ toDOM: (node) => ['pre', ['code', { class: node.attrs.language }, 0]],
+ parseDOM: [
+ {
+ tag: 'pre',
+ getAttrs(dom) {
+ return { language: dom.getAttribute('class') };
+ },
+ },
+ ],
+ },
+
+ blockquote: {
+ content: 'paragraph+',
+ group: 'block',
+ toDOM: () => ['blockquote', 0],
+ parseDOM: [{ tag: 'blockquote' }],
+ },
+
+ youtube: {
+ attrs: {
+ src: {},
+ start: { default: 0 },
+ width: { default: 640 },
+ height: { default: 480 },
+ },
+ toDOM: (node) => [
+ 'iframe',
+ {
+ src: node.attrs.src,
+ width: node.attrs.width,
+ height: node.attrs.height,
+ frameborder: '0',
+ allow:
+ 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; web-share',
+ allowfullscreen: true,
+ },
+ ],
+ parseDOM: [
+ {
+ tag: 'iframe',
+ getAttrs(dom) {
+ return {
+ src: dom.getAttribute('src'),
+ width: dom.getAttribute('width'),
+ height: dom.getAttribute('height'),
+ };
+ },
+ },
+ ],
+ },
+
+ twitter: {
+ attrs: {
+ src: {},
+ },
+ toDOM: (node) => [
+ 'blockquote',
+ { class: 'twitter-tweet' },
+ ['a', { href: node.attrs.src }, 0],
+ ],
+ parseDOM: [
+ {
+ tag: 'blockquote.twitter-tweet',
+ getAttrs(dom) {
+ return { src: dom.querySelector('a')?.getAttribute('href') };
+ },
+ },
+ ],
+ },
+
+ image: {
+ inline: true,
+ attrs: {
+ src: {},
+ alt: { default: null },
+ title: { default: null },
+ },
+ group: 'inline',
+ draggable: true,
+ parseDOM: [
+ {
+ tag: 'img[src]',
+ getAttrs: (dom) => ({
+ src: (dom as HTMLElement).getAttribute('src'),
+ alt: (dom as HTMLElement).getAttribute('alt'),
+ title: (dom as HTMLElement).getAttribute('title'),
+ }),
+ },
+ ],
+ toDOM: (node) => [
+ 'img',
+ {
+ src: node.attrs.src,
+ alt: node.attrs.alt,
+ title: node.attrs.title,
+ },
+ ],
+ },
+ },
+ marks: {},
+});
diff --git a/apps/backend/src/yjs/yjs.service.spec.ts b/apps/backend/src/yjs/yjs.service.spec.ts
new file mode 100644
index 00000000..61a0b9b3
--- /dev/null
+++ b/apps/backend/src/yjs/yjs.service.spec.ts
@@ -0,0 +1,303 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { PageService } from '../page/page.service';
+import { EdgeService } from '../edge/edge.service';
+import { NodeService } from '../node/node.service';
+import { YjsService } from './yjs.service';
+
+import { NodeCacheService } from '../node-cache/node-cache.service';
+
+describe('PageService', () => {
+ const dummyNovelData = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'asdf',
+ },
+ ],
+ },
+ {
+ type: 'taskList',
+ content: [
+ {
+ type: 'taskItem',
+ attrs: {
+ checked: false,
+ },
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: '아녕하세요',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'taskItem',
+ attrs: {
+ checked: false,
+ },
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: '하하',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'heading',
+ attrs: {
+ level: 1,
+ },
+ content: [
+ {
+ type: 'text',
+ text: '제목임',
+ },
+ ],
+ },
+ {
+ type: 'heading',
+ attrs: {
+ level: 2,
+ },
+ content: [
+ {
+ type: 'text',
+ text: '네목임',
+ },
+ ],
+ },
+ {
+ type: 'heading',
+ attrs: {
+ level: 3,
+ },
+ content: [
+ {
+ type: 'text',
+ text: '세목임',
+ },
+ ],
+ },
+ {
+ type: 'bulletList',
+ attrs: {
+ tight: true,
+ },
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'gfsd',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'gfsd',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'hgfd',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'orderedList',
+ attrs: {
+ tight: true,
+ start: 1,
+ },
+ content: [
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'gsf',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'hgfddf',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'fd',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'codeBlock',
+ attrs: {
+ language: null,
+ },
+ content: [
+ {
+ type: 'text',
+ text: 'codingdi',
+ },
+ ],
+ },
+ {
+ type: 'blockquote',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ {
+ type: 'text',
+ text: 'dlsdydla',
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: 'youtube',
+ attrs: {
+ src: 'https://www.youtube.com/watch?v=_2p2pFUoS5c',
+ start: 0,
+ width: 640,
+ height: 480,
+ },
+ },
+ {
+ type: 'paragraph',
+ },
+ {
+ type: 'twitter',
+ attrs: {
+ src: 'https://x.com/withyou3542',
+ },
+ },
+ {
+ type: 'paragraph',
+ },
+ {
+ type: 'paragraph',
+ },
+ ],
+ };
+ let yjsService: YjsService;
+ let pageService: PageService;
+ let nodeService: NodeService;
+ let edgeService: EdgeService;
+ let nodeCacheService: NodeCacheService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ YjsService,
+ {
+ provide: PageService,
+ useValue: {},
+ },
+ {
+ provide: NodeService,
+ useValue: {},
+ },
+ {
+ provide: EdgeService,
+ useValue: {},
+ },
+ {
+ provide: NodeCacheService,
+ useValue: {},
+ },
+ {
+ provide: NodeCacheService,
+ useValue: {},
+ },
+ ],
+ }).compile();
+
+ yjsService = module.get(YjsService);
+ pageService = module.get(PageService);
+ nodeService = module.get(NodeService);
+ edgeService = module.get(EdgeService);
+ nodeCacheService = module.get(NodeCacheService);
+ });
+
+ it('서비스 클래스가 정상적으로 인스턴스화된다.', () => {
+ expect(yjsService).toBeDefined();
+ expect(pageService).toBeDefined();
+ expect(nodeService).toBeDefined();
+ expect(edgeService).toBeDefined();
+ expect(nodeCacheService).toBeDefined();
+ });
+
+ it('모든 페이지 목록을 조회할 수 있다.', async () => {});
+
+ describe('createPage', () => {
+ it('페이지를 성공적으로 생성한다.', async () => {});
+ });
+});
diff --git a/apps/backend/src/yjs/yjs.service.ts b/apps/backend/src/yjs/yjs.service.ts
new file mode 100644
index 00000000..6f61a1bd
--- /dev/null
+++ b/apps/backend/src/yjs/yjs.service.ts
@@ -0,0 +1,232 @@
+import {
+ OnGatewayConnection,
+ OnGatewayDisconnect,
+ OnGatewayInit,
+ WebSocketGateway,
+ WebSocketServer,
+} from '@nestjs/websockets';
+import { Logger } from '@nestjs/common';
+import { Server } from 'socket.io';
+import { YSocketIO } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+import { NodeService } from '../node/node.service';
+import { PageService } from '../page/page.service';
+import { NodeCacheService } from '../node-cache/node-cache.service';
+import {
+ yXmlFragmentToProsemirrorJSON,
+ prosemirrorJSONToYXmlFragment,
+} from 'y-prosemirror';
+import { novelEditorSchema } from './yjs.schema';
+import { EdgeService } from '../edge/edge.service';
+import { Node } from 'src/node/node.entity';
+import { Edge } from 'src/edge/edge.entity';
+import { YMapEdge } from './yjs.type';
+
+// Y.Doc에는 name 컬럼이 없어서 생성했습니다.
+class CustomDoc extends Y.Doc {
+ name: string;
+
+ constructor(name: string) {
+ super();
+ this.name = name;
+ }
+}
+
+@WebSocketGateway(1234)
+export class YjsService
+ implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
+{
+ private logger = new Logger('YjsGateway');
+ private ysocketio: YSocketIO;
+
+ constructor(
+ private readonly nodeService: NodeService,
+ private readonly pageService: PageService,
+ private readonly edgeService: EdgeService,
+ private readonly nodeCacheService: NodeCacheService,
+ ) {}
+
+ @WebSocketServer()
+ server: Server;
+
+ // insertProseMirrorDataToXmlFragment(xmlFragment: Y.XmlFragment, data: any[]) {
+ // // XML Fragment 초기화
+ // xmlFragment.delete(0, xmlFragment.length);
+
+ // // 데이터를 순회하면서 추가
+ // data.forEach((nodeData) => {
+ // const yNode = new Y.XmlElement(nodeData.type);
+
+ // if (nodeData.content) {
+ // nodeData.content.forEach((child) => {
+ // if (child.type === 'text') {
+ // const yText = new Y.XmlText();
+ // yText.insert(0, child.text);
+ // yNode.push([yText]);
+ // }
+ // });
+ // }
+
+ // xmlFragment.push([yNode]);
+ // });
+ // }
+
+ afterInit() {
+ if (!this.server) {
+ this.logger.error('서버 초기화 안됨..!');
+ this.server = new Server();
+ }
+
+ this.ysocketio = new YSocketIO(this.server, {
+ gcEnabled: true,
+ });
+
+ this.ysocketio.initialize();
+ }
+
+ // this.ysocketio.on('document-loaded', async (doc: Y.Doc) => {
+ // // Y.Doc에 name이 없어서 새로 만든 CustomDoc
+ // const editorDoc = doc.getXmlFragment('default');
+ // const customDoc = editorDoc.doc as CustomDoc;
+
+ // // document name이 flow-room이라면 모든 노드들을 볼 수 있는 화면입니다.
+ // // 노드를 클릭해 페이지를 열었을 때만 해당 페이지 값을 가져와서 초기 데이터로 세팅해줍니다.
+ // if (customDoc.name?.startsWith('document-')) {
+ // const pageId = parseInt(customDoc.name.split('-')[1]);
+ // const findPage = await this.pageService.findPageById(pageId);
+
+ // // content가 비어있다면 내부 구조가 novel editor schema를 따르지 않기 때문에 오류가 납니다.
+ // // content가 존재할 때만 넣어줍니다.
+ // const pageContent = JSON.parse(JSON.stringify(findPage.content));
+ // const novelEditorContent = {
+ // type: 'doc',
+ // content: pageContent,
+ // };
+ // pageContent.length > 0 &&
+ // // JSON.parse(findPage.content).length > 0 &&
+ // this.initializePageContent(novelEditorContent, editorDoc);
+
+ // // 페이지 내용 변경 사항을 감지해서 데이터베이스에 갱신합니다.
+ // editorDoc.observeDeep(() => {
+ // const document = editorDoc.doc as CustomDoc;
+ // const pageId = parseInt(document.name.split('-')[1]);
+ // this.pageService.updatePage(
+ // pageId,
+ // JSON.parse(
+ // JSON.stringify(yXmlFragmentToProsemirrorJSON(editorDoc)),
+ // ),
+ // );
+ // });
+ // return;
+ // }
+
+ // // 만약 페이지가 아닌 모든 노드들을 볼 수 있는 document라면 node, edge 초기 데이터를 세팅해줍니다.
+ // // node, edge, page content 가져오기
+ // const nodes = await this.nodeService.findNodes();
+ // const edges = await this.edgeService.findEdges();
+ // const nodesMap = doc.getMap('nodes');
+ // const edgesMap = doc.getMap('edges');
+
+ // this.initializeYNodeMap(nodes, nodesMap);
+ // this.initializeYEdgeMap(edges, edgesMap);
+
+ // // node의 변경 사항을 감지한다.
+ // nodesMap.observe(async () => {
+ // const nodes = Object.values(doc.getMap('nodes').toJSON());
+
+ // // 모든 노드에 대해 검사한다.
+ // for await (const node of nodes) {
+ // const { title, id } = node.data; // TODO: 이모지 추가
+ // const { x, y } = node.position;
+ // const isHolding = node.isHolding;
+ // const updateCondition =
+ // !(await this.nodeCacheService.has(id)) ||
+ // !(await this.nodeCacheService.hasSameTitle(id, title)) ||
+ // !(await this.nodeCacheService.isHoldingStatusChanged(
+ // id,
+ // isHolding,
+ // ));
+
+ // if (updateCondition) {
+ // await this.nodeService.updateNode(id, { title, x, y });
+ // await this.nodeCacheService.set(id, { title, isHolding });
+ // }
+ // }
+ // });
+
+ // // edge의 변경 사항을 감지한다.
+ // edgesMap.observe(async () => {
+ // const edges: YMapEdge[] = Object.values(doc.getMap('edges').toJSON());
+
+ // for await (const edge of edges) {
+ // const findEdge = await this.edgeService.findEdgeByFromNodeAndToNode(
+ // parseInt(edge.source),
+ // parseInt(edge.target),
+ // );
+
+ // // 연결된 노드가 없을 때만 edge 생성
+ // if (!findEdge) {
+ // await this.edgeService.createEdge({
+ // fromNode: parseInt(edge.source),
+ // toNode: parseInt(edge.target),
+ // });
+ // }
+ // }
+ // });
+ // });
+ // }
+
+ // // YMap에 노드 정보를 넣어준다.
+ // initializeYNodeMap(nodes: Node[], yMap: Y.Map