├── .github ├── ISSUE_TEMPLATE │ ├── bug--refactor.md │ ├── etc.md │ └── feat.md ├── pull_request_template.md └── workflows │ ├── backend-rest-deploy.yml │ ├── backend-socket-deploy .yml │ └── frontend-deploy.yml ├── .gitignore ├── .husky ├── pre-commit ├── pre-push └── prepare-commit-msg ├── README.md ├── backend ├── rest │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── app.module.ts │ │ ├── auth │ │ │ ├── auth.module.ts │ │ │ ├── controller │ │ │ │ └── auth.controller.ts │ │ │ ├── dto │ │ │ │ ├── create-jwt.builder.ts │ │ │ │ ├── create-jwt.dto.ts │ │ │ │ ├── redirect-url.dto.ts │ │ │ │ └── user-id.dto.ts │ │ │ ├── guard │ │ │ │ └── jwt.guard.ts │ │ │ ├── service │ │ │ │ ├── auth.service.spec.ts │ │ │ │ ├── auth.service.ts │ │ │ │ └── oauth │ │ │ │ │ ├── interface-oauth.service.ts │ │ │ │ │ ├── kakao-oauth.service.ts │ │ │ │ │ └── naver-oauth.service.ts │ │ │ └── strategy │ │ │ │ ├── access-jwt.strategy.ts │ │ │ │ └── refresh-jwt.strategy.ts │ │ ├── common │ │ │ ├── base.builder.ts │ │ │ ├── index.ts │ │ │ └── typeorm-base.entity.ts │ │ ├── config │ │ │ ├── env.config.ts │ │ │ ├── index.ts │ │ │ ├── pipe.config.ts │ │ │ ├── swagger.config.ts │ │ │ └── typeorm.config.ts │ │ ├── constant │ │ │ ├── auth.constant.ts │ │ │ ├── env.constant.ts │ │ │ ├── exception-message.constant.ts │ │ │ ├── index.ts │ │ │ ├── interview.constant.ts │ │ │ └── swagger.constant.ts │ │ ├── filter │ │ │ └── http-exception.filter.ts │ │ ├── interceptor │ │ │ └── http.interceptor.ts │ │ ├── interview │ │ │ ├── controller │ │ │ │ └── interview.controller.ts │ │ │ ├── dto │ │ │ │ ├── docs-list.dto.ts │ │ │ │ ├── docs.dto.ts │ │ │ │ ├── feedback.dto.ts │ │ │ │ ├── request-docs.dto.ts │ │ │ │ └── response-docs.builder.ts │ │ │ ├── entities │ │ │ │ ├── feedback.entity.ts │ │ │ │ ├── interview-docs.entity.ts │ │ │ │ ├── typeorm-feedback.builder.ts │ │ │ │ ├── typeorm-feedback.entity.ts │ │ │ │ ├── typeorm-interview-docs.builder.ts │ │ │ │ └── typeorm-interview-docs.entity.ts │ │ │ ├── interview.module.ts │ │ │ ├── repository │ │ │ │ ├── interview.repository.ts │ │ │ │ └── typeorm-interview.repository.ts │ │ │ └── service │ │ │ │ └── interview.service.ts │ │ ├── main.ts │ │ ├── types │ │ │ ├── auth.type.ts │ │ │ ├── index.ts │ │ │ ├── interview.type.ts │ │ │ ├── mock.type.ts │ │ │ └── query.type.ts │ │ └── user │ │ │ ├── dto │ │ │ ├── create-user.dto.ts │ │ │ └── update-user.dto.ts │ │ │ ├── entities │ │ │ ├── typeorm-user.builder.ts │ │ │ ├── typeorm-user.entity.ts │ │ │ └── user.entity.ts │ │ │ ├── repository │ │ │ ├── typeorm-user.repository.ts │ │ │ └── user.repository.ts │ │ │ └── user.module.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ ├── jest-e2e.json │ │ └── test.service.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.path.json │ └── yarn.lock └── socket │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── @types │ └── woowa-babble.d.ts │ ├── Dockerfile │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── src │ ├── app.module.ts │ ├── config │ │ ├── env.config.ts │ │ ├── index.ts │ │ ├── pipe.config.ts │ │ └── redis.adapter.ts │ ├── constant │ │ ├── env.constant.ts │ │ ├── event.constant.ts │ │ ├── index.ts │ │ ├── object-storage.constant.ts │ │ ├── room.constant.ts │ │ └── user.constant.ts │ ├── filter │ │ └── socket-exception.filter.ts │ ├── interceptor │ │ └── socket-response.interceptor.ts │ ├── main.ts │ ├── room │ │ ├── dto │ │ │ ├── chat.dto.ts │ │ │ ├── socket-response.dto.ts │ │ │ ├── update-media-info.dto.ts │ │ │ ├── user.dto.ts │ │ │ └── webrtc.dto.ts │ │ ├── repository │ │ │ ├── inmemory-room.repository.ts │ │ │ ├── redis-room.repository.ts │ │ │ └── room.repository.ts │ │ ├── room.gateway.ts │ │ ├── room.module.ts │ │ └── service │ │ │ ├── chat │ │ │ └── chat.service.ts │ │ │ ├── connection │ │ │ ├── connection.service.spec.ts │ │ │ └── connection.service.ts │ │ │ ├── interview │ │ │ ├── interview.service.spec.ts │ │ │ └── interview.service.ts │ │ │ ├── objectstorage │ │ │ └── objectstorage.service.ts │ │ │ └── webRTC │ │ │ └── webrtc.service.ts │ └── types │ │ ├── index.ts │ │ ├── mock.type.ts │ │ └── room.type.ts │ ├── test │ ├── app.e2e-spec.ts │ ├── jest-e2e.json │ └── stress │ │ └── stress.yaml │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.path.json │ ├── util │ └── rest-api.util.ts │ └── yarn.lock ├── changelog.config.js ├── dump.rdb ├── frontend ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── .storybook │ ├── main.js │ └── preview.js ├── craco.config.js ├── package.json ├── public │ ├── assets │ │ └── test.mp4 │ └── index.html ├── src │ ├── App.tsx │ ├── api │ │ └── rest.api.ts │ ├── assets │ │ ├── icon │ │ │ ├── back.svg │ │ │ ├── broadcast.svg │ │ │ ├── camera_off.svg │ │ │ ├── camera_on.svg │ │ │ ├── chat.svg │ │ │ ├── check.svg │ │ │ ├── close.svg │ │ │ ├── copy.svg │ │ │ ├── delete.svg │ │ │ ├── download.svg │ │ │ ├── edit.svg │ │ │ ├── enter.svg │ │ │ ├── exit.svg │ │ │ ├── folder.svg │ │ │ ├── github.svg │ │ │ ├── kakao.svg │ │ │ ├── link.svg │ │ │ ├── mic_off.svg │ │ │ ├── mic_on.svg │ │ │ ├── naver.svg │ │ │ ├── next.svg │ │ │ ├── pause.svg │ │ │ ├── plus.svg │ │ │ ├── record.svg │ │ │ ├── stop.svg │ │ │ ├── user.svg │ │ │ ├── users.svg │ │ │ └── video.svg │ │ ├── logo_black.svg │ │ ├── logo_white.svg │ │ ├── preview.svg │ │ ├── preview_error.svg │ │ ├── preview_sync.svg │ │ └── sync_dot_line.svg │ ├── components │ │ ├── @drawer │ │ │ ├── ChatDrawer │ │ │ │ ├── ChatDrawer.style.ts │ │ │ │ └── ChatDrawer.tsx │ │ │ └── UserDrawer │ │ │ │ ├── UserDrawer.style.ts │ │ │ │ └── UserDrawer.tsx │ │ ├── @modal │ │ │ ├── CancelInterviewModal.tsx │ │ │ ├── EndFeedbackModal.tsx │ │ │ ├── EndInterviewModal.tsx │ │ │ ├── EnterRoomModal.tsx │ │ │ ├── ExitRoomModal.tsx │ │ │ ├── InterviewDocsModal │ │ │ │ ├── InterviewDocsModal.style.ts │ │ │ │ └── InterviewDocsModal.tsx │ │ │ ├── NotStreamModal.tsx │ │ │ ├── RoomInfoModal.tsx │ │ │ ├── StartInterviewModal.tsx │ │ │ └── TimeOverAlertModal.tsx │ │ ├── @shared │ │ │ ├── BottomBarButton │ │ │ │ └── BottomBarButton.tsx │ │ │ ├── Button │ │ │ │ ├── Button.stories.tsx │ │ │ │ ├── Button.style.ts │ │ │ │ └── Button.tsx │ │ │ ├── Modal │ │ │ │ ├── Modal.stories.tsx │ │ │ │ ├── Modal.style.ts │ │ │ │ └── Modal.tsx │ │ │ ├── RoundButton │ │ │ │ ├── RoundButton.stories.tsx │ │ │ │ ├── RoundButton.style.ts │ │ │ │ └── RoundButton.tsx │ │ │ ├── StreamingVideo │ │ │ │ ├── StreamVideo.stories.tsx │ │ │ │ ├── StreamVideo.style.ts │ │ │ │ └── StreamVideo.tsx │ │ │ ├── TextArea │ │ │ │ └── TextArea.tsx │ │ │ ├── TextField │ │ │ │ ├── TextField.stories.tsx │ │ │ │ ├── TextField.style.ts │ │ │ │ └── TextField.tsx │ │ │ ├── Video │ │ │ │ ├── Video.stories.tsx │ │ │ │ ├── Video.style.ts │ │ │ │ └── Video.tsx │ │ │ └── VideoGrid │ │ │ │ ├── VideoGrid.stories.tsx │ │ │ │ ├── VideoGrid.style.ts │ │ │ │ └── VideoGrid.tsx │ │ ├── BottomBar │ │ │ ├── BottomBar.style.ts │ │ │ └── BottomBar.tsx │ │ ├── FeedbackEditBtns │ │ │ ├── FeedbackEditBtns.style.ts │ │ │ └── FeedbackEditBtns.tsx │ │ ├── FeedbackForm │ │ │ ├── FeedbackForm.style.ts │ │ │ └── FeedbackForm.tsx │ │ ├── FeedbackItem │ │ │ ├── FeedbackItem.style.ts │ │ │ └── FeedbackItem.tsx │ │ ├── FeedbackList │ │ │ ├── FeedbackList.style.ts │ │ │ └── FeedbackList.tsx │ │ ├── InterviewDocsDetail │ │ │ ├── InterviewDocsDetail.style.tsx │ │ │ └── InterviewDocsDetail.tsx │ │ ├── InterviewDocsItem │ │ │ ├── InterviewDocsItem.style.ts │ │ │ └── InterviewDocsItem.tsx │ │ ├── InterviewDocsList │ │ │ ├── InterviewDocsList.style.ts │ │ │ └── InterviewDocsList.tsx │ │ ├── IntervieweeVideo │ │ │ ├── IntervieweeVideo.stories.tsx │ │ │ └── IntervieweeVideo.tsx │ │ ├── Loading │ │ │ └── Loading.tsx │ │ ├── ModalManager.tsx │ │ ├── RecordTimeLabel │ │ │ ├── RecordTimeLabel.style.ts │ │ │ └── RecordTimeLabel.tsx │ │ └── ToastManager.tsx │ ├── constants │ │ ├── oauth.constant.ts │ │ ├── page.constant.ts │ │ ├── rest.constant.ts │ │ ├── role.constant.ts │ │ ├── route.constant.ts │ │ ├── socket.constant.ts │ │ ├── statusCode.constants.ts │ │ └── time.constant.ts │ ├── customType │ │ ├── dto.ts │ │ ├── feedback.d.ts │ │ ├── svgComponent.d.ts │ │ └── user.d.ts │ ├── hooks │ │ ├── useAddFeedback.ts │ │ ├── useCleanupInterview.ts │ │ ├── useCleanupRoom.ts │ │ ├── useCommonSocketEvent.ts │ │ ├── useEditFeedback.ts │ │ ├── useMediaStreamer.ts │ │ ├── useModal.tsx │ │ ├── usePreventLeave.ts │ │ ├── useSafeNavigate.ts │ │ ├── useSafeNavigator.ts │ │ ├── useSocket.ts │ │ ├── useToast.tsx │ │ ├── useUserRole.ts │ │ └── useWebRTCSignaling.ts │ ├── index.tsx │ ├── pages │ │ ├── Feedback │ │ │ ├── Feedback.style.ts │ │ │ └── Feedback.tsx │ │ ├── Interviewee │ │ │ ├── Interviewee.style.tsx │ │ │ └── Interviewee.tsx │ │ ├── Interviewer │ │ │ ├── Interviewer.style.tsx │ │ │ └── Interviewer.tsx │ │ ├── Landing │ │ │ ├── Landing.style.ts │ │ │ └── Landing.tsx │ │ ├── Lobby │ │ │ ├── Lobby.style.ts │ │ │ └── Lobby.tsx │ │ ├── Login │ │ │ ├── Login.style.ts │ │ │ └── Login.tsx │ │ ├── NotFound │ │ │ ├── NotFound.tsx │ │ │ └── NotFount.style.ts │ │ └── Waiting │ │ │ ├── Waiting.style.ts │ │ │ └── Waiting.tsx │ ├── routes │ │ ├── PrivateRoutes.tsx │ │ ├── PublicRoutes.tsx │ │ ├── RootRoutes.tsx │ │ └── StrictRoute.tsx │ ├── service │ │ └── socket.ts │ ├── setupProxy.js │ ├── store │ │ ├── auth.store.ts │ │ ├── chatList.store.ts │ │ ├── currentModal.store.ts │ │ ├── currentVideoTime.store.ts │ │ ├── feedback.store.ts │ │ ├── interview.store.ts │ │ ├── interviewDocs.store.ts │ │ ├── page.store.ts │ │ ├── room.store.ts │ │ ├── toast.store.ts │ │ └── user.store.ts │ ├── styles │ │ ├── commonStyle.ts │ │ ├── globalStyle.ts │ │ └── theme.ts │ └── utils │ │ ├── common.util.ts │ │ ├── getPathWithPage.ts │ │ └── lowerBound.ts ├── tsconfig.json ├── tsconfig.paths.json └── yarn.lock ├── package.json ├── yarn-error.log └── yarn.lock /.github/ISSUE_TEMPLATE/bug--refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug, Refactor 3 | about: 버그, 리팩토링 이슈 템플릿 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## As-Is 11 | 12 | (현재 어떤 상황인지) 13 | 14 | ## To-Be 15 | 16 | (어떻게 되길 바라는지) 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/etc.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Etc 3 | about: 기타 이슈 템플릿 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 이슈 내용 11 | 12 | (자유 양식입니다) 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feat.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feat 3 | about: 기능 이슈 템플릿 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 구현 기능 11 | 12 | (구현하는 기능 설명) 13 | 14 | ## 참조 15 | 16 | (기능 구현에 필요한 참조 소스, 설계 기록, 레퍼런스 등) 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 작업 중인 PR이라면 제목에 [WIP]을 작성해주세요. 2 | 3 | # PR 목적 / 요약 4 | 5 | 6 | # 관련 이슈 7 | 8 | 9 | # 리뷰받고 싶은 부분 설명 10 | 11 | 12 | # 특이사항 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/backend-socket-deploy .yml: -------------------------------------------------------------------------------- 1 | name: Backend Socket Server Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | docker-build-push: 9 | runs-on: ubuntu-latest 10 | defaults: 11 | run: 12 | working-directory: ./backend/socket 13 | 14 | steps: 15 | - name: repository의 파일을 가상 인스턴스로 복사합니다. 16 | uses: actions/checkout@v3 17 | 18 | - name: docker 관련 로직 처리를 위한 buildx를 설치합니다. 19 | uses: docker/setup-buildx-action@v2 20 | 21 | - name: docker hub에 로그인 합니다. 22 | uses: docker/login-action@v2 23 | with: 24 | username: ${{ secrets.DOCKER_HUB_ID }} 25 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 26 | 27 | - name: docker 이미지를 build 및 push합니다. 28 | uses: docker/build-push-action@v3 29 | with: 30 | context: ./backend/socket 31 | push: true 32 | tags: ${{ secrets.DOCKER_SOCKET_IMAGE }}:${{ secrets.VERSION }},${{ secrets.DOCKER_SOCKET_IMAGE }}:latest 33 | cache-from: type=gha 34 | cache-to: type=gha,mode=max 35 | 36 | docker-run: 37 | needs: docker-build-push 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - name: 배포 서버에서 docker image를 받아서 container를 실행시킵니다. 42 | uses: appleboy/ssh-action@master 43 | with: 44 | username: ${{ secrets.SOCKET_SERVER_USER }} 45 | password: ${{ secrets.SOCKET_SERVER_PWD }} 46 | host: ${{ secrets.SOCKET_SERVER_HOST }} 47 | port: ${{ secrets.SOCKET_SERVER_PORT }} 48 | 49 | script: | 50 | docker-compose down 51 | docker pull ${{ secrets.DOCKER_SOCKET_IMAGE }} 52 | docker rmi $(docker images -f "dangling=true" -q) 53 | docker-compose up -d 54 | -------------------------------------------------------------------------------- /.github/workflows/frontend-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Frontend Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build-push: 9 | runs-on: ubuntu-latest 10 | defaults: 11 | run: 12 | working-directory: ./frontend 13 | 14 | steps: 15 | - name: repository의 파일을 가상 인스턴스로 복사합니다. 16 | uses: actions/checkout@v3 17 | 18 | - name: node 패키지를 caching 합니다. 19 | uses: actions/cache@v3 20 | with: 21 | path: "**/node_modules" 22 | key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} 25 | 26 | - name: caching된 node 패키지가 없으면 설치합니다. 27 | if: steps.cache.outputs.cache-hit != 'true' 28 | run: yarn install 29 | 30 | - name: front 파일을 build 합니다. 31 | run: yarn build 32 | 33 | - name: build된 파일을 배포 서버에 복사합니다. 34 | uses: appleboy/scp-action@master 35 | with: 36 | host: ${{ secrets.NGINX_SERVER_HOST }} 37 | username: ${{ secrets.NGINX_SERVER_USER }} 38 | password: ${{ secrets.NGINX_SERVER_PWD }} 39 | port: ${{ secrets.NGINX_SERVER_PORT }} 40 | source: './frontend/build/*' 41 | target: '/root/interface/html' 42 | strip_components: 2 43 | rm: true 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | cd backend/rest && yarn lint && cd ../../backend/socket && yarn lint && cd ../../frontend && yarn lint 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | origin_url="https://github.com/boostcampwm-2022/web14-interface.git" 5 | 6 | url="$2" 7 | 8 | if [ "$url" != "$origin_url" ] 9 | then 10 | exit 0 11 | fi 12 | 13 | current_branch=$(git rev-parse --abbrev-ref HEAD) 14 | 15 | if [ "$current_branch" == "main" -o "$current_branch" == "dev" ] 16 | then 17 | echo "do not push in dev or main branch" 18 | exit 1 19 | fi 20 | 21 | cd server && yarn lint && yarn test 22 | 23 | exit 0 -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && node_modules/.bin/git-cz --hook || true 5 | -------------------------------------------------------------------------------- /backend/rest/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /backend/rest/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # env 38 | *.env 39 | .env.* -------------------------------------------------------------------------------- /backend/rest/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "parser": "typescript", 4 | "semi": true, 5 | "useTabs": true, 6 | "printWidth": 100, 7 | "tabWidth": 4 8 | } 9 | -------------------------------------------------------------------------------- /backend/rest/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.12.1 2 | 3 | WORKDIR /app 4 | 5 | COPY yarn.lock package.json ./ 6 | RUN yarn install 7 | 8 | COPY . . 9 | RUN yarn build 10 | 11 | CMD yarn start 12 | -------------------------------------------------------------------------------- /backend/rest/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/rest/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { envConfig, typeormConfig } from '@config'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import { UserModule } from './user/user.module'; 7 | import { InterviewModule } from './interview/interview.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigModule.forRoot(envConfig), 12 | TypeOrmModule.forRootAsync(typeormConfig), 13 | AuthModule, 14 | UserModule, 15 | InterviewModule, 16 | ], 17 | controllers: [], 18 | providers: [], 19 | }) 20 | export class AppModule {} 21 | -------------------------------------------------------------------------------- /backend/rest/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { ClassProvider, Module } from '@nestjs/common'; 2 | import { AuthService } from './service/auth.service'; 3 | import { AuthController } from './controller/auth.controller'; 4 | import { USER_REPOSITORY_INTERFACE } from '@constant'; 5 | import { UserModule } from '../user/user.module'; 6 | import { OauthNaverService } from './service/oauth/naver-oauth.service'; 7 | import { OauthKakaoService } from './service/oauth/kakao-oauth.service'; 8 | import { JwtService } from '@nestjs/jwt'; 9 | import { ConfigService } from '@nestjs/config'; 10 | import { JwtAccessStrategy } from './strategy/access-jwt.strategy'; 11 | import { JwtRefreshStrategy } from './strategy/refresh-jwt.strategy'; 12 | import { TypeormUserRepository } from '../user/repository/typeorm-user.repository'; 13 | 14 | export const UserRepository: ClassProvider = { 15 | provide: USER_REPOSITORY_INTERFACE, 16 | useClass: TypeormUserRepository, 17 | }; 18 | 19 | @Module({ 20 | imports: [UserModule], 21 | controllers: [AuthController], 22 | providers: [ 23 | AuthService, 24 | UserRepository, 25 | OauthKakaoService, 26 | OauthNaverService, 27 | JwtService, 28 | ConfigService, 29 | JwtAccessStrategy, 30 | JwtRefreshStrategy, 31 | ], 32 | }) 33 | export class AuthModule {} 34 | -------------------------------------------------------------------------------- /backend/rest/src/auth/dto/create-jwt.builder.ts: -------------------------------------------------------------------------------- 1 | import { CreateJwtPayloadDto } from 'src/auth/dto/create-jwt.dto'; 2 | import { BaseBuilder } from '../../common/base.builder'; 3 | 4 | export class JwtPayloadBuiler extends BaseBuilder { 5 | constructor() { 6 | super(CreateJwtPayloadDto); 7 | } 8 | 9 | setId(id: string) { 10 | this.instance.id = id; 11 | return this; 12 | } 13 | 14 | setEmail(email: string) { 15 | this.instance.email = email; 16 | return this; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/rest/src/auth/dto/create-jwt.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString } from 'class-validator'; 2 | 3 | export class CreateJwtPayloadDto { 4 | @IsString() 5 | id: string; 6 | 7 | @IsString() 8 | nickname: string; 9 | 10 | @IsEmail() 11 | email: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/rest/src/auth/dto/redirect-url.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class RedirectUrlResponseDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | example: 'https://nid.naver.com/oauth2.0/authorize?', 9 | description: 'redirect url입니다.', 10 | }) 11 | url: string; 12 | 13 | constructor(pageUrl: string) { 14 | this.url = pageUrl; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/rest/src/auth/dto/user-id.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class UserIdResponseDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | example: '2vLh8WexsWfhQHLQ8ao5YniAAHvk3g6-djZsdV5xn5', 9 | description: 'OAUTH user id입니다.', 10 | }) 11 | userId: string; 12 | 13 | constructor(userId: string) { 14 | this.userId = userId; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/rest/src/auth/guard/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard(['jwt-access', 'jwt-refresh']) {} 6 | -------------------------------------------------------------------------------- /backend/rest/src/auth/service/oauth/interface-oauth.service.ts: -------------------------------------------------------------------------------- 1 | import { UserSocialInfo } from '@types'; 2 | 3 | export interface OauthService { 4 | /** 5 | * 해당하는 oauth type에 따른 authorization page url을 반환합니다. 6 | */ 7 | getSocialUrl(): string; 8 | 9 | /** 10 | * social login을 승인 시 반환되는 authorization code를 전달하여 access token을 받아서 반환합니다. 11 | * @param authorizationCode social login으로 얻은 authorization code 12 | * @returns accessToken - social 인증으로 얻은 accessToken 13 | */ 14 | getAccessTokenByAuthorizationCode(authorizationCode: string): Promise; 15 | 16 | /** 17 | * accessToken으로 해당 social에 profile 조회 api를 통해 user 정보를 얻어 반환합니다. 18 | * @param accessToken 19 | * @returns userInfo 20 | */ 21 | getSocialInfoByAccessToken(accessToken: string): Promise; 22 | } 23 | -------------------------------------------------------------------------------- /backend/rest/src/auth/strategy/access-jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { Request } from 'express'; 6 | import { JwtPayload, UserInfo } from 'src/types/auth.type'; 7 | import { AuthService } from '../service/auth.service'; 8 | import { JWT_ACCESS_TOKEN_SECRET } from 'src/constant/env.constant'; 9 | 10 | @Injectable() 11 | export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') { 12 | constructor( 13 | private readonly configService: ConfigService, 14 | private readonly authService: AuthService 15 | ) { 16 | super({ 17 | ignoreExpiration: false, 18 | jwtFromRequest: ExtractJwt.fromExtractors([ 19 | (req: Request) => { 20 | const token = req?.cookies.accessToken; 21 | return token ?? null; 22 | }, 23 | ]), 24 | secretOrKey: configService.get(JWT_ACCESS_TOKEN_SECRET), 25 | passReqToCallback: true, 26 | }); 27 | } 28 | 29 | async validate(req: Request, payload: JwtPayload) { 30 | const { id, email } = payload; 31 | const { accessToken, refreshToken } = this.authService.createAccessTokenAndRefreshToken({ 32 | id, 33 | email, 34 | } as UserInfo); 35 | 36 | req.cookies.accessToken = accessToken; 37 | req.cookies.refreshToken = refreshToken; 38 | 39 | return payload; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/rest/src/auth/strategy/refresh-jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { Request } from 'express'; 6 | import { AuthService } from '../service/auth.service'; 7 | import { JwtPayload, UserInfo } from 'src/types/auth.type'; 8 | import { JWT_REFRESH_TOKEN_SECRET } from 'src/constant/env.constant'; 9 | 10 | @Injectable() 11 | export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { 12 | constructor( 13 | private readonly configService: ConfigService, 14 | private readonly authService: AuthService 15 | ) { 16 | super({ 17 | ignoreExpiration: false, 18 | jwtFromRequest: ExtractJwt.fromExtractors([ 19 | (req: Request) => { 20 | const token = req?.cookies.refreshToken; 21 | return token ?? null; 22 | }, 23 | ]), 24 | secretOrKey: configService.get(JWT_REFRESH_TOKEN_SECRET), 25 | passReqToCallback: true, 26 | }); 27 | } 28 | 29 | async validate(req: Request, payload: JwtPayload): Promise { 30 | const { id, email } = payload; 31 | const { accessToken, refreshToken } = this.authService.createAccessTokenAndRefreshToken({ 32 | id, 33 | email, 34 | } as UserInfo); 35 | 36 | req.cookies.accessToken = accessToken; 37 | req.cookies.refreshToken = refreshToken; 38 | 39 | return payload; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/rest/src/common/base.builder.ts: -------------------------------------------------------------------------------- 1 | export class BaseBuilder { 2 | public instance: T; 3 | 4 | constructor(builder: new () => T) { 5 | this.instance = new builder(); 6 | } 7 | 8 | build(): T { 9 | return this.instance; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/rest/src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.builder'; 2 | export * from './typeorm-base.entity'; 3 | -------------------------------------------------------------------------------- /backend/rest/src/common/typeorm-base.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsDate } from 'class-validator'; 2 | import { Column } from 'typeorm'; 3 | 4 | export class TypeormBaseEntity { 5 | @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) 6 | @IsDate() 7 | createdAt: Date; 8 | 9 | @Column({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) 10 | @IsDate() 11 | updatedAt: Date; 12 | 13 | @Column({ name: 'is_deleted', default: false }) 14 | @IsBoolean() 15 | isDeleted: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /backend/rest/src/config/env.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModuleOptions } from '@nestjs/config'; 2 | import Joi from 'joi'; 3 | 4 | export const envConfig: ConfigModuleOptions = { 5 | isGlobal: true, // 환경 변수를 전역으로 사용 6 | envFilePath: process.env.NODE_ENV === 'dev' ? '.env' : '.env.test', 7 | // 루트 경로에서 .env 사용 (cross-env로 환경에 따른 .env 적용도 가능) 8 | // public에 올리면 env 파일의 변수 목록이 공개되어 안 좋지 않을까.. 9 | validationSchema: Joi.object({ 10 | DB_USER: Joi.string().required(), 11 | DB_PASSWORD: Joi.string().required(), 12 | DB_NAME: Joi.string().required(), 13 | 14 | JWT_ACCESS_TOKEN_SECRET: Joi.string().required(), 15 | JWT_REFRESH_TOKEN_SECRET: Joi.string().required(), 16 | 17 | NAVER_CLIENT_SECRET: Joi.string().required(), 18 | KAKAO_CLIENT_SECRET: Joi.string().required(), 19 | CLIENT_ORIGIN_URL: Joi.string().required(), 20 | 21 | SWAGGER_USER: Joi.string().required(), 22 | SWAGGER_PASSWORD: Joi.string().required(), 23 | }), 24 | }; 25 | -------------------------------------------------------------------------------- /backend/rest/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env.config'; 2 | export * from './typeorm.config'; 3 | export * from './swagger.config'; 4 | -------------------------------------------------------------------------------- /backend/rest/src/config/pipe.config.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipeOptions } from '@nestjs/common'; 2 | 3 | export const pipeOptions: ValidationPipeOptions = { 4 | transform: true, 5 | whitelist: true, 6 | forbidNonWhitelisted: true, 7 | forbidUnknownValues: true, 8 | }; 9 | -------------------------------------------------------------------------------- /backend/rest/src/config/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | import expressBasicAuth from 'express-basic-auth'; 4 | 5 | const swaggerConfig = new DocumentBuilder() 6 | .setTitle('인터페이스 API 문서') 7 | .setDescription('Web14 - interface 프로젝트에서 사용하는 API를 작성한 문서입니다.') 8 | .setVersion('0.0.1') 9 | .addTag('auth') 10 | .addTag('interview') 11 | .addCookieAuth('accessToken', { 12 | type: 'http', 13 | in: 'header', 14 | scheme: 'bearer', 15 | }) 16 | .build(); 17 | 18 | export function setupSwagger(app: INestApplication) { 19 | app.use( 20 | ['/docs/api'], 21 | expressBasicAuth({ 22 | challenge: true, 23 | users: { 24 | [process.env.SWAGGER_USER]: process.env.SWAGGER_PASSWORD, 25 | }, 26 | }) 27 | ); 28 | 29 | const document = SwaggerModule.createDocument(app, swaggerConfig); 30 | SwaggerModule.setup('docs/api', app, document); 31 | } 32 | -------------------------------------------------------------------------------- /backend/rest/src/config/typeorm.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 4 | 5 | export const typeormConfig = { 6 | useFactory: async (configService: ConfigService): Promise => ({ 7 | type: 'mysql', 8 | host: 'localhost', 9 | port: 3306, 10 | username: configService.get('DB_USER'), 11 | password: configService.get('DB_PASSWORD'), 12 | database: configService.get('DB_NAME'), 13 | entities: [__dirname + '/../**/*.entity.{ts,js}'], 14 | logging: ['query', 'error'], 15 | synchronize: true, 16 | namingStrategy: new SnakeNamingStrategy(), 17 | }), 18 | inject: [ConfigService], 19 | }; 20 | -------------------------------------------------------------------------------- /backend/rest/src/constant/auth.constant.ts: -------------------------------------------------------------------------------- 1 | import { CookieOptions } from 'express'; 2 | import { JWT_ACCESS_TOKEN_SECRET, JWT_REFRESH_TOKEN_SECRET } from './env.constant'; 3 | 4 | export const USER_REPOSITORY_INTERFACE = 'UserRepository'; 5 | export const MAX_AGE = 60 * 60 * 24 * 30 * 1000; 6 | 7 | // OAUTH 8 | export enum OAUTH_TYPE { 9 | NAVER = 'naver', 10 | KAKAO = 'kakao', 11 | } 12 | 13 | export const OAUTH_CALLBACK_URL = 'api/auth/oauth/callback'; 14 | export const AUTHORIZATION_TOKEN_TYPE = 'Bearer'; 15 | 16 | // NAVER 17 | export const NAVER_AUTHORIZE_PAGE_URL = 'https://nid.naver.com/oauth2.0/authorize'; 18 | export const NAVER_ACCESS_TOKEN_URL = 'https://nid.naver.com/oauth2.0/token'; 19 | export const NAVER_PROFILE_API_URL = 'https://openapi.naver.com/v1/nid/me'; 20 | export const NAVER_CLIENT_ID = 'eSJrVQDHuXZHhx0A0VqC'; 21 | 22 | // KAKAO 23 | export const KAKAO_AUTHORIZE_PAGE_URL = 'https://kauth.kakao.com/oauth/authorize'; 24 | export const KAKAO_ACCESS_TOKEN_URL = 'https://kauth.kakao.com/oauth/token'; 25 | export const KAKAO_PROFILE_API_URL = 'https://kapi.kakao.com/v2/user/me'; 26 | export const KAKAO_CLIENT_ID = 'bc9b6bd6439f128d5ee02f0f3cccec69'; 27 | 28 | // JWT 29 | export enum JWT_TYPE { 30 | ACCESS_TOKEN = 'accessToken', 31 | REFRESH_TOKEN = 'refreshToken', 32 | } 33 | 34 | export const JWT_ACCESS_TOKEN_EXPIRATION_TIME = 60 * 5; 35 | 36 | export const accessTokenOptions = { 37 | secret: JWT_ACCESS_TOKEN_SECRET, 38 | expirationTime: JWT_ACCESS_TOKEN_EXPIRATION_TIME, 39 | }; 40 | 41 | export const JWT_REFRESH_TOKEN_EXPIRATION_TIME = 60 * 60 * 24 * 14; 42 | 43 | export const refreshTokenOptions = { 44 | secret: JWT_REFRESH_TOKEN_SECRET, 45 | expirationTime: JWT_REFRESH_TOKEN_EXPIRATION_TIME, 46 | }; 47 | 48 | export const tokenCookieOptions: CookieOptions = { 49 | httpOnly: true, 50 | maxAge: MAX_AGE, 51 | secure: true, 52 | } as const; 53 | -------------------------------------------------------------------------------- /backend/rest/src/constant/env.constant.ts: -------------------------------------------------------------------------------- 1 | export const JWT_ACCESS_TOKEN_SECRET = 'JWT_ACCESS_TOKEN_SECRET'; 2 | export const JWT_REFRESH_TOKEN_SECRET = 'JWT_REFRESH_TOKEN_SECRET'; 3 | 4 | export const NAVER_CLIENT_SECRET = 'NAVER_CLIENT_SECRET'; 5 | export const KAKAO_CLIENT_SECRET = 'KAKAO_CLIENT_SECRET'; 6 | -------------------------------------------------------------------------------- /backend/rest/src/constant/exception-message.constant.ts: -------------------------------------------------------------------------------- 1 | export enum HTTP_ERROR_MSG { 2 | UNKNOWN_OAUTH_TYPE_ERROR = '알 수 없는 OAuth Type입니다.', 3 | OAUTH_AUTHENTICATION_CANCEL = 'Social 인증이 되지 않았습니다.', 4 | NOT_FOUND_TARGET_IN_DATABASE = 'DataBase에 삭제할 대상이 존재하지 않습니다.', 5 | NOT_FOUND_MATCHED_DOCS = 'feedback을 저장할 interview docs가 존재하지 않습니다.', 6 | CANT_DELETE_ANOTHER_DOCS = '다른 유저의 interview docs를 삭제할 수 없습니다.', 7 | } 8 | -------------------------------------------------------------------------------- /backend/rest/src/constant/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.constant'; 2 | export * from './exception-message.constant'; 3 | export * from './interview.constant'; 4 | export * from './env.constant'; 5 | export * from './swagger.constant'; 6 | -------------------------------------------------------------------------------- /backend/rest/src/constant/interview.constant.ts: -------------------------------------------------------------------------------- 1 | export const INTERVIEW_REPOSITORY_INTERFACE = 'InterviewRepository'; 2 | export const OBJECT_STORAGE_ENDPOINT = 'https://kr.object.ncloudstorage.com'; 3 | export const OBJECT_STORAGE_BUCKET = 'interface'; 4 | -------------------------------------------------------------------------------- /backend/rest/src/filter/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | InternalServerErrorException, 7 | Logger, 8 | } from '@nestjs/common'; 9 | import { Request, Response } from 'express'; 10 | import { QueryFailedError } from 'typeorm'; 11 | 12 | @Catch() 13 | export class HttpExceptionFilter implements ExceptionFilter { 14 | catch(exception: Error, host: ArgumentsHost) { 15 | const logger = new Logger('EXCEPTION'); 16 | 17 | const ctx = host.switchToHttp(); 18 | const res = ctx.getResponse(); 19 | const req = ctx.getRequest(); 20 | 21 | if (!(exception instanceof HttpException || exception instanceof QueryFailedError)) { 22 | console.error(exception); 23 | exception = new InternalServerErrorException(); 24 | } 25 | 26 | const { statusCode, message, name }: any = (exception as HttpException)?.getResponse(); 27 | const { stack } = exception; 28 | 29 | logger.error(`[Request URL] ${req.url}`); 30 | logger.error(`[Exception Time] ${new Date().toISOString()}`); 31 | logger.error(`[Exception Name] ${name}`); 32 | logger.error(`[Exception Message] ${statusCode} - ${message}`); 33 | logger.error(`[Exception Stack] ${stack}`); 34 | if (exception instanceof QueryFailedError) { 35 | Logger.error(`[SQL MESSAGE] ${exception.message}`); 36 | } 37 | 38 | const response = { 39 | name, 40 | message: `${statusCode} - ${message}`, 41 | stack: stack, 42 | }; 43 | 44 | res.status(statusCode).json(response); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/rest/src/interceptor/http.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { tap, map } from 'rxjs/operators'; 4 | 5 | @Injectable() 6 | export class RestInterceptor implements NestInterceptor { 7 | intercept(context: ExecutionContext, next: CallHandler): Observable { 8 | const logger = new Logger('REST API'); 9 | const ctx = context.switchToHttp(); 10 | const req = ctx.getRequest(); 11 | 12 | logger.log(`[Request URL] ${req.method} ${req.url}`); 13 | 14 | const start = new Date().getTime(); 15 | return next 16 | .handle() 17 | .pipe( 18 | tap(() => { 19 | const end = new Date(); 20 | logger.log(`[Process Time] ${end.getTime() - start}ms`); 21 | }) 22 | ) 23 | .pipe(map((result) => ({ data: result }))); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/rest/src/interview/dto/docs-list.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsDate, IsNotEmpty, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class DocsListResponseDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | example: '1162bd9d-0db7-403c-a32d-fdd2db00ca0b', 9 | description: 'docs uuid', 10 | }) 11 | id: string; 12 | 13 | @IsNumber() 14 | @IsNotEmpty() 15 | @ApiProperty({ 16 | example: '123', 17 | description: '영상 재생시간입니다.', 18 | }) 19 | videoPlayTime: number; 20 | 21 | @IsDate() 22 | @IsNotEmpty() 23 | @ApiProperty({ 24 | example: '2022-12-13', 25 | description: '생성 날짜입니다.', 26 | }) 27 | createdAt: Date; 28 | 29 | constructor({ 30 | id, 31 | videoPlayTime, 32 | createdAt, 33 | }: { 34 | id: string; 35 | videoPlayTime: number; 36 | createdAt: Date; 37 | }) { 38 | this.id = id; 39 | this.videoPlayTime = videoPlayTime; 40 | this.createdAt = createdAt; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/rest/src/interview/dto/docs.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsDate, IsNotEmpty, IsNumber, IsString } from 'class-validator'; 3 | import { FeedbackBoxDto } from './feedback.dto'; 4 | 5 | export class DocsResponseDto { 6 | @IsString() 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | example: '1162bd9d-0db7-403c-a32d-fdd2db00ca0b', 10 | description: 'docs uuid', 11 | }) 12 | docsUUID: string; 13 | 14 | @IsNotEmpty() 15 | @IsDate() 16 | @ApiProperty({ 17 | example: '2022-12-09 18:29:24', 18 | description: '생성 시점', 19 | }) 20 | createdAt: Date; 21 | 22 | @IsNotEmpty() 23 | @IsNumber() 24 | @ApiProperty({ 25 | example: '2022-12-09 18:29:24', 26 | description: '업데이트 시점', 27 | }) 28 | videoPlayTime: number; 29 | 30 | @IsString() 31 | @IsNotEmpty() 32 | @ApiProperty({ 33 | example: 'https://naver.com~', 34 | description: 'object storage url', 35 | }) 36 | videoUrl: string; 37 | 38 | @ApiProperty({ 39 | example: 40 | '{ nickname: "엉큼한 거북이", feedbackList: [{ startTime: 0, innerIndex: 0, content: "목소리에서 울림이 느껴지네요." }] }', 41 | description: '닉네임과 피드백들', 42 | type: 'array', 43 | items: { 44 | type: 'UserFeedback', 45 | }, 46 | }) 47 | feedbacks: UserFeedback[]; 48 | } 49 | 50 | export class UserFeedback { 51 | @IsString() 52 | @IsNotEmpty() 53 | nickname: string; 54 | 55 | feedbackList: FeedbackBoxDto[]; 56 | } 57 | -------------------------------------------------------------------------------- /backend/rest/src/interview/dto/feedback.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsNotEmpty, IsNumber, IsString, ValidateNested } from 'class-validator'; 4 | 5 | export class FeedbackRequestDto { 6 | @IsString() 7 | @IsNotEmpty() 8 | @ApiProperty({ 9 | example: '1162bd9d-0db7-403c-a32d-fdd2db00ca0b', 10 | description: 'docs uuid', 11 | }) 12 | docsUUID: string; 13 | 14 | @IsNotEmpty() 15 | @ValidateNested({ each: true }) 16 | @Type(() => FeedbackBoxDto) 17 | @ApiProperty({ 18 | example: '{ startTime: 0, innerIndex: 0, content: "목소리에서 울림이 느껴지네요." }', 19 | description: 'feedbackbox (startTime, innerIndex, content)', 20 | type: 'array', 21 | items: { 22 | type: 'feedbackBoxDto', 23 | }, 24 | }) 25 | feedbackList: FeedbackBoxDto[]; 26 | } 27 | 28 | export class FeedbackBoxDto { 29 | @IsNumber() 30 | @IsNotEmpty() 31 | startTime: number; 32 | 33 | @IsNumber() 34 | @IsNotEmpty() 35 | innerIndex: number; 36 | 37 | @IsString() 38 | @IsNotEmpty() 39 | content: string; 40 | } 41 | -------------------------------------------------------------------------------- /backend/rest/src/interview/dto/request-docs.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class DocsRequestDto { 5 | @IsString() 6 | @IsNotEmpty() 7 | @ApiProperty({ 8 | example: '1162bd9d-0db7-403c-a32d-fdd2db00ca0b', 9 | description: 'docs uuid', 10 | }) 11 | docsUUID: string; 12 | 13 | @IsNumber() 14 | @IsNotEmpty() 15 | @ApiProperty({ 16 | example: 3000, 17 | description: '영상의 총 재생 시간', 18 | }) 19 | videoPlayTime: number; 20 | 21 | @IsString() 22 | @IsNotEmpty() 23 | @ApiProperty({ 24 | example: 'e8da1496-2d7c-4536-a8e5-5f6a72311e4c', 25 | description: 'room uuid', 26 | }) 27 | roomUUID: string; 28 | } 29 | -------------------------------------------------------------------------------- /backend/rest/src/interview/dto/response-docs.builder.ts: -------------------------------------------------------------------------------- 1 | import { BaseBuilder } from '@common'; 2 | import { DocsResponseDto, UserFeedback } from './docs.dto'; 3 | 4 | export class DocsResponseDtoBuilder extends BaseBuilder { 5 | constructor() { 6 | super(DocsResponseDto); 7 | } 8 | 9 | setCreatedAt(createdAt: Date) { 10 | this.instance.createdAt = createdAt; 11 | return this; 12 | } 13 | 14 | setDocsUUID(docsUUID: string) { 15 | this.instance.docsUUID = docsUUID; 16 | return this; 17 | } 18 | 19 | setVideoPlayTime(videoPlayTime: number) { 20 | this.instance.videoPlayTime = videoPlayTime; 21 | return this; 22 | } 23 | 24 | setVideoUrl(videoUrl: string) { 25 | this.instance.videoUrl = videoUrl; 26 | return this; 27 | } 28 | 29 | setFeedback(feedbacks: UserFeedback[]) { 30 | this.instance.feedbacks = feedbacks; 31 | return this; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/rest/src/interview/entities/feedback.entity.ts: -------------------------------------------------------------------------------- 1 | export interface Feedback { 2 | id: number; 3 | userId: string; 4 | startTime: number; 5 | innerIndex: number; 6 | content: string; 7 | } 8 | -------------------------------------------------------------------------------- /backend/rest/src/interview/entities/interview-docs.entity.ts: -------------------------------------------------------------------------------- 1 | export interface InterviewDocs { 2 | id: string; 3 | userId: string; 4 | videoUrl: string; 5 | videoPlayTime: number; 6 | createdAt: Date; 7 | updatedAt: Date; 8 | isDeleted: boolean; 9 | roomUUID: string; 10 | feedbackList: T[]; 11 | } 12 | -------------------------------------------------------------------------------- /backend/rest/src/interview/entities/typeorm-feedback.builder.ts: -------------------------------------------------------------------------------- 1 | import { BaseBuilder } from '../../common/base.builder'; 2 | import { TypeormFeedbackEntity } from './typeorm-feedback.entity'; 3 | import { TypeormInterviewDocsEntity } from './typeorm-interview-docs.entity'; 4 | 5 | export class FeedbackBuilder extends BaseBuilder { 6 | constructor() { 7 | super(TypeormFeedbackEntity); 8 | } 9 | 10 | setUserId(userId: string): FeedbackBuilder { 11 | this.instance.userId = userId; 12 | return this; 13 | } 14 | 15 | setDocs(docs: TypeormInterviewDocsEntity): FeedbackBuilder { 16 | this.instance.docs = docs; 17 | return this; 18 | } 19 | 20 | setStartTime(startTime: number): FeedbackBuilder { 21 | this.instance.startTime = startTime; 22 | return this; 23 | } 24 | 25 | setInnerIndex(innerIndex: number): FeedbackBuilder { 26 | this.instance.innerIndex = innerIndex; 27 | return this; 28 | } 29 | 30 | setContent(content: string): FeedbackBuilder { 31 | this.instance.content = content; 32 | return this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/rest/src/interview/entities/typeorm-feedback.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; 3 | import { Feedback } from './feedback.entity'; 4 | import { TypeormInterviewDocsEntity } from './typeorm-interview-docs.entity'; 5 | 6 | @Entity('feedback') 7 | export class TypeormFeedbackEntity implements Feedback { 8 | @PrimaryGeneratedColumn('increment') 9 | id: number; 10 | 11 | @Column({ length: 45 }) 12 | @IsString() 13 | userId: string; 14 | 15 | @Column() 16 | @IsNumber() 17 | startTime: number; 18 | 19 | @Column() 20 | @IsNumber() 21 | innerIndex: number; 22 | 23 | @Column() 24 | @IsString() 25 | content: string; 26 | 27 | @ManyToOne(() => TypeormInterviewDocsEntity, (docs) => docs.feedbackList, { 28 | onDelete: 'CASCADE', 29 | }) 30 | @JoinColumn({ name: 'docs_uuid' }) 31 | docs: TypeormInterviewDocsEntity; 32 | } 33 | -------------------------------------------------------------------------------- /backend/rest/src/interview/entities/typeorm-interview-docs.builder.ts: -------------------------------------------------------------------------------- 1 | import { BaseBuilder } from '../../common/base.builder'; 2 | import { TypeormInterviewDocsEntity } from './typeorm-interview-docs.entity'; 3 | 4 | export class InterviewDocsBuilder extends BaseBuilder { 5 | constructor() { 6 | super(TypeormInterviewDocsEntity); 7 | } 8 | 9 | setId(id: string): InterviewDocsBuilder { 10 | this.instance.id = id; 11 | return this; 12 | } 13 | 14 | setUserId(userId: string): InterviewDocsBuilder { 15 | this.instance.userId = userId; 16 | return this; 17 | } 18 | 19 | setVideoUrl(videoUrl: string): InterviewDocsBuilder { 20 | this.instance.videoUrl = videoUrl; 21 | return this; 22 | } 23 | 24 | setVideoPlayTime(videoPlayTime: number): InterviewDocsBuilder { 25 | this.instance.videoPlayTime = videoPlayTime; 26 | return this; 27 | } 28 | 29 | setRoomUUID(roomUUID: string): InterviewDocsBuilder { 30 | this.instance.roomUUID = roomUUID; 31 | return this; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/rest/src/interview/entities/typeorm-interview-docs.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { TypeormBaseEntity } from 'src/common/typeorm-base.entity'; 3 | import { Entity, PrimaryColumn, Column, OneToMany } from 'typeorm'; 4 | import { Feedback } from './feedback.entity'; 5 | import { InterviewDocs } from './interview-docs.entity'; 6 | import { TypeormFeedbackEntity } from './typeorm-feedback.entity'; 7 | 8 | @Entity('interview_docs') 9 | export class TypeormInterviewDocsEntity 10 | extends TypeormBaseEntity 11 | implements InterviewDocs 12 | { 13 | @PrimaryColumn({ length: 36 }) 14 | @IsString() 15 | id: string; 16 | 17 | @Column({ length: 45 }) 18 | @IsString() 19 | userId: string; 20 | 21 | @Column({ length: 200 }) 22 | @IsString() 23 | videoUrl: string; 24 | 25 | @Column() 26 | @IsNumber() 27 | videoPlayTime: number; 28 | 29 | @Column({ length: 36, name: 'room_uuid' }) 30 | @IsString() 31 | roomUUID: string; 32 | 33 | @OneToMany(() => TypeormFeedbackEntity, (feedback) => feedback.docs, { 34 | cascade: true, 35 | }) 36 | feedbackList: TypeormFeedbackEntity[]; 37 | } 38 | -------------------------------------------------------------------------------- /backend/rest/src/interview/interview.module.ts: -------------------------------------------------------------------------------- 1 | import { INTERVIEW_REPOSITORY_INTERFACE } from '@constant'; 2 | import { ClassProvider } from '@nestjs/common'; 3 | import { Module } from '@nestjs/common'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { InterviewController } from './controller/interview.controller'; 6 | import { TypeormFeedbackEntity } from './entities/typeorm-feedback.entity'; 7 | import { TypeormInterviewDocsEntity } from './entities/typeorm-interview-docs.entity'; 8 | import { TypeormInterviewRepository } from './repository/typeorm-interview.repository'; 9 | import { InterviewService } from './service/interview.service'; 10 | 11 | export const InterviewRepository: ClassProvider = { 12 | provide: INTERVIEW_REPOSITORY_INTERFACE, 13 | useClass: TypeormInterviewRepository, 14 | }; 15 | 16 | @Module({ 17 | imports: [TypeOrmModule.forFeature([TypeormInterviewDocsEntity, TypeormFeedbackEntity])], 18 | controllers: [InterviewController], 19 | providers: [InterviewService, InterviewRepository], 20 | }) 21 | export class InterviewModule {} 22 | -------------------------------------------------------------------------------- /backend/rest/src/interview/repository/interview.repository.ts: -------------------------------------------------------------------------------- 1 | import { DocsWhereCondition } from 'src/types/query.type'; 2 | import { DocsRequestDto } from '../dto/request-docs.dto'; 3 | import { FeedbackVO } from '@types'; 4 | 5 | export interface InterviewRepository { 6 | /** 7 | * 전달 받은 docs의 정보를 바탕으로 docs를 db에 저장합니다. 8 | * @param userId user id 9 | * @param videoUrl storage object url 10 | * @param docsDto docsUUiD, videoPlayTime, roomUUID 11 | * @returns docs uuid 12 | */ 13 | saveInterviewDocs({ 14 | userId, 15 | videoUrl, 16 | docsDto, 17 | }: { 18 | userId: string; 19 | videoUrl: string; 20 | docsDto: DocsRequestDto; 21 | }): Promise; 22 | 23 | /** 24 | * docs UUID에 해당하는 docs와 feedback들을 정렬하여 반환합니다. 25 | * @param docsUUID docs UUID 26 | * @returns InterviewDocsEntity 27 | */ 28 | getInterviewDocs(docsUUID: string): Promise; 29 | 30 | /** 31 | * docs UUID로 interview docs를 찾습니다. 32 | * @param docsUUID docs UUID 33 | * @returns InterviewDocsEntity 34 | */ 35 | getInterviewDocsByDocsUUID(docsUUID: string): Promise; 36 | 37 | /** 38 | * room UUID가 있을 경우 해당 room UUID에서 생성된 interview docs를 반환하고 39 | * room UUID가 없을 경우 user id에 해당하는 유저의 모든 interview docs를 반환합니다. 40 | * @param whereCondition user id와 room UUID 41 | * @returns InterviewDocsEntity[] 42 | */ 43 | getInterviewDocsInRoomOrGlobalByUserId(whereCondition: DocsWhereCondition): Promise; 44 | 45 | /** 46 | * docs UUID에 해당하는 interview docs를 삭제합니다. 47 | * @param docsUUID 48 | * @returns docs UUID 49 | */ 50 | deleteInterviewDocs(docsUUID: string): Promise; 51 | 52 | /** 53 | * feedback list를 저장합니다. 54 | * @param feedbackVO 55 | */ 56 | saveFeedbackList(feedbackVO: FeedbackVO[]): Promise; 57 | } 58 | -------------------------------------------------------------------------------- /backend/rest/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { setupSwagger } from '@config'; 4 | import helmet from 'helmet'; 5 | import cookieParser from 'cookie-parser'; 6 | import { RestInterceptor } from './interceptor/http.interceptor'; 7 | import { HttpExceptionFilter } from './filter/http-exception.filter'; 8 | import { ValidationPipe } from '@nestjs/common'; 9 | import { pipeOptions } from './config/pipe.config'; 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule); 13 | app.setGlobalPrefix('api'); 14 | app.use(helmet()); 15 | app.use(cookieParser()); 16 | app.useGlobalPipes(new ValidationPipe(pipeOptions)); 17 | app.useGlobalInterceptors(new RestInterceptor()); 18 | app.useGlobalFilters(new HttpExceptionFilter()); 19 | 20 | setupSwagger(app); 21 | 22 | await app.listen(8080); 23 | } 24 | 25 | bootstrap(); 26 | -------------------------------------------------------------------------------- /backend/rest/src/types/auth.type.ts: -------------------------------------------------------------------------------- 1 | export interface UserSocialInfo { 2 | id: string; 3 | oauthType: string; 4 | } 5 | 6 | export interface UserLocalInfo { 7 | id: string; 8 | password: string; 9 | email: string; 10 | } 11 | 12 | export interface UserInfo extends UserSocialInfo, UserLocalInfo {} 13 | 14 | export interface JwtPayload { 15 | id: string; 16 | email: string; 17 | iat: number; 18 | exp: number; 19 | } 20 | -------------------------------------------------------------------------------- /backend/rest/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.type'; 2 | export * from './mock.type'; 3 | export * from './query.type'; 4 | export * from './interview.type'; 5 | -------------------------------------------------------------------------------- /backend/rest/src/types/interview.type.ts: -------------------------------------------------------------------------------- 1 | import { FeedbackBoxDto } from 'src/interview/dto/feedback.dto'; 2 | 3 | export interface FeedbackVO { 4 | readonly userId: string; 5 | readonly docs: T; 6 | readonly feedbackBoxDto: FeedbackBoxDto; 7 | } 8 | -------------------------------------------------------------------------------- /backend/rest/src/types/mock.type.ts: -------------------------------------------------------------------------------- 1 | export type MockRepository = Partial>; 2 | -------------------------------------------------------------------------------- /backend/rest/src/types/query.type.ts: -------------------------------------------------------------------------------- 1 | export type DocsWhereCondition = { 2 | userId: string; 3 | roomUUID?: string; 4 | }; 5 | -------------------------------------------------------------------------------- /backend/rest/src/user/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateUserDto {} 2 | -------------------------------------------------------------------------------- /backend/rest/src/user/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateUserDto } from './create-user.dto'; 3 | 4 | export class UpdateUserDto extends PartialType(CreateUserDto) {} 5 | -------------------------------------------------------------------------------- /backend/rest/src/user/entities/typeorm-user.builder.ts: -------------------------------------------------------------------------------- 1 | import { TypeormUserEntity } from 'src/user/entities/typeorm-user.entity'; 2 | import { BaseBuilder } from '../../common/base.builder'; 3 | 4 | export class JoinUserBuilder extends BaseBuilder { 5 | constructor() { 6 | super(TypeormUserEntity); 7 | } 8 | 9 | setId(id: string): JoinUserBuilder { 10 | this.instance.id = id; 11 | return this; 12 | } 13 | 14 | setPassword(password: string): JoinUserBuilder { 15 | this.instance.password = password; 16 | return this; 17 | } 18 | 19 | setEmail(email: string): JoinUserBuilder { 20 | this.instance.email = email; 21 | return this; 22 | } 23 | 24 | setOauthType(oauthType: string): JoinUserBuilder { 25 | this.instance.oauthType = oauthType; 26 | return this; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/rest/src/user/entities/typeorm-user.entity.ts: -------------------------------------------------------------------------------- 1 | import { TypeormBaseEntity } from 'src/common/typeorm-base.entity'; 2 | import { Entity, PrimaryColumn, Column } from 'typeorm'; 3 | import { UserEntity } from './user.entity'; 4 | 5 | @Entity('user') 6 | export class TypeormUserEntity extends TypeormBaseEntity implements UserEntity { 7 | @PrimaryColumn({ length: 100 }) 8 | id: string; 9 | 10 | @Column({ length: 200, default: '' }) 11 | password: string; 12 | 13 | @Column({ length: 45, default: '' }) 14 | email: string; 15 | 16 | @Column({ length: 10, default: 'none' }) 17 | oauthType: string; 18 | } 19 | -------------------------------------------------------------------------------- /backend/rest/src/user/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | export interface UserEntity { 2 | id: string; 3 | password: string; 4 | email: string; 5 | oauthType: string; 6 | createdAt: Date; 7 | updatedAt: Date; 8 | isDeleted: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /backend/rest/src/user/repository/typeorm-user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { JoinUserBuilder } from '../entities/typeorm-user.builder'; 4 | import { UserInfo } from '@types'; 5 | import { Repository } from 'typeorm'; 6 | import { TypeormUserEntity } from '../entities/typeorm-user.entity'; 7 | import { UserRepository } from './user.repository'; 8 | 9 | @Injectable() 10 | export class TypeormUserRepository implements UserRepository { 11 | constructor( 12 | @InjectRepository(TypeormUserEntity) 13 | private readonly userRepository: Repository 14 | ) {} 15 | 16 | async saveUser(userInfo: UserInfo): Promise { 17 | const { id, password, email, oauthType } = userInfo; 18 | const user = new JoinUserBuilder() 19 | .setId(id) 20 | .setPassword(password) 21 | .setEmail(email) 22 | .setOauthType(oauthType) 23 | .build(); 24 | 25 | const joinedUser = await this.userRepository.save(user); 26 | 27 | return joinedUser; 28 | } 29 | 30 | async findUserById(id: string): Promise { 31 | const user = this.userRepository.findOneBy({ id }); 32 | return user; 33 | } 34 | 35 | async findAllUser(): Promise { 36 | const users = this.userRepository.find(); 37 | return users; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/rest/src/user/repository/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { UserInfo } from '@types'; 2 | 3 | export interface UserRepository { 4 | /** 5 | * 유저 엔티티를 DB에 저장하는 메서드입니다. 6 | * @param user user entity 7 | */ 8 | saveUser(user: UserInfo): Promise; 9 | 10 | /** 11 | * 유저 id로 유저 엔티디를 반환합니다. 12 | * @param id user의 id 13 | */ 14 | findUserById(id: string): Promise; 15 | 16 | /** 17 | * 모든 유저의 엔티티 배열을 반환합니다. 18 | */ 19 | findAllUser(): Promise; 20 | } 21 | -------------------------------------------------------------------------------- /backend/rest/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeormUserRepository } from './repository/typeorm-user.repository'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { TypeormUserEntity } from './entities/typeorm-user.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([TypeormUserEntity])], 8 | controllers: [], 9 | providers: [TypeormUserRepository], 10 | exports: [TypeormUserRepository, TypeOrmModule.forFeature([TypeormUserEntity])], 11 | }) 12 | export class UserModule {} 13 | -------------------------------------------------------------------------------- /backend/rest/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "moduleNameMapper": { 10 | "@config": "/../src/config/index", 11 | "@constant": "/../src/constant/index", 12 | "@types": "/../src/types/index", 13 | "@builder": "/../src/builder/index", 14 | "^src/(.*)$": "/../src/$1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/rest/test/test.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Connection } from 'typeorm'; 3 | 4 | @Injectable() 5 | export class TestService { 6 | constructor(@Inject('Connection') public connection: Connection) {} 7 | 8 | public async cleanDatabase(): Promise { 9 | try { 10 | const entities = this.connection.entityMetadatas; 11 | const tableNames = entities.map((entity) => `"${entity.tableName}"`).join(', '); 12 | 13 | await this.connection.query(`TRUNCATE ${tableNames} CASCADE;`); 14 | console.log('[TEST DATABASE]: Clean'); 15 | } catch (error) { 16 | throw new Error(`ERROR: Cleaning test database: ${error}`); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/rest/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/rest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | }, 22 | "extends": "./tsconfig.path.json" 23 | } 24 | -------------------------------------------------------------------------------- /backend/rest/tsconfig.path.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@config": ["src/config/index"], 5 | "@constant": ["src/constant/index"], 6 | "@types": ["src/types/index"], 7 | "@common": ["src/common/index"], 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/socket/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /backend/socket/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | *.env* 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json -------------------------------------------------------------------------------- /backend/socket/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "parser": "typescript", 4 | "semi": true, 5 | "useTabs": true, 6 | "printWidth": 100, 7 | "tabWidth": 4 8 | } 9 | -------------------------------------------------------------------------------- /backend/socket/@types/woowa-babble.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@woowa-babble/random-nickname'; -------------------------------------------------------------------------------- /backend/socket/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.12.1 2 | 3 | WORKDIR /app 4 | 5 | COPY yarn.lock package.json ./ 6 | RUN yarn install 7 | 8 | COPY . . 9 | RUN yarn build 10 | 11 | CMD yarn start 12 | -------------------------------------------------------------------------------- /backend/socket/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /backend/socket/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { envConfig } from '@config'; 4 | import { RoomModule } from './room/room.module'; 5 | 6 | @Module({ 7 | imports: [ConfigModule.forRoot(envConfig), RoomModule], 8 | controllers: [], 9 | providers: [], 10 | }) 11 | export class AppModule {} 12 | -------------------------------------------------------------------------------- /backend/socket/src/config/env.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigModuleOptions } from '@nestjs/config'; 2 | import Joi from 'joi'; 3 | 4 | export const envConfig: ConfigModuleOptions = { 5 | isGlobal: true, 6 | envFilePath: process.env.NODE_ENV === 'dev' ? '.env' : '.env.test', 7 | validationSchema: Joi.object({ 8 | NAVER_API_KEY: Joi.string().required(), 9 | NAVER_API_PWD: Joi.string().required(), 10 | REST_SERVER_ORIGIN: Joi.string().required(), 11 | }), 12 | }; 13 | -------------------------------------------------------------------------------- /backend/socket/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env.config'; 2 | export * from './redis.adapter'; 3 | -------------------------------------------------------------------------------- /backend/socket/src/config/pipe.config.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipeOptions } from '@nestjs/common'; 2 | 3 | export const pipeOptions: ValidationPipeOptions = { 4 | transform: true, 5 | whitelist: true, 6 | forbidNonWhitelisted: true, 7 | forbidUnknownValues: true, 8 | }; 9 | -------------------------------------------------------------------------------- /backend/socket/src/config/redis.adapter.ts: -------------------------------------------------------------------------------- 1 | import { IoAdapter } from '@nestjs/platform-socket.io'; 2 | import { ServerOptions } from 'socket.io'; 3 | import { createAdapter } from '@socket.io/redis-adapter'; 4 | import { createClient } from 'redis'; 5 | import { REDIS_URL } from '@constant'; 6 | 7 | export const pubClient = createClient({ url: process.env[REDIS_URL] }); 8 | const subClient = pubClient.duplicate(); 9 | 10 | export class RedisIoAdapter extends IoAdapter { 11 | private adapterConstructor: ReturnType; 12 | 13 | async connectToRedis(): Promise { 14 | await Promise.all([pubClient.connect(), subClient.connect()]); 15 | this.adapterConstructor = createAdapter(pubClient, subClient); 16 | } 17 | 18 | createIOServer(port: number, options?: ServerOptions): any { 19 | const server = super.createIOServer(port, options); 20 | server.adapter(this.adapterConstructor); 21 | return server; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/socket/src/constant/env.constant.ts: -------------------------------------------------------------------------------- 1 | export const NAVER_API_KEY = 'NAVER_API_KEY'; 2 | export const NAVER_API_PWD = 'NAVER_API_PWD'; 3 | 4 | export const REST_SERVER_ORIGIN = 'REST_SERVER_ORIGIN'; 5 | export const REDIS_URL = 'REDIS_URL'; 6 | -------------------------------------------------------------------------------- /backend/socket/src/constant/event.constant.ts: -------------------------------------------------------------------------------- 1 | export enum EVENT { 2 | // connection 3 | CREATE_ROOM = 'create_room', 4 | ENTER_ROOM = 'enter_room', 5 | LEAVE_ROOM = 'leave_room', 6 | ENTER_USER = 'enter_user', 7 | LEAVE_USER = 'leave_user', 8 | UPDATE_MEDIA_INFO = 'update_media_info', 9 | 10 | // interview 11 | START_INTERVIEW = 'start_interview', 12 | JOIN_INTERVIEW = 'join_interview', 13 | END_INTERVIEW = 'end_interview', 14 | END_FEEDBACK = 'end_feedback', 15 | COUNT_FEEDBACK = 'count_feedback', 16 | TERMINATE_SESSION = 'terminate_session', 17 | START_WAITING = 'start_waiting', 18 | START_FEEDBACK = 'start_feedback', 19 | 20 | // video 21 | VIDEO_BLOB = 'video_blob', 22 | 23 | // chat 24 | SEND_MESSAGE = 'send_message', 25 | RECEIVE_MESSAGE = 'receive_message', 26 | 27 | // objectStorage 28 | STREAM_VIDEO = 'stream_video', 29 | FINISH_STEAMING = 'finish_streaming', 30 | ALLOW_BUCKET_CORS = 'allow_bucket_cors', 31 | DOWNLOAD_VIDEO = 'download_video', 32 | 33 | // webRTC 34 | START_SIGNALING = 'start_signaling', 35 | RECEIVE_SIGNALING = 'receive_signaling', 36 | OFFER = 'offer', 37 | ANSWER = 'answer', 38 | ICECANDIDATE = 'icecandidate', 39 | DISCONNECT_WEBRTC = 'disconnect_webrtc', 40 | 41 | // error 42 | BAD_REQUEST = 'bad_request', 43 | INTERNAL_SERVER_EXCEPTION = 'server_down', 44 | VALIDATION_EXCEPTION = 'validation_exception', 45 | } 46 | 47 | export enum SOCKET_MESSAGE { 48 | FULL_ROOM = 'full_room', 49 | NO_ROOM = 'no_room', 50 | BUSY_ROOM = 'busy_room', 51 | NOT_ENOUGHT_USER = 'not_enought_user', 52 | VIDEO_TIME_LIMIT_OVER = 'video_time_limit_over', 53 | EXIST_SAME_AUTH_ID = 'exist_same_auth_id', 54 | } 55 | 56 | export enum EXCEPTION_MESSAGE { 57 | INVALID_USER_ROLE = '해당하는 유저의 역할이 없습니다.', 58 | INVALID_CHANGE_PHASE = 'interview 진행 단계가 옳바르지 않습니다.', 59 | INVALID_CHAT_DATA = '비정상적인 채팅 데이터입니다.', 60 | NOT_FOUND_USER = '유저가 존재하지 않습니다.', 61 | } 62 | -------------------------------------------------------------------------------- /backend/socket/src/constant/index.ts: -------------------------------------------------------------------------------- 1 | export * from './room.constant'; 2 | export * from './user.constant'; 3 | export * from './event.constant'; 4 | export * from './env.constant'; 5 | export * from './object-storage.constant'; 6 | -------------------------------------------------------------------------------- /backend/socket/src/constant/object-storage.constant.ts: -------------------------------------------------------------------------------- 1 | export const OBJECT_STORAGE_ENDPOINT = 'https://kr.object.ncloudstorage.com'; 2 | export const AWS_S3_RESION = 'kr-standard'; 3 | export const BUCKET_NAME = 'interface'; 4 | export const MAX_VIDEO_COUNT = 10; 5 | export const BUCKET_CORS_ALLOW_SEC = 60; 6 | export const VIDEO_RUNNING_SEC_LIMIT = 60 * 60; 7 | -------------------------------------------------------------------------------- /backend/socket/src/constant/room.constant.ts: -------------------------------------------------------------------------------- 1 | export const ROOM_REPOSITORY_INTERFACE = 'RoomRepository'; 2 | export const MAX_USER_COUNT = 4; 3 | export const MIN_USER_COUNT = 2; 4 | 5 | export enum ROOM_PHASE { 6 | LOBBY = 'lobby', 7 | INTERVIEW = 'interview', 8 | FEEDBACK = 'feedback', 9 | } 10 | -------------------------------------------------------------------------------- /backend/socket/src/constant/user.constant.ts: -------------------------------------------------------------------------------- 1 | export enum USER_ROLE { 2 | INTERVIEWER = 'interviewer', 3 | INTERVIEWEE = 'interviewee', 4 | FEEDBACKED = 'feedbacked', 5 | NONE = 'none', 6 | } 7 | -------------------------------------------------------------------------------- /backend/socket/src/filter/socket-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { EVENT } from '@constant'; 2 | import { 3 | Catch, 4 | ArgumentsHost, 5 | InternalServerErrorException, 6 | Logger, 7 | HttpException, 8 | } from '@nestjs/common'; 9 | import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; 10 | import { Socket } from 'socket.io'; 11 | 12 | @Catch() 13 | export class SocketExceptionFilter extends BaseWsExceptionFilter { 14 | catch(exception: Error, host: ArgumentsHost) { 15 | const logger = new Logger('SocketException'); 16 | 17 | const ctx = host.switchToWs(); 18 | const client = ctx.getClient(); 19 | 20 | const { name, stack } = exception; 21 | let { message } = exception; 22 | if (!(exception instanceof WsException)) { 23 | const res: any = (exception as HttpException)?.getResponse(); 24 | message = res.message; 25 | } 26 | 27 | logger.error(`[Client ID] ${client.id}] `); 28 | logger.error(`[Exception Name] ${name}`); 29 | logger.error(`[Exception Message] ${message}`); 30 | logger.error(`[Exception Stack] ${stack}`); 31 | 32 | const error = { message, stack: exception.stack }; 33 | 34 | if (exception instanceof WsException) { 35 | client.emit(EVENT.BAD_REQUEST, error); 36 | return; 37 | } 38 | 39 | if (exception instanceof HttpException) { 40 | client.emit(EVENT.VALIDATION_EXCEPTION, error); 41 | return; 42 | } 43 | 44 | exception = new InternalServerErrorException(); 45 | client.emit(EVENT.INTERNAL_SERVER_EXCEPTION, error); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/socket/src/interceptor/socket-response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { map, tap } from 'rxjs/operators'; 4 | import { Socket } from 'socket.io'; 5 | import { SocketResponseDto } from 'src/room/dto/socket-response.dto'; 6 | 7 | @Injectable() 8 | export class SocketResponseInterceptor implements NestInterceptor { 9 | intercept(context: ExecutionContext, next: CallHandler): Observable { 10 | const logger = new Logger('Socket request'); 11 | 12 | const ctx = context.switchToWs(); 13 | const client = ctx.getClient(); 14 | 15 | const start = Date.now(); 16 | return next.handle().pipe( 17 | tap(() => { 18 | const end = Date.now(); 19 | logger.log(`[Client ID: ${client.id}] [${end - start}ms]`); 20 | }), 21 | map((response) => { 22 | return new SocketResponseDto(response); 23 | }) 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/socket/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { RedisIoAdapter } from '@config'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | 8 | const redisIoAdapter = new RedisIoAdapter(app); 9 | await redisIoAdapter.connectToRedis(); 10 | app.useWebSocketAdapter(redisIoAdapter); 11 | 12 | await app.listen(8081); 13 | } 14 | 15 | bootstrap(); 16 | -------------------------------------------------------------------------------- /backend/socket/src/room/dto/chat.dto.ts: -------------------------------------------------------------------------------- 1 | import { USER_ROLE } from '@constant'; 2 | import { IsDate, IsNotEmpty, IsOptional, IsString } from 'class-validator'; 3 | 4 | export enum ChatTarget { 5 | EVERYONE = 'everyone', 6 | DRIECT = 'direct', 7 | ROLE = 'role', 8 | } 9 | 10 | export class ChatRequestDto { 11 | @IsString() 12 | @IsNotEmpty() 13 | nickname: string; 14 | 15 | @IsString() 16 | @IsNotEmpty() 17 | content: string; 18 | 19 | @IsString() 20 | @IsNotEmpty() 21 | target: ChatTarget; 22 | 23 | @IsString() 24 | @IsOptional() 25 | uuid: string; 26 | 27 | @IsString() 28 | @IsOptional() 29 | role: USER_ROLE; 30 | } 31 | 32 | export class ChatResponseDto { 33 | constructor(chatRequest: ChatRequestDto) { 34 | this.nickname = chatRequest.nickname; 35 | this.content = chatRequest.content; 36 | this.timestamp = new Date(); 37 | } 38 | 39 | @IsString() 40 | @IsNotEmpty() 41 | nickname: string; 42 | 43 | @IsString() 44 | @IsNotEmpty() 45 | content: string; 46 | 47 | @IsDate() 48 | @IsNotEmpty() 49 | timestamp: Date; 50 | } 51 | -------------------------------------------------------------------------------- /backend/socket/src/room/dto/socket-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsObject, IsBoolean } from 'class-validator'; 2 | 3 | export class SocketResponseDto { 4 | constructor({ success = true, data = null, message = null }) { 5 | this.success = success; 6 | this.data = data; 7 | this.message = message; 8 | } 9 | 10 | @IsBoolean() 11 | success: boolean; 12 | 13 | @IsObject() 14 | data: unknown; 15 | 16 | @IsString() 17 | message: string; 18 | } 19 | -------------------------------------------------------------------------------- /backend/socket/src/room/dto/update-media-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsOptional, IsString } from 'class-validator'; 3 | 4 | export class UpdateMediaDto { 5 | @IsOptional() 6 | @IsString() 7 | @Transform((obj) => String(obj.value)) 8 | video: string; 9 | 10 | @IsOptional() 11 | @IsString() 12 | @Transform((obj) => String(obj.value)) 13 | audio: string; 14 | } 15 | -------------------------------------------------------------------------------- /backend/socket/src/room/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@types'; 2 | import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class UserDto { 5 | constructor(user: User) { 6 | this.uuid = user.uuid; 7 | this.nickname = user.nickname; 8 | this.role = user.role; 9 | this.roomUUID = user.roomUUID; 10 | this.video = JSON.parse(user.video); 11 | this.audio = JSON.parse(user.audio); 12 | } 13 | 14 | @IsString() 15 | @IsNotEmpty() 16 | uuid: string; 17 | 18 | @IsString() 19 | @IsNotEmpty() 20 | nickname: string; 21 | 22 | @IsString() 23 | @IsNotEmpty() 24 | role: string; 25 | 26 | @IsString() 27 | @IsNotEmpty() 28 | roomUUID: string; 29 | 30 | @IsBoolean() 31 | @IsNotEmpty() 32 | video: boolean; 33 | 34 | @IsBoolean() 35 | @IsNotEmpty() 36 | audio: boolean; 37 | } 38 | -------------------------------------------------------------------------------- /backend/socket/src/room/dto/webrtc.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class WebrtcBaseDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | myId: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | opponentId: string; 11 | } 12 | 13 | export class WebrtcOfferDto extends WebrtcBaseDto { 14 | @IsNotEmpty() 15 | offer: unknown; 16 | } 17 | 18 | export class WebrtcAnswerDto extends WebrtcBaseDto { 19 | @IsNotEmpty() 20 | answer: unknown; 21 | } 22 | 23 | export class WebrtcIcecandidateDto extends WebrtcBaseDto { 24 | @IsNotEmpty() 25 | icecandidate: unknown; 26 | } 27 | -------------------------------------------------------------------------------- /backend/socket/src/room/room.module.ts: -------------------------------------------------------------------------------- 1 | import { ROOM_REPOSITORY_INTERFACE } from '@constant'; 2 | import { ClassProvider, Module } from '@nestjs/common'; 3 | import { RedisRoomRepository } from './repository/redis-room.repository'; 4 | import { RoomGateway } from './room.gateway'; 5 | import { ChatService } from './service/chat/chat.service'; 6 | import { ConnectionService } from './service/connection/connection.service'; 7 | import { InterviewService } from './service/interview/interview.service'; 8 | import { ObjectStorageService } from './service/objectstorage/objectstorage.service'; 9 | import { WebrtcService } from './service/webRTC/webrtc.service'; 10 | 11 | export const RoomRepository: ClassProvider = { 12 | provide: ROOM_REPOSITORY_INTERFACE, 13 | useClass: RedisRoomRepository, 14 | }; 15 | 16 | @Module({ 17 | providers: [ 18 | RoomGateway, 19 | RoomRepository, 20 | ConnectionService, 21 | InterviewService, 22 | ChatService, 23 | WebrtcService, 24 | ObjectStorageService, 25 | ], 26 | }) 27 | export class RoomModule {} 28 | -------------------------------------------------------------------------------- /backend/socket/src/room/service/webRTC/webrtc.service.ts: -------------------------------------------------------------------------------- 1 | import { EVENT, ROOM_REPOSITORY_INTERFACE } from '@constant'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { Socket } from 'socket.io'; 4 | import { WebrtcBaseDto } from 'src/room/dto/webrtc.dto'; 5 | import { RoomRepository } from '../../repository/room.repository'; 6 | 7 | @Injectable() 8 | export class WebrtcService { 9 | constructor( 10 | @Inject(ROOM_REPOSITORY_INTERFACE) 11 | private readonly roomRepository: RoomRepository 12 | ) {} 13 | 14 | async startSignaling(client: Socket) { 15 | const user = await this.roomRepository.getUserByClientId(client.id); 16 | 17 | client.to(user.roomUUID).emit(EVENT.RECEIVE_SIGNALING, { userUUID: user.uuid }); 18 | 19 | return {}; 20 | } 21 | 22 | /** 23 | * 모든 시그널을 이벤트 이름에 따라 전달하는 메소드입니다. 24 | * @param param0 25 | */ 26 | async delivery({ 27 | client, 28 | connectSignal, 29 | eventType, 30 | }: { 31 | client: Socket; 32 | connectSignal: WebrtcBaseDto; 33 | eventType: EVENT; 34 | }) { 35 | const { myId, opponentId } = connectSignal; 36 | const opponent = await this.roomRepository.getUserByUserId(opponentId); 37 | 38 | client 39 | .to(opponent.clientId) 40 | .emit(eventType, { ...connectSignal, myId: opponentId, opponentId: myId }); 41 | 42 | return {}; 43 | } 44 | 45 | /** 46 | * socket connection이 끊겼을 때 webRTC도 같이 끊어주는 메서드입니다. 47 | * @param client Socket 48 | */ 49 | async disconnectWebrtc(client: Socket) { 50 | const user = await this.roomRepository.getUserByClientId(client.id); 51 | if (!user) return; 52 | 53 | client.to(user.roomUUID).emit(EVENT.DISCONNECT_WEBRTC, { userUUID: user.uuid }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /backend/socket/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './room.type'; 2 | export * from './mock.type'; 3 | -------------------------------------------------------------------------------- /backend/socket/src/types/mock.type.ts: -------------------------------------------------------------------------------- 1 | export type MockRepository = Partial>; 2 | -------------------------------------------------------------------------------- /backend/socket/src/types/room.type.ts: -------------------------------------------------------------------------------- 1 | import { ROOM_PHASE, USER_ROLE } from '@constant'; 2 | 3 | export interface Room { 4 | roomUUID: string; 5 | phase: ROOM_PHASE; 6 | } 7 | 8 | export interface User { 9 | uuid: string; 10 | nickname: string; 11 | role: USER_ROLE; 12 | roomUUID: string; 13 | clientId: string; 14 | authId: string; 15 | video: string; 16 | audio: string; 17 | } 18 | 19 | export type userUUID = string; 20 | export type roomUUID = string; 21 | export type clientId = string; 22 | export type authId = string; 23 | -------------------------------------------------------------------------------- /backend/socket/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/socket/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/socket/test/stress/stress.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | target: "http://localhost:8081/socket" 3 | socketio: 4 | extraHeaders: 5 | x-client-id: "abc" 6 | scenarios: 7 | - name: socket.io test 8 | engine: socketio 9 | flow: 10 | - emit: 11 | channel: "create_room" 12 | namespace: "/socket" 13 | capture: 14 | - json: "$.data" 15 | as: "data" 16 | - log: "emitting captured values: {{ data }}" -------------------------------------------------------------------------------- /backend/socket/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/socket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true, 10 | "target": "es2017", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": false, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false, 21 | }, 22 | "extends": "./tsconfig.path.json" 23 | } 24 | -------------------------------------------------------------------------------- /backend/socket/tsconfig.path.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@config": ["src/config/index"], 5 | "@constant": ["src/constant/index"], 6 | "@types": ["src/types/index"], 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/socket/util/rest-api.util.ts: -------------------------------------------------------------------------------- 1 | import { REST_SERVER_ORIGIN } from '@constant'; 2 | import { Logger } from '@nestjs/common'; 3 | import axios from 'axios'; 4 | import { Socket } from 'socket.io'; 5 | 6 | export const setUserIdInClient = async (client: Socket) => { 7 | try { 8 | const res = await axios.get(`${process.env[REST_SERVER_ORIGIN]}/api/auth/id`, { 9 | headers: { 10 | Cookie: client.handshake.headers.cookie, 11 | }, 12 | }); 13 | client.data.authId = res.data.data.userId; 14 | } catch (err) { 15 | errerLogger(err); 16 | } 17 | }; 18 | 19 | export const deleteInterviewDocs = async ({ 20 | client, 21 | docsUUID, 22 | }: { 23 | client: Socket; 24 | docsUUID: string; 25 | }) => { 26 | try { 27 | await axios.delete(`${process.env[REST_SERVER_ORIGIN]}/api/interview/docs/${docsUUID}`, { 28 | headers: { 29 | Cookie: client.handshake.headers.cookie, 30 | }, 31 | }); 32 | } catch (err) { 33 | errerLogger(err); 34 | } 35 | }; 36 | 37 | const errerLogger = (err) => { 38 | const logger = new Logger('Rest Exception'); 39 | const { message }: any = err.response.data; 40 | logger.error(message); 41 | }; 42 | -------------------------------------------------------------------------------- /changelog.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | disableEmoji: false, 3 | format: "{emoji}{type}: {scope} - {subject}", 4 | list: ["feat", "fix", "refactor", "design", "chore", "test", "docs"], 5 | maxMessageLength: 64, 6 | minMessageLength: 3, 7 | questions: ["type", "scope", "subject", "body"], 8 | scopes: ["FE", "BE", "COMMON"], 9 | types: { 10 | chore: { 11 | description: "Build process or auxiliary tool changes", 12 | emoji: "🐋", 13 | value: "chore", 14 | }, 15 | docs: { 16 | description: "Documentation only changes", 17 | emoji: "📖", 18 | value: "docs", 19 | }, 20 | feat: { 21 | description: "A new feature", 22 | emoji: "✨", 23 | value: "feat", 24 | }, 25 | fix: { 26 | description: "A bug fix", 27 | emoji: "🐞", 28 | value: "fix", 29 | }, 30 | refactor: { 31 | description: 32 | "A code change that neither fixes a bug or adds a feature", 33 | emoji: "🛠️", 34 | value: "refactor", 35 | }, 36 | test: { 37 | description: "Adding missing tests", 38 | emoji: "🚨", 39 | value: "test", 40 | }, 41 | design: { 42 | description: "Change user interface design", 43 | emoji: "💄", 44 | value: "design", 45 | }, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web14-interface/3c422394c46170022cdd91cc69a8f411a903a636/dump.rdb -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier" 11 | ], 12 | "overrides": [], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["react", "@typescript-eslint"], 19 | "rules": { 20 | "prefer-arrow-callback": "error", 21 | "react/no-unknown-property": ["error", { "ignore": ["css"] }] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development 18 | .env.development.local 19 | .env.test.local 20 | .env.production 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "parser": "typescript", 4 | "semi": true, 5 | "useTabs": true, 6 | "printWidth": 100, 7 | "tabWidth": 4 8 | } 9 | -------------------------------------------------------------------------------- /frontend/.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | '@storybook/preset-create-react-app', 10 | ], 11 | framework: '@storybook/react', 12 | core: { 13 | builder: '@storybook/builder-webpack5', 14 | }, 15 | webpackFinal: async (config, { configType }) => { 16 | config.module.rules.push({ 17 | test: /\.(ts|tsx)$/, 18 | loader: require.resolve('babel-loader'), 19 | options: { 20 | presets: [ 21 | ['react-app', { flow: false, typescript: true }], 22 | require.resolve('@emotion/babel-preset-css-prop'), 23 | ], 24 | }, 25 | }); 26 | 27 | config.resolve.alias = { 28 | ...config.resolve.alias, 29 | '@api': path.resolve(__dirname, '../src/api'), 30 | '@assets': path.resolve(__dirname, '../src/assets'), 31 | '@components': path.resolve(__dirname, '../src/components'), 32 | '@constants': path.resolve(__dirname, '../src/constants'), 33 | '@customType': path.resolve(__dirname, '../src/customType'), 34 | '@hooks': path.resolve(__dirname, '../src/hooks'), 35 | '@layout': path.resolve(__dirname, '../src/layout'), 36 | '@pages': path.resolve(__dirname, '../src/pages'), 37 | '@routes': path.resolve(__dirname, '../src/routes'), 38 | '@service': path.resolve(__dirname, '../src/service'), 39 | '@store': path.resolve(__dirname, '../src/store'), 40 | '@styles': path.resolve(__dirname, '../src/styles'), 41 | '@utils': path.resolve(__dirname, '../src/utils'), 42 | }; 43 | 44 | return config; 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { Global, ThemeProvider, css } from '@emotion/react'; 2 | import { RecoilRoot } from 'recoil'; 3 | import globalStyle from '../src/styles/globalStyle'; 4 | import theme from '../src/styles/theme'; 5 | 6 | export const parameters = { 7 | actions: { argTypesRegex: '^on[A-Z].*' }, 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/, 12 | }, 13 | }, 14 | }; 15 | 16 | export const decorators = [ 17 | (Story) => ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ), 25 | ]; 26 | -------------------------------------------------------------------------------- /frontend/craco.config.js: -------------------------------------------------------------------------------- 1 | const CracoAlias = require('craco-alias'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | { 6 | plugin: CracoAlias, 7 | options: { 8 | source: 'tsconfig', 9 | tsConfigPath: 'tsconfig.paths.json', 10 | }, 11 | }, 12 | ], 13 | babel: { 14 | plugins: ['transform-remove-console'], 15 | presets: ['@emotion/babel-preset-css-prop'], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/public/assets/test.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web14-interface/3c422394c46170022cdd91cc69a8f411a903a636/frontend/public/assets/test.mp4 -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | Interface 12 | 13 | 14 | 15 | 16 |
17 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { RecoilRoot } from 'recoil'; 3 | import { Global, ThemeProvider } from '@emotion/react'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | import RootRoutes from '@routes/RootRoutes'; 7 | import globalStyle from '@styles/globalStyle'; 8 | import theme from '@styles/theme'; 9 | import ModalManager from '@components/ModalManager'; 10 | import Loading from '@components/Loading/Loading'; 11 | import ToastContainer from '@components/ToastManager'; 12 | 13 | function App() { 14 | return ( 15 | 16 | 17 | 18 |
19 | 20 | }> 21 | 22 | 23 | 24 |
25 | 26 | 27 |
28 |
29 |
30 | ); 31 | } 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /frontend/src/api/rest.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const requestGet = async (url) => { 4 | try { 5 | await axios.get(url); 6 | } catch (e) { 7 | console.log(e); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/broadcast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 13 | 17 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/camera_off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/camera_on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/download.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/enter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/exit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/kakao.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/mic_on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 9 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/naver.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/record.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/stop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/users.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/icon/video.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/assets/sync_dot_line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/src/components/@drawer/ChatDrawer/ChatDrawer.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { flexColumn, flexRow } from '@styles/globalStyle'; 3 | 4 | export const chatDrawerStyle = () => css` 5 | ${flexColumn({ justifyContent: 'space-between' })}; 6 | width: 100%; 7 | height: calc(100% - 56px); 8 | `; 9 | 10 | export const chatListContainer = () => css` 11 | display: flex; 12 | flex-direction: column-reverse; 13 | gap: 16px; 14 | 15 | width: 100%; 16 | height: 100%; 17 | padding: 12px 0px; 18 | 19 | overflow: auto; 20 | `; 21 | 22 | export const chatItemStyle = (theme, isMe) => css` 23 | ${flexColumn({ gap: '4px', alignItems: isMe ? 'end' : 'flex-start' })}; 24 | 25 | width: 100%; 26 | text-align: ${isMe ? 'right' : 'left'}; 27 | `; 28 | 29 | export const chatNicknameStyle = (theme) => css` 30 | color: ${theme.colors.white}; 31 | font-size: ${theme.fontSize.xSmall}; 32 | `; 33 | 34 | export const chatContentStyle = (theme, isMe) => css` 35 | width: 80%; 36 | padding: 12px; 37 | 38 | color: ${isMe ? theme.colors.white : theme.colors.black}; 39 | background-color: ${isMe ? theme.colors.primary : theme.colors.gray3}; 40 | 41 | font-size: ${theme.fontSize.small}; 42 | border-radius: ${theme.borderRadius}; 43 | `; 44 | 45 | export const chatInputStyle = (theme) => css` 46 | ${flexRow({ gap: '8px', alignItems: 'center' })}; 47 | 48 | width: 100%; 49 | height: 56px; 50 | padding-top: 16px; 51 | 52 | border-top: 1px solid ${theme.colors.white}; 53 | `; 54 | -------------------------------------------------------------------------------- /frontend/src/components/@drawer/UserDrawer/UserDrawer.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { flexColumn, flexRow } from '@styles/globalStyle'; 3 | 4 | export const userListStyle = (theme) => css` 5 | ${flexColumn({ gap: '8px', justifyContent: 'space-between', alignItems: 'center' })} 6 | width: 100%; 7 | 8 | color: ${theme.colors.white}; 9 | padding: 20px 0px; 10 | `; 11 | 12 | export const userItemStyle = () => css` 13 | width: 100%; 14 | ${flexRow({ justifyContent: 'space-between' })}; 15 | `; 16 | 17 | export const userIconStyle = (theme) => css` 18 | ${flexRow({ gap: '16px' })}; 19 | padding-right: 12px; 20 | 21 | svg { 22 | width: 20px; 23 | height: 20px; 24 | fill: ${theme.colors.white}; 25 | } 26 | `; 27 | 28 | export const drawerBottomBoxStyle = css` 29 | ${flexColumn({ justifyContent: 'center' })}; 30 | 31 | width: 100%; 32 | 33 | position: absolute; 34 | left: 0px; 35 | bottom: 0; 36 | `; 37 | 38 | export const dividerStyle = (theme) => css` 39 | height: 1px; 40 | width: 90%; 41 | background-color: ${theme.colors.gray3}; 42 | `; 43 | 44 | export const roomUUIDStyle = (theme) => css` 45 | ${flexRow({ gap: '32px', justifyContent: 'space-between' })} 46 | width: 100%; 47 | height: 72px; 48 | padding: ${theme.fontSize.medium}; 49 | 50 | color: ${theme.colors.secondary}; 51 | font-size: ${theme.fontSize.xSmall}; 52 | `; 53 | 54 | export const offIconStyle = (theme) => css` 55 | fill: ${theme.colors.red}; 56 | `; 57 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/CancelInterviewModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Modal from '@components/@shared/Modal/Modal'; 3 | import useModal from '@hooks/useModal'; 4 | 5 | const CancelInterviewModal = () => { 6 | const { closeModal } = useModal(); 7 | 8 | const cancelInterview = () => { 9 | //TODO BE와 협의하여 면접 취소 로직 붙이기 10 | closeModal(); 11 | }; 12 | 13 | return ( 14 | 15 | 면접을 취소하시겠습니까? 16 | 17 | 현재까지 진행 사항이 저장되지 않습니다. 18 | 19 | 20 | 돌아가기 21 | 22 | 취소 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default CancelInterviewModal; 30 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/EndInterviewModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSocket from '@hooks/useSocket'; 3 | import Modal from '@components/@shared/Modal/Modal'; 4 | import { SOCKET_EVENT_TYPE } from '@constants/socket.constant'; 5 | import useModal from '@hooks/useModal'; 6 | 7 | const EndInterviewModal = () => { 8 | const { closeModal } = useModal(); 9 | const { socketEmit } = useSocket(); 10 | 11 | const hadleEndInterview = () => { 12 | socketEmit(SOCKET_EVENT_TYPE.END_INTERVIEW); 13 | closeModal(); 14 | }; 15 | 16 | return ( 17 | 18 | 면접을 종료하시겠습니까? 19 | 20 | 취소 21 | 22 | 종료 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default EndInterviewModal; 30 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/ExitRoomModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSocket from '@hooks/useSocket'; 3 | import Modal from '@components/@shared/Modal/Modal'; 4 | import { PAGE_TYPE } from '@constants/page.constant'; 5 | import { SOCKET_EVENT_TYPE } from '@constants/socket.constant'; 6 | import useModal from '@hooks/useModal'; 7 | import useSafeNavigate from '@hooks/useSafeNavigate'; 8 | import useCleanupRoom from '@hooks/useCleanupRoom'; 9 | import useWebRTCSignaling from '@hooks/useWebRTCSignaling'; 10 | import { useRecoilState, useRecoilValue } from 'recoil'; 11 | import { meInRoomState, webRTCUserMapState } from '@store/user.store'; 12 | import { socket } from '@service/socket'; 13 | 14 | export interface ExitRoomModalPropType { 15 | content?: string; 16 | } 17 | 18 | const ExitRoomModal = ({ content }: ExitRoomModalPropType) => { 19 | const { closeModal } = useModal(); 20 | const cleanupRoom = useCleanupRoom(); 21 | const { safeNavigate } = useSafeNavigate(); 22 | const { socketEmit } = useSocket(); 23 | 24 | const me = useRecoilValue(meInRoomState); 25 | const [webRTCUserList, setWebRTCUserList] = useRecoilState(webRTCUserMapState); 26 | const { closeConnection } = useWebRTCSignaling(webRTCUserList, setWebRTCUserList); 27 | 28 | const handleLeaveRoom = async () => { 29 | await socketEmit(SOCKET_EVENT_TYPE.LEAVE_ROOM); 30 | closeConnection(me); 31 | socket.removeAllListeners(); 32 | cleanupRoom(); 33 | 34 | closeModal(); 35 | safeNavigate(PAGE_TYPE.LANDING_PAGE); 36 | }; 37 | 38 | return ( 39 | 40 | 방을 나가시겠습니까? 41 | {content && ( 42 | 43 |
{content}
44 |
45 | )} 46 | 47 | 취소 48 | 49 | 50 | 나가기 51 | 52 | 53 |
54 | ); 55 | }; 56 | 57 | export default ExitRoomModal; 58 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/InterviewDocsModal/InterviewDocsModal.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { flexColumn, flexRow } from '@styles/globalStyle'; 3 | 4 | export const docsModalWrapperStyle = (theme, section) => css` 5 | ${flexColumn({ justifyContent: 'unset' })}; 6 | 7 | width: 840px; 8 | height: 80vh; 9 | 10 | background-color: ${section === 'list' ? theme.colors.white : theme.colors.tertiary}; 11 | color: ${section === 'list' ? theme.colors.black : theme.colors.white}; 12 | overflow-x: hidden; 13 | 14 | border-radius: ${theme.borderRadius}; 15 | `; 16 | 17 | export const docsModalHeaderStyle = () => css` 18 | ${flexRow({ justifyContent: 'space-between' })}; 19 | 20 | width: 100%; 21 | padding: 16px; 22 | 23 | span { 24 | font-size: 24px; 25 | line-height: 16px; 26 | } 27 | `; 28 | 29 | export const docsModalContentStyle = (section) => css` 30 | ${flexRow({ justifyContent: 'unset', gap: '64px' })}; 31 | 32 | width: 100%; 33 | height: calc(100% - 72px); 34 | padding: 16px 32px; 35 | 36 | transform: translateX(${section === 'list' ? '0%' : '-100%'}); 37 | `; 38 | 39 | export const modalSyncButtonAreaStyle = (theme) => css` 40 | ${flexRow({ gap: '12px' })}; 41 | 42 | padding-left: 96px; 43 | `; 44 | 45 | export const modalSyncDotLineStyle = (theme) => css` 46 | width: 12px; 47 | `; 48 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/NotStreamModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Modal from '@components/@shared/Modal/Modal'; 3 | import { PAGE_TYPE } from '@constants/page.constant'; 4 | import { SOCKET_EVENT_TYPE } from '@constants/socket.constant'; 5 | import useCleanupRoom from '@hooks/useCleanupRoom'; 6 | import useModal from '@hooks/useModal'; 7 | import useSafeNavigate from '@hooks/useSafeNavigate'; 8 | import useSocket from '@hooks/useSocket'; 9 | import { socket } from '@service/socket'; 10 | 11 | const NotStreamModal = () => { 12 | const { closeModal } = useModal(); 13 | const cleanupRoom = useCleanupRoom(); 14 | const { safeNavigate } = useSafeNavigate(); 15 | const { socketEmit } = useSocket(); 16 | 17 | const handleLeaveRoom = async () => { 18 | await socketEmit(SOCKET_EVENT_TYPE.LEAVE_ROOM); 19 | socket.removeAllListeners(); 20 | cleanupRoom(); 21 | 22 | closeModal(); 23 | safeNavigate(PAGE_TYPE.LANDING_PAGE); 24 | }; 25 | 26 | return ( 27 | 28 | 미디어 오류 29 | 30 | 미디어를 가져올 수 없습니다. 31 | 화상 회의, 화면 공유 등이 진행 중인 경우, 32 | 연결이 어려울 수 있습니다. 33 | 34 | 35 | 36 | 방 나가기 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default NotStreamModal; 44 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/RoomInfoModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Modal from '@components/@shared/Modal/Modal'; 3 | import { ReactComponent as CopyIcon } from '@assets/icon/copy.svg'; 4 | import useToast from '@hooks/useToast'; 5 | 6 | export interface RoomInfoModelPropType { 7 | value: string; 8 | } 9 | 10 | const RoomInfoModal = ({ value }: RoomInfoModelPropType) => { 11 | const { popToast } = useToast(); 12 | 13 | const copyRoomInfo = async () => { 14 | await navigator.clipboard.writeText(value); 15 | popToast('복사 완료'); 16 | }; 17 | 18 | return ( 19 | 20 | 방 정보 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 닫기 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default RoomInfoModal; 37 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/StartInterviewModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSocket from '@hooks/useSocket'; 3 | import Modal from '@components/@shared/Modal/Modal'; 4 | import { PAGE_TYPE } from '@constants/page.constant'; 5 | import { SOCKET_EVENT_TYPE } from '@constants/socket.constant'; 6 | import useModal from '@hooks/useModal'; 7 | import useSafeNavigate from '@hooks/useSafeNavigate'; 8 | import { UserType } from '@customType/user'; 9 | import { useUserRole } from '@hooks/useUserRole'; 10 | import { useRecoilValue } from 'recoil'; 11 | import { meInRoomState } from '@store/user.store'; 12 | import { css } from '@emotion/react'; 13 | 14 | interface joinInterviewResponseType { 15 | usersInRoom: UserType[]; 16 | } 17 | 18 | const StartInterviewModal = () => { 19 | const { closeModal } = useModal(); 20 | const { safeNavigate } = useSafeNavigate(); 21 | const { setUserRole } = useUserRole(); 22 | const { socketEmit } = useSocket(); 23 | const me = useRecoilValue(meInRoomState); 24 | 25 | const handleStartInterviewee = async () => { 26 | await socketEmit(SOCKET_EVENT_TYPE.START_INTERVIEW); 27 | 28 | setUserRole(me); 29 | closeModal(); 30 | safeNavigate(PAGE_TYPE.INTERVIEWEE_PAGE); 31 | }; 32 | 33 | return ( 34 | 35 | 면접자로 시작하시겠습니까? 36 | 37 | 최대 녹화 가능 시간은 1시간입니다. 38 | 39 | 40 | 취소 41 | 시작 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default StartInterviewModal; 48 | 49 | const timeLimitMessage = (theme) => css` 50 | color: ${theme.colors.red}; 51 | `; 52 | -------------------------------------------------------------------------------- /frontend/src/components/@modal/TimeOverAlertModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useSocket from '@hooks/useSocket'; 4 | import Modal from '@components/@shared/Modal/Modal'; 5 | import { SOCKET_EVENT_TYPE } from '@constants/socket.constant'; 6 | import useModal from '@hooks/useModal'; 7 | 8 | const TimeOverAlertModal = () => { 9 | const { closeModal } = useModal(); 10 | const { socketEmit } = useSocket(); 11 | 12 | const hadleEndInterview = () => { 13 | socketEmit(SOCKET_EVENT_TYPE.END_INTERVIEW); 14 | closeModal(); 15 | }; 16 | 17 | return ( 18 | 19 | 녹화 제한 시간 초과 20 | 21 | 현재 시점부터는 영상이 녹화되지 않습니다. 22 | 면접을 종료해주십시오. 23 | 24 | 25 | 면접 종료 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default TimeOverAlertModal; 32 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/BottomBarButton/BottomBarButton.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import React from 'react'; 3 | import { buttonStyle } from '../Button/Button.style'; 4 | 5 | export interface buttonPropType { 6 | children?: React.ReactNode | React.ReactNode[]; 7 | width?: string; 8 | size?: 'small' | 'medium' | 'large'; 9 | style?: 'contained' | 'text'; 10 | color?: 'primary' | 'secondary' | 'red' | 'black'; 11 | justifyContent?: 'center' | 'space-between'; 12 | iconColor?: boolean; 13 | disabled?: boolean; 14 | onClick?: React.MouseEventHandler; 15 | visibility?: 'visible' | 'hidden'; 16 | } 17 | 18 | const BottomBarButtom = ({ 19 | children, 20 | width, 21 | size = 'large', 22 | style = 'text', 23 | color = 'secondary', 24 | justifyContent = 'center', 25 | iconColor = true, 26 | disabled = false, 27 | onClick, 28 | visibility, 29 | }: buttonPropType) => { 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default BottomBarButtom; 45 | 46 | const bottomBarPadding = (visibility) => css` 47 | visibility: ${visibility}; 48 | padding: 8px 12px; 49 | `; 50 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/Button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story } from '@storybook/react'; 3 | import Button, { buttonPropType } from './Button'; 4 | import { ReactComponent as FolderIcon } from '@assets/icon/folder.svg'; 5 | 6 | export default { 7 | component: Button, 8 | title: '@shared/Button', 9 | }; 10 | 11 | const Template: Story = (args) => ( 12 | 15 | ); 16 | 17 | export const Default = Template.bind({}); 18 | Default.args = {}; 19 | 20 | const IconTemplate: Story = (args) => ( 21 | 25 | ); 26 | 27 | export const IconButton = IconTemplate.bind({}); 28 | IconButton.args = {}; 29 | 30 | const IconOnlyTemplate: Story = (args) => ( 31 | 34 | ); 35 | 36 | export const IconOnlyButton = IconOnlyTemplate.bind({}); 37 | IconButton.args = {}; 38 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { buttonStyle } from './Button.style'; 3 | 4 | export interface buttonPropType { 5 | children?: React.ReactNode | React.ReactNode[]; 6 | width?: string; 7 | size?: 'xSmall' | 'small' | 'medium' | 'large'; 8 | style?: 'contained' | 'text'; 9 | color?: 'primary' | 'secondary' | 'red' | 'black'; 10 | justifyContent?: 'center' | 'space-between'; 11 | iconColor?: boolean; 12 | disabled?: boolean; 13 | onClick?: React.MouseEventHandler; 14 | } 15 | 16 | const Button = ( 17 | { 18 | children, 19 | width, 20 | size = 'medium', 21 | style = 'contained', 22 | color = 'primary', 23 | justifyContent = 'center', 24 | iconColor = true, 25 | disabled = false, 26 | onClick, 27 | }: buttonPropType, 28 | ref 29 | ) => { 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default forwardRef(Button); 45 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/Modal/Modal.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { flexColumn, flexRow } from '@styles/globalStyle'; 3 | 4 | export const ModalWrapperStyle = (theme) => css` 5 | ${flexColumn({ gap: '32px' })}; 6 | 7 | width: 480px; 8 | background-color: ${theme.colors.white}; 9 | padding: 40px 32px 32px 32px; 10 | 11 | border: 1px solid ${theme.colors.gray2}; 12 | border-radius: ${theme.borderRadius}; 13 | `; 14 | 15 | export const ModalTitleStyle = (theme, color) => css` 16 | color: ${theme.colors[color]}; 17 | 18 | font-size: ${theme.fontSize.large}; 19 | font-weight: bold; 20 | `; 21 | 22 | export const ModalButtonAreaStyle = (isArray) => css` 23 | ${flexRow({ justifyContent: isArray ? 'space-between' : 'center' })}; 24 | 25 | width: 100%; 26 | `; 27 | 28 | export const ModalContentAreaStyle = (gap = '16px', flexDirection) => css` 29 | ${flexDirection === 'row' 30 | ? flexRow({ gap, justifyContent: 'center' }) 31 | : flexColumn({ gap, justifyContent: 'center' })} 32 | 33 | width: 100%; 34 | `; 35 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/RoundButton/RoundButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story } from '@storybook/react'; 3 | import RoundButton, { roundButtonPropType } from './RoundButton'; 4 | import { ReactComponent as FolderIcon } from '@assets/icon/folder.svg'; 5 | import { iconSmStyle } from '@styles/commonStyle'; 6 | import theme from '@styles/theme'; 7 | 8 | export default { 9 | component: RoundButton, 10 | title: '@shared/RoundButton', 11 | }; 12 | 13 | const Template: Story = (args) => ( 14 | 15 | Button 16 | 17 | ); 18 | export const Default = Template.bind({}); 19 | Default.args = { 20 | style: { width: 500, size: 'medium', color: 'primary' }, 21 | }; 22 | 23 | const IconTemplate: Story = (args) => ( 24 | 25 | 26 | Button 27 | 28 | ); 29 | export const IconButton = IconTemplate.bind({}); 30 | IconButton.args = { 31 | style: { width: 200, size: 'medium', color: 'primary' }, 32 | }; 33 | 34 | const IconOnlyTemplate: Story = (args) => ( 35 | 36 | 37 | 38 | ); 39 | export const IconOnlyButton = IconOnlyTemplate.bind({}); 40 | IconOnlyButton.args = { 41 | style: { size: 'medium', color: 'primary' }, 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/RoundButton/RoundButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { roundButtonStyle } from './RoundButton.style'; 3 | 4 | export interface roundButtonPropType { 5 | children?: React.ReactNode | React.ReactNode[]; 6 | onClick?: React.MouseEventHandler; 7 | style: StyleType; 8 | } 9 | 10 | export interface StyleType { 11 | theme?: any; 12 | width?: number; 13 | size?: 'small' | 'medium' | 'large'; 14 | color?: 'primary' | 'secondary' | 'red' | 'black'; 15 | style?: 'contained' | 'text'; 16 | } 17 | 18 | const RoundButton = ({ children, onClick, style }: roundButtonPropType) => { 19 | return ( 20 | 23 | ); 24 | }; 25 | 26 | export default RoundButton; 27 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/StreamingVideo/StreamVideo.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story } from '@storybook/react'; 3 | import StreamVideo, { StreamVideoPropType } from './StreamVideo'; 4 | 5 | export default { 6 | component: StreamVideo, 7 | title: '@shared/Video/StreamVideo', 8 | }; 9 | 10 | const Template: Story = (args, { loaded: { MediaStram } }) => ( 11 | 12 | ); 13 | 14 | const getMedia = async () => { 15 | return new Promise((resolve, reject) => { 16 | const myStream = navigator.mediaDevices.getUserMedia({ 17 | audio: true, 18 | video: true, 19 | }); 20 | 21 | resolve(myStream); 22 | }); 23 | }; 24 | export const Default = Template.bind({}); 25 | Default.args = { 26 | nickname: 'User1', 27 | controls: true, 28 | muted: true, 29 | }; 30 | Default.loaders = [ 31 | async () => ({ 32 | MediaStram: { src: await getMedia() }, 33 | }), 34 | ]; 35 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/StreamingVideo/StreamVideo.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { videoStyle, videoWrapperStyle } from '../Video/Video.style'; 3 | 4 | export const streamVideoWrapperStyle = (theme, width, height) => css` 5 | ${videoWrapperStyle(theme, width, height)}; 6 | 7 | position: relative; 8 | `; 9 | 10 | export const streamVideoStyle = () => css` 11 | ${videoStyle()}; 12 | transform: rotateY(180deg); 13 | `; 14 | 15 | export const nameTagStyle = (theme, audio) => css` 16 | display: flex; 17 | align-items: center; 18 | gap: 8px; 19 | 20 | position: absolute; 21 | bottom: 0px; 22 | left: 0px; 23 | 24 | max-width: 50%; 25 | height: 20px; 26 | background-color: black; 27 | padding: 12px 8px; 28 | 29 | color: ${theme.colors.white}; 30 | line-height: 24px; 31 | 32 | border-top-right-radius: ${theme.borderRadius}; 33 | border-bottom-left-radius: ${theme.borderRadius}; 34 | 35 | svg { 36 | flex: 0 0 auto; 37 | width: 16px; 38 | height: 16px; 39 | fill: ${audio ? theme.colors.white : theme.colors.red}; 40 | } 41 | 42 | span { 43 | overflow: hidden; 44 | white-space: nowrap; 45 | text-overflow: ellipsis; 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/StreamingVideo/StreamVideo.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useEffect, useRef } from 'react'; 2 | import { nameTagStyle, streamVideoStyle, streamVideoWrapperStyle } from './StreamVideo.style'; 3 | 4 | import { ReactComponent as MicOnIcon } from '@assets/icon/mic_on.svg'; 5 | import { ReactComponent as MicOffIcon } from '@assets/icon/mic_off.svg'; 6 | 7 | export interface StreamVideoPropType { 8 | src: MediaStream; 9 | nickname: string; 10 | width?: string; 11 | height?: string; 12 | audio?: boolean; 13 | isMyStream?: boolean; 14 | } 15 | 16 | const StreamVideo = ( 17 | { src, nickname, width, height, audio, isMyStream }: StreamVideoPropType, 18 | ref 19 | ) => { 20 | const videoRef = ref ? ref : useRef(null); 21 | 22 | useEffect(() => { 23 | if (!videoRef.current) return; 24 | 25 | videoRef.current.srcObject = src; 26 | videoRef.current.controls = false; 27 | }, [src]); 28 | 29 | return ( 30 |
streamVideoWrapperStyle(theme, width, height)}> 31 |
37 | ); 38 | }; 39 | 40 | export default forwardRef(StreamVideo); 41 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/TextArea/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { KeyboardEventHandler } from 'react'; 2 | 3 | interface TextAreaType { 4 | value?: string; 5 | onChange?: React.Dispatch>; 6 | onKeyDown?: KeyboardEventHandler; 7 | } 8 | 9 | const TextArea = ({ value, onChange, onKeyDown }: TextAreaType) => { 10 | return ( 11 | 16 | ); 17 | }; 18 | 19 | export default TextArea; 20 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/TextField/TextField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story } from '@storybook/react'; 3 | import TextField, { TextFieldPropType } from './TextField'; 4 | 5 | export default { 6 | component: TextField, 7 | title: '@shared/TextField', 8 | }; 9 | 10 | const Template: Story = (args) => ; 11 | 12 | export const Default = Template.bind({}); 13 | Default.args = {}; 14 | 15 | export const HelperText = Template.bind({}); 16 | HelperText.args = { helperText: 'this is helper text' }; 17 | 18 | export const Disabled = Template.bind({}); 19 | Disabled.args = { disabled: true }; 20 | 21 | export const Error = Template.bind({}); 22 | Error.args = { error: true, helperText: 'this is helper text' }; 23 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/TextField/TextField.style.ts: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { flexColumn } from '@styles/globalStyle'; 3 | 4 | export const TextFieldWrapperStyle = (width) => css` 5 | ${flexColumn({ gap: '4px' })}; 6 | 7 | width: ${width}; 8 | `; 9 | 10 | export const TextFieldStyle = (theme, error, disabled, textAlign) => css` 11 | width: 100%; 12 | background-color: ${error 13 | ? theme.colors.red + '22' 14 | : disabled 15 | ? theme.colors.gray3 16 | : theme.colors.secondary}; 17 | color: ${error ? theme.colors.red : disabled ? theme.colors.gray2 : theme.colors.black}; 18 | padding: 12px; 19 | 20 | text-align: ${textAlign}; 21 | font-size: ${theme.fontSize.small}; 22 | 23 | border: 1px solid ${!disabled && error ? theme.colors.red : theme.colors.gray2}; 24 | border-radius: ${theme.borderRadius}; 25 | 26 | &::placeholder { 27 | color: ${theme.colors.gray2}; 28 | } 29 | `; 30 | 31 | export const TextFieldHelperTextStyle = (theme, error) => css` 32 | color: ${error ? theme.colors.red : theme.colors.gray2}; 33 | 34 | font-size: ${theme.fontSize.xSmall}; 35 | `; 36 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/TextField/TextField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TextFieldHelperTextStyle, TextFieldStyle, TextFieldWrapperStyle } from './TextField.style'; 3 | 4 | export interface TextFieldPropType { 5 | width?: string; 6 | placeholder?: string; 7 | disabled?: boolean; 8 | readOnly?: boolean; 9 | error?: boolean; 10 | textAlign?: 'left' | 'center' | 'right'; 11 | helperText?: string; 12 | onChange?: React.ChangeEventHandler; 13 | value?: string; 14 | } 15 | 16 | const TextField = ({ 17 | width, 18 | placeholder, 19 | disabled = false, 20 | readOnly = false, 21 | error = false, 22 | textAlign = 'left', 23 | helperText, 24 | onChange, 25 | value, 26 | }: TextFieldPropType) => { 27 | return ( 28 |
29 | TextFieldStyle(theme, error, disabled, textAlign)} 31 | type="text" 32 | placeholder={placeholder} 33 | disabled={disabled} 34 | readOnly={readOnly} 35 | onChange={onChange} 36 | value={value} 37 | /> 38 | {helperText?.trim().length > 0 ? ( 39 | TextFieldHelperTextStyle(theme, error)}>{helperText} 40 | ) : null} 41 |
42 | ); 43 | }; 44 | 45 | export default TextField; 46 | -------------------------------------------------------------------------------- /frontend/src/components/@shared/Video/Video.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story } from '@storybook/react'; 3 | import Video, { VideoPropType } from './Video'; 4 | 5 | export default { 6 | component: Video, 7 | title: '@shared/Video/Video', 8 | }; 9 | 10 | const Template: Story = (args, { loaded: { MediaStram } }) => ( 11 |