├── .github ├── ISSUE_TEMPLATE │ ├── ✅-feature.md │ ├── 🐞-bug.md │ └── 🔄-refactor.md ├── pr-labels.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── add-issue-to-project.yml │ ├── blue-green-cd.yml │ ├── build-backend.yml │ ├── close-issue.yml │ ├── configure-pr.yml │ ├── random-review-assign.yml │ └── release-drafter.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── README.md ├── backend ├── .dockerignore ├── Dockerfile.nginx ├── Dockerfile.signal ├── Dockerfile.was ├── compose.blue-build.yml ├── compose.blue-deploy.yml ├── compose.cache-export.yml ├── compose.green-build.yml ├── compose.green-deploy.yml ├── deploy.sh ├── nginx │ └── default.conf ├── send-slack-message.sh ├── signal │ ├── .eslintrc.js │ ├── .prettierrc │ ├── README.md │ ├── nest-cli.json │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── events │ │ │ ├── events.gateway.spec.ts │ │ │ ├── events.gateway.ts │ │ │ └── events.module.ts │ │ ├── logger │ │ │ ├── logger.module.ts │ │ │ └── logger.service.ts │ │ ├── main.ts │ │ └── mocks │ │ │ └── events │ │ │ ├── logger.mock.ts │ │ │ ├── room.mock.ts │ │ │ ├── socket.mock.ts │ │ │ └── user.mock.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json └── was │ ├── .eslintrc.js │ ├── .prettierrc │ ├── README.md │ ├── jest-e2e.config.js │ ├── jest-unit.config.js │ ├── jest.config.js │ ├── jest.config.json │ ├── nest-cli.json │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── app.controller.ts │ ├── app.decorators.ts │ ├── app.module.ts │ ├── auth │ │ ├── __mocks__ │ │ │ └── kakao.auth.service.ts │ │ ├── auth.controller.ts │ │ ├── auth.decorators.ts │ │ ├── auth.module.ts │ │ ├── dto │ │ │ ├── auth-status.dto.ts │ │ │ ├── index.ts │ │ │ ├── jwt-payload.dto.ts │ │ │ ├── kakao │ │ │ │ ├── index.ts │ │ │ │ ├── kakao-access-token-info.dto.ts │ │ │ │ └── kakao-token.dto.ts │ │ │ ├── oauth-token.dto.ts │ │ │ └── profile.dto.ts │ │ ├── guard │ │ │ ├── index.ts │ │ │ ├── jwt-auth.guard.ts │ │ │ └── socket-jwt-auth.guard.ts │ │ ├── interface │ │ │ └── cache-key.ts │ │ ├── service │ │ │ ├── auth.service.spec.ts │ │ │ ├── auth.service.ts │ │ │ ├── index.ts │ │ │ └── kakao.auth.service.ts │ │ ├── strategies │ │ │ └── jwt.strategy.ts │ │ └── util │ │ │ └── kakao.ts │ ├── chat │ │ ├── chat.controller.ts │ │ ├── chat.decorators.ts │ │ ├── chat.module.ts │ │ ├── chat.service.spec.ts │ │ ├── chat.service.ts │ │ ├── chatting-info.interface.ts │ │ ├── dto │ │ │ ├── chatting-message.dto.ts │ │ │ ├── chatting-room.dto.ts │ │ │ ├── create-chatting-message.dto.ts │ │ │ ├── index.ts │ │ │ └── update-chatting-room.dto.ts │ │ └── entities │ │ │ ├── chatting-message.entity.ts │ │ │ ├── chatting-room.entity.ts │ │ │ └── index.ts │ ├── chatbot │ │ ├── chatbot.interface.ts │ │ ├── chatbot.module.ts │ │ └── clova-studio │ │ │ ├── api.ts │ │ │ ├── clova-studio.service.spec.ts │ │ │ ├── clova-studio.service.ts │ │ │ ├── message │ │ │ ├── builder.ts │ │ │ ├── converter.ts │ │ │ ├── creator.ts │ │ │ ├── index.ts │ │ │ └── message.spec.ts │ │ │ └── stream │ │ │ ├── converter.ts │ │ │ ├── index.ts │ │ │ └── stream.spec.ts │ ├── common │ │ ├── config │ │ │ ├── cache │ │ │ │ └── redis-cache.module.ts │ │ │ ├── database │ │ │ │ └── mysql.module.ts │ │ │ ├── jwt │ │ │ │ └── jwt.module.ts │ │ │ ├── sentry.setting.ts │ │ │ └── swagger.setting.ts │ │ ├── constants │ │ │ ├── apis.ts │ │ │ ├── clova-studio.ts │ │ │ ├── errors.ts │ │ │ ├── etc.ts │ │ │ └── socket.ts │ │ ├── interceptors │ │ │ └── errors.interceptor.ts │ │ ├── types │ │ │ ├── chatbot.ts │ │ │ ├── clova-studio.ts │ │ │ └── socket.ts │ │ └── utils │ │ │ ├── logging.ts │ │ │ ├── slack-webhook.ts │ │ │ └── stream.ts │ ├── exceptions │ │ ├── codemap │ │ │ ├── auth-codemap.ts │ │ │ ├── chat-codemap.ts │ │ │ ├── index.ts │ │ │ ├── members-codemap.ts │ │ │ ├── tarot-codemap.ts │ │ │ └── type.ts │ │ ├── custom-exception.ts │ │ ├── index.ts │ │ └── metadata.ts │ ├── logger │ │ ├── logger.module.ts │ │ └── logger.service.ts │ ├── main.ts │ ├── members │ │ ├── dto │ │ │ ├── create-member.dto.ts │ │ │ ├── index.ts │ │ │ ├── member.dto.ts │ │ │ └── update-member.dto.ts │ │ ├── entities │ │ │ ├── index.ts │ │ │ └── member.entity.ts │ │ ├── members.controller.ts │ │ ├── members.decorators.ts │ │ ├── members.module.ts │ │ ├── members.service.spec.ts │ │ └── members.service.ts │ ├── mocks │ │ ├── clova-studio │ │ │ ├── clova-studio.mocks.ts │ │ │ └── index.ts │ │ └── socket │ │ │ ├── index.ts │ │ │ └── socket.mocks.ts │ ├── socket │ │ ├── socket.gateway.ts │ │ ├── socket.module.ts │ │ ├── socket.service.spec.ts │ │ ├── socket.service.ts │ │ └── ws-exception.filter.ts │ └── tarot │ │ ├── dto │ │ ├── create-tarot-result.dto.ts │ │ ├── index.ts │ │ ├── tarot-card.dto.ts │ │ └── tarot-result.dto.ts │ │ ├── entities │ │ ├── index.ts │ │ ├── tarot-card-pack.entity.ts │ │ ├── tarot-card.entity.ts │ │ └── tarot-result.entity.ts │ │ ├── tarot.controller.ts │ │ ├── tarot.decorators.ts │ │ ├── tarot.module.ts │ │ ├── tarot.service.spec.ts │ │ └── tarot.service.ts │ ├── test │ ├── auth.e2e-spec.ts │ ├── chat.e2e-spec.ts │ ├── common │ │ ├── constants.ts │ │ └── database │ │ │ └── sqlite.module.ts │ ├── members.e2e-spec.ts │ └── tarot.e2e-spec.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsconfig.paths.json ├── frontend ├── .eslintrc.cjs ├── .prettierrc ├── README.md ├── __mocks__ │ ├── socket.io-client.ts │ └── zustand.ts ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── bg-light.png │ ├── bg-night.png │ ├── ddung.png │ ├── flipCard.mp3 │ ├── logo.png │ ├── mockServiceWorker.js │ ├── moon.png │ ├── sponge.png │ └── spreadCards.mp3 ├── src │ ├── @types │ │ ├── Kakao.d.ts │ │ └── close.d.ts │ ├── App.tsx │ ├── business │ │ ├── hooks │ │ │ ├── auth │ │ │ │ ├── index.ts │ │ │ │ ├── useKakaoOAuth.ts │ │ │ │ └── useKakaoOAuthRedirect.ts │ │ │ ├── chatMessage │ │ │ │ ├── index.ts │ │ │ │ ├── useAiChatMessage.ts │ │ │ │ ├── useChatMessage.ts │ │ │ │ └── useHumanChatMessage.ts │ │ │ ├── index.ts │ │ │ ├── overlay │ │ │ │ ├── OverlayProvider.tsx │ │ │ │ ├── __tests__ │ │ │ │ │ └── useOverlay.spec.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── types.tsx │ │ │ │ └── useOverlay.tsx │ │ │ ├── popup │ │ │ │ ├── __mocks__ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── usePasswordPopup.ts │ │ │ │ ├── index.ts │ │ │ │ ├── useExitPopup.tsx │ │ │ │ ├── useLoginPopup.tsx │ │ │ │ └── usePasswordPopup.tsx │ │ │ ├── sidbar_tmp │ │ │ │ ├── index.ts │ │ │ │ ├── useSideBarAnimation.ts │ │ │ │ └── useSideBarButton.ts │ │ │ ├── sidebar │ │ │ │ ├── index.tsx │ │ │ │ └── useSidebar.tsx │ │ │ ├── tarotSpread │ │ │ │ ├── index.ts │ │ │ │ ├── useAiTarotSpread.tsx │ │ │ │ ├── useDisplayTarotCard.tsx │ │ │ │ ├── useHumanTarotSpread.tsx │ │ │ │ └── useTarotSpread.tsx │ │ │ ├── useBlocker.ts │ │ │ ├── useOutSideClickEvent.ts │ │ │ ├── useOverflowTextBoxCenter.ts │ │ │ ├── useShareButtons.ts │ │ │ ├── useSpeakerHighlighter.ts │ │ │ ├── useUserFeedback.tsx │ │ │ └── webRTC │ │ │ │ ├── __mocks__ │ │ │ │ ├── index.ts │ │ │ │ ├── useDataChannel.ts │ │ │ │ └── useMedia.ts │ │ │ │ ├── __tests__ │ │ │ │ ├── useDataChannel.spec.ts │ │ │ │ ├── useDataChannelEventListener.spec.ts │ │ │ │ ├── useMedia.spec.ts │ │ │ │ ├── useMediaStream.spec.ts │ │ │ │ ├── useSignalingSocket.spec.tsx │ │ │ │ └── useWebRTC.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── useDataChannel.ts │ │ │ │ ├── useDataChannelEventListener.ts │ │ │ │ ├── useMedia.ts │ │ │ │ ├── useMediaStream.ts │ │ │ │ ├── useSignalingSocket.ts │ │ │ │ └── useWebRTC.ts │ │ └── services │ │ │ ├── Kakao.ts │ │ │ ├── Media.ts │ │ │ ├── Socket.ts │ │ │ ├── SocketManager │ │ │ ├── AISocketManager.ts │ │ │ ├── HumanSocketManager.ts │ │ │ ├── SocketManager.ts │ │ │ ├── __mocks__ │ │ │ │ ├── HumanSocketManager.ts │ │ │ │ ├── SocketManager.ts │ │ │ │ └── index.ts │ │ │ ├── __tests__ │ │ │ │ ├── HumanSocketManager.spec.ts │ │ │ │ └── SocketManager.spec.ts │ │ │ └── index.ts │ │ │ ├── WebRTC.ts │ │ │ ├── __mocks__ │ │ │ ├── Media.ts │ │ │ ├── Socket.ts │ │ │ ├── WebRTC.ts │ │ │ └── index.ts │ │ │ ├── __tests__ │ │ │ ├── Media.spec.ts │ │ │ ├── Socket.spec.ts │ │ │ └── WebRTC.spec.ts │ │ │ └── index.ts │ ├── components │ │ ├── aiChatPage │ │ │ ├── ChatLogContainer │ │ │ │ ├── ChatLogContainer.tsx │ │ │ │ ├── ChatLogList │ │ │ │ │ ├── ChatLogGroup.tsx │ │ │ │ │ ├── ChatLogItem.tsx │ │ │ │ │ ├── ChatLogList.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ContinueChatButton.tsx │ │ │ │ ├── __mocks__ │ │ │ │ │ ├── data.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks │ │ │ │ │ ├── index.ts │ │ │ │ │ └── useChatLogList.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── common │ │ │ ├── Background │ │ │ │ ├── Background.tsx │ │ │ │ └── index.ts │ │ │ ├── BackgroundMusic │ │ │ │ ├── BackgroundMusic.tsx │ │ │ │ └── index.ts │ │ │ ├── Buttons │ │ │ │ ├── Button.tsx │ │ │ │ ├── IconButton.tsx │ │ │ │ ├── IconToggleButton.tsx │ │ │ │ ├── InputFileButton.tsx │ │ │ │ ├── KakaoLoginButton.tsx │ │ │ │ ├── KakaoLoginoutButton.tsx │ │ │ │ ├── LogoButton.tsx │ │ │ │ └── index.ts │ │ │ ├── ChatContainer │ │ │ │ ├── ChatContainer.tsx │ │ │ │ ├── ChatInput │ │ │ │ │ ├── ChatInput.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── ChatList │ │ │ │ │ ├── ChatList.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── MessageBox │ │ │ │ │ ├── Message.tsx │ │ │ │ │ ├── MessageBox.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── Cursor │ │ │ │ ├── Cursor.tsx │ │ │ │ └── index.ts │ │ │ ├── Header │ │ │ │ ├── Header.tsx │ │ │ │ ├── Toast │ │ │ │ │ ├── Toast.tsx │ │ │ │ │ ├── hooks │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── useToast.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── InputText │ │ │ │ ├── InputText.tsx │ │ │ │ └── index.ts │ │ │ ├── Popup │ │ │ │ ├── LoginPopup.tsx │ │ │ │ ├── PasswordPopup.tsx │ │ │ │ ├── Popup.tsx │ │ │ │ └── index.tsx │ │ │ ├── Portals │ │ │ │ ├── DocumentBodyPortal.tsx │ │ │ │ └── index.ts │ │ │ ├── Select │ │ │ │ ├── Select.tsx │ │ │ │ └── index.ts │ │ │ ├── SideBar │ │ │ │ ├── ContentAreaWithSideBar.tsx │ │ │ │ ├── SideBar.tsx │ │ │ │ ├── SideBarButton.tsx │ │ │ │ └── index.ts │ │ │ ├── TarotSpread │ │ │ │ ├── TarotCard.tsx │ │ │ │ ├── TarotSpread.tsx │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ └── humanChatPage │ │ │ ├── CamBox │ │ │ ├── CamBox.tsx │ │ │ └── index.ts │ │ │ ├── CamContainer │ │ │ ├── CamContainer.tsx │ │ │ └── index.tsx │ │ │ ├── ProfileSetting │ │ │ ├── DeviceSelect.tsx │ │ │ ├── DeviceToggleButtons.tsx │ │ │ ├── ProfileSetting.tsx │ │ │ └── index.tsx │ │ │ └── index.ts │ ├── constants │ │ ├── animation.ts │ │ ├── browser.ts │ │ ├── colors.ts │ │ ├── events.ts │ │ ├── kakao.ts │ │ ├── message.ts │ │ ├── messages.ts │ │ ├── nickname.ts │ │ ├── sizes.ts │ │ ├── time.ts │ │ └── urls.ts │ ├── errors │ │ ├── APIErrorBoundary.tsx │ │ ├── UnknownErrorBoundary.tsx │ │ └── index.ts │ ├── main.tsx │ ├── mocks │ │ ├── browser.ts │ │ ├── cards │ │ │ ├── 00.jpg │ │ │ ├── 01.jpg │ │ │ ├── 02.jpg │ │ │ ├── 03.jpg │ │ │ ├── 04.jpg │ │ │ ├── 05.jpg │ │ │ ├── 06.jpg │ │ │ ├── 07.jpg │ │ │ ├── 08.jpg │ │ │ ├── 09.jpg │ │ │ ├── 10.jpg │ │ │ ├── 11.jpg │ │ │ ├── 12.jpg │ │ │ ├── 13.jpg │ │ │ ├── 14.jpg │ │ │ ├── 15.jpg │ │ │ ├── 16.jpg │ │ │ ├── 17.jpg │ │ │ ├── 18.jpg │ │ │ ├── 19.jpg │ │ │ ├── 20.jpg │ │ │ ├── 21.jpg │ │ │ └── back.jpg │ │ ├── event │ │ │ ├── event.ts │ │ │ └── index.ts │ │ ├── handlers.ts │ │ ├── mockResults.ts │ │ ├── socket │ │ │ ├── index.ts │ │ │ └── socketMock.ts │ │ └── webRTC │ │ │ ├── dataChannelMock.ts │ │ │ ├── index.ts │ │ │ ├── mediaStreamMock.ts │ │ │ ├── peerConnectionMock.ts │ │ │ └── signalingMock.ts │ ├── pages │ │ ├── AIChatPage │ │ │ ├── AIChatPage.tsx │ │ │ └── index.ts │ │ ├── ErrorPage │ │ │ ├── SomethingWrongErrorPage.tsx │ │ │ └── index.ts │ │ ├── HomePage │ │ │ ├── HomePage.tsx │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ └── useReset.ts │ │ │ └── index.ts │ │ ├── HumanChatPage │ │ │ ├── ChattingPage.tsx │ │ │ ├── HumanChatPage.tsx │ │ │ ├── SettingPage.tsx │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ ├── useCreateJoinRoomPasswordPopup.ts │ │ │ │ ├── useCreateRoomEvent.ts │ │ │ │ ├── useHumanChatPageState.tsx │ │ │ │ ├── useMediaOptinos.tsx │ │ │ │ ├── usePageWrongURL.ts │ │ │ │ └── useProfileNicknameSetting.ts │ │ │ └── index.ts │ │ ├── OAuth2RedirectHandlePage.tsx │ │ ├── ResultSharePage │ │ │ ├── ResultImage.tsx │ │ │ ├── ResultSharePage.tsx │ │ │ ├── ResultTextBox.tsx │ │ │ ├── ShareButtonList.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── router.tsx │ ├── setup-vitest.ts │ ├── stores │ │ ├── queries │ │ │ ├── getAuthorizedQuery.ts │ │ │ ├── getBgmQuery.ts │ │ │ ├── getChatLogListQuery.ts │ │ │ ├── getChatLogQuery.ts │ │ │ ├── getResultShareQuery.ts │ │ │ ├── getTarotImageQuery.ts │ │ │ └── index.ts │ │ └── zustandStores │ │ │ ├── index.ts │ │ │ ├── useAiChatLogId.ts │ │ │ ├── useMediaInfo.ts │ │ │ ├── useMediaStreamStore.ts │ │ │ ├── useProfileInfo.ts │ │ │ ├── useSideBarStore.ts │ │ │ └── useToastStore.ts │ ├── tailwind.css │ ├── utils │ │ ├── __mocks__ │ │ │ └── random.ts │ │ ├── array.ts │ │ ├── downloadImage.ts │ │ ├── env.ts │ │ ├── insertOnclick.ts │ │ ├── loadScript.ts │ │ ├── random.ts │ │ ├── test │ │ │ └── matcher.ts │ │ └── unit8Array.ts │ ├── vite-env.d.ts │ └── vitest-extend.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package-lock.json ├── package.json └── packages ├── socket-event ├── .eslintrc.js ├── .prettierrc ├── index.ts ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── src │ ├── ai.ts │ ├── human.ts │ └── index.ts └── tsconfig.json └── winston-logger ├── .eslintrc.js ├── .prettierrc ├── index.ts ├── package-lock.json ├── package.json ├── src ├── format.ts ├── index.ts ├── logger.ts └── transports.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/✅-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "✅ FEATURE" 3 | about: Feature 작업 사항을 입력해주세요. 4 | title: "✅ FEATURE: " 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 설명을 작성하세요 12 | 13 | ## Todo 14 | - [ ] todo 15 | - [ ] todo 16 | 17 | ## ETC 18 | 기타 사항 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🐞-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41E BUG" 3 | about: BUG 발생 시 작성해주세요. 4 | title: "\U0001F41E BUG: " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 설명을 작성하세요 12 | 13 | ## How to reproduce it 14 | 버그를 재현하는 방법을 작성하세요 15 | 16 | ## Todo 17 | - [ ] todo 18 | - [ ] todo 19 | 20 | ## ETC 21 | 기타 사항 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🔄-refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F504 REFACTOR" 3 | about: Refactor 작업 사항을 입력해주세요. 4 | title: "\U0001F504 REFACTOR:" 5 | labels: refactor 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 설명을 작성하세요 12 | 13 | ## Expected Changes 14 | > EX) `utils.js` 파일에서 중복된 유틸리티 함수를 정리합니다. 15 | > EX) `src/components` 디렉토리의 컴포넌트 파일들을 모듈화하여 가독성을 높입니다. 16 | 17 | ## ETC 18 | 기타 사항 19 | -------------------------------------------------------------------------------- /.github/pr-labels.yml: -------------------------------------------------------------------------------- 1 | feature: "*feature*" 2 | bug: ["*bugfix*", "*hotfix*"] 3 | refactor: "*refactor*" 4 | release: "release*" 5 | BE: ["Be/*", "Fe,be/*", "Be,fe/*"] 6 | FE: ["Fe/*", "Fe,be/*", "Be,fe/*"] 7 | DevOps: "Devops/*" 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### 변경 사항 2 | ex) 로그인 시, 구글 소셜 로그인 기능을 추가했습니다. 3 | 4 | ### 고민과 해결 과정 5 | 6 | ### (선택) 테스트 결과 7 | ex) 베이스 브랜치에 포함되기 위한 코드는 모두 정상적으로 동작해야 합니다. 결과물에 대한 스크린샷, GIF, 혹은 라이브 데모가 가능하도록 샘플API를 첨부할 수도 있습니다. -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | template: | 4 | # Changes (v$RESOLVED_VERSION) 5 | 6 | $CHANGES 7 | categories: 8 | - title: "🚀 Features" 9 | collapse-after: 15 10 | labels: 11 | - "feature" 12 | - title: "🐛 Bug Fixes" 13 | collapse-after: 15 14 | labels: 15 | - "bug" 16 | - title: "🛠️ Improvement" 17 | collapse-after: 15 18 | labels: 19 | - "refactor" 20 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR" 21 | change-title-escapes: '\<*_&' 22 | version-resolver: 23 | major: 24 | labels: 25 | - "major" 26 | minor: 27 | labels: 28 | - "minor" 29 | patch: 30 | labels: 31 | - "patch" 32 | default: patch 33 | replacers: 34 | - search: "/$(Be|Fe|Devops|Be,fe|Fe,be)/" 35 | replace: "[$1]" 36 | - search: "/(feature|bugfix|hotfix|refactor)(,(feature|bugfix|hotfix|refactor))/" 37 | replace: "[$1$2]" 38 | - search: "/" 39 | replace: " " 40 | -------------------------------------------------------------------------------- /.github/workflows/random-review-assign.yml: -------------------------------------------------------------------------------- 1 | name: Random Review Assign 2 | 3 | on: 4 | pull_request: 5 | types: [opened, ready_for_review, synchronize, reopened] 6 | 7 | jobs: 8 | assign: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4.1.1 13 | 14 | - name: pick_random_reviwer 15 | id: pick_random_reviwer 16 | uses: actions/github-script@v6 17 | with: 18 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 19 | script: | 20 | const myNickname = context.actor; 21 | const all = ['kimyu0218', 'iQuQi', 'Doosies', 'HeoJiye']; 22 | const candidate = all.filter((nickname) => nickname !== myNickname); 23 | const reviewers = candidate.sort(() => Math.random() - 0.5).slice(0, 2).join(', '); 24 | core.setOutput('reviewers', reviewers); 25 | 26 | - uses: hkusu/review-assign-action@v1.3.1 27 | with: 28 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 29 | assignees: ${{ github.actor }} 30 | reviewers: ${{ steps.pick_random_reviwer.outputs.reviewers }} 31 | max-num-of-reviewers: 2 32 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | update-release-draft: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | steps: 18 | - name: Set up Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | 23 | - name: Update Release Drafter 24 | uses: release-drafter/release-drafter@v5 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env* 2 | *.local 3 | 4 | *.pem 5 | *.eslintcache 6 | 7 | # compiled output 8 | */dist 9 | */**/dist 10 | */dist-ssr 11 | *node_modules 12 | 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | pnpm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | lerna-debug.log* 21 | 22 | # OS 23 | .DS_Store 24 | 25 | # Tests 26 | /coverage 27 | /.nyc_output 28 | 29 | # IDEs and editors 30 | /.idea 31 | .project 32 | .classpath 33 | .c9/ 34 | *.launch 35 | .settings/ 36 | *.sublime-workspace 37 | 38 | # IDE - VSCode 39 | .vscode/* 40 | !.vscode/settings.json 41 | !.vscode/tasks.json 42 | !.vscode/launch.json 43 | !.vscode/extensions.json 44 | 45 | *.suo 46 | *.ntvs* 47 | *.njsproj 48 | *.sln 49 | *.sw? -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | . "$(dirname -- "$0")/_/husky.sh" 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | . "$(dirname -- "$0")/_/husky.sh" 4 | 5 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 6 | 7 | ALLOWED_BRANCH_PATTERN="^(BE|FE|DEVOPS|BE,FE|FE,BE)\/(feature|bugfix|hotfix|refactor)(,(feature|bugfix|hotfix|refactor))*\/#[0-9]+(-#[0-9]+)*.+$" 8 | 9 | if ! [[ $BRANCH =~ $ALLOWED_BRANCH_PATTERN ]]; then 10 | echo "Error: You are not allowed to push to the branch \"$BRANCH\"" 11 | exit 1 12 | fi 13 | 14 | exit 0 -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | *node_modules/ 2 | *dist/ 3 | 4 | *nest-cli.json 5 | *tsconfig.build.json 6 | *tsconfig.json 7 | 8 | *.git 9 | *.gitignore -------------------------------------------------------------------------------- /backend/Dockerfile.nginx: -------------------------------------------------------------------------------- 1 | FROM nginx:1.18.0 2 | 3 | COPY --link nginx/default.conf /etc/nginx/conf.d/default.conf 4 | 5 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /backend/Dockerfile.signal: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | RUN apt-get update && apt-get install -y tini 4 | 5 | WORKDIR /app 6 | 7 | COPY package*.json ./ 8 | RUN npm ci 9 | 10 | COPY packages ./ 11 | RUN npm run build-prod:logger 12 | RUN npm run build-prod:event 13 | 14 | WORKDIR /app/signal 15 | 16 | COPY signal/package*.json ./ 17 | RUN npm ci 18 | RUN npm run move:logger && npm run move:event 19 | 20 | COPY signal . 21 | RUN npm run build 22 | 23 | CMD ["tini", "--", "npm", "run", "start:prod"] -------------------------------------------------------------------------------- /backend/Dockerfile.was: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | RUN apt-get update && apt-get install -y tini 4 | 5 | WORKDIR /app 6 | 7 | COPY package*.json ./ 8 | RUN npm ci 9 | 10 | COPY packages ./ 11 | RUN npm run build-prod:logger 12 | RUN npm run build-prod:event 13 | 14 | WORKDIR /app/was 15 | 16 | COPY was/package*.json ./ 17 | RUN npm ci 18 | RUN npm run move:logger && npm run move:event 19 | 20 | COPY was . 21 | RUN npm run build 22 | 23 | CMD ["tini", "--", "npm", "run", "start:prod"] -------------------------------------------------------------------------------- /backend/compose.blue-build.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | was-blue: 5 | container_name: "was-blue" 6 | build: 7 | context: . 8 | cache_from: 9 | - ${DOCKER_USERNAME}/magicconch:was-latest 10 | dockerfile: Dockerfile.was 11 | env_file: .env 12 | environment: 13 | - PORT=3000 14 | expose: 15 | - "3000" 16 | volumes: 17 | - /var/log/was:/app/was/logs 18 | - /var/log/ormlogs.log:/app/was/ormlogs.log 19 | networks: 20 | - backend 21 | image: "${DOCKER_USERNAME}/magicconch:was-blue-${GITHUB_SHA}" 22 | 23 | signal-blue: 24 | container_name: "signal-blue" 25 | build: 26 | context: . 27 | cache_from: 28 | - ${DOCKER_USERNAME}/magicconch:signal-latest 29 | dockerfile: Dockerfile.signal 30 | env_file: .env 31 | environment: 32 | - PORT=3001 33 | expose: 34 | - "3001" 35 | networks: 36 | - backend 37 | image: "${DOCKER_USERNAME}/magicconch:signal-blue-${GITHUB_SHA}" 38 | 39 | networks: 40 | backend: 41 | external: true 42 | name: backend 43 | -------------------------------------------------------------------------------- /backend/compose.cache-export.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | was: 5 | build: 6 | context: . 7 | cache_from: 8 | - ${DOCKER_USERNAME}/magicconch:was-latest 9 | cache_to: 10 | - ${DOCKER_USERNAME}/magicconch:was-latest 11 | dockerfile: Dockerfile.was 12 | 13 | signal: 14 | build: 15 | context: . 16 | cache_from: 17 | - ${DOCKER_USERNAME}/magicconch:signal-latest 18 | cache_to: 19 | - ${DOCKER_USERNAME}/magicconch:signal-latest 20 | dockerfile: Dockerfile.signal 21 | -------------------------------------------------------------------------------- /backend/compose.green-build.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | was-green: 5 | container_name: "was-green" 6 | build: 7 | context: . 8 | cache_from: 9 | - ${DOCKER_USERNAME}/magicconch:was-latest 10 | dockerfile: Dockerfile.was 11 | env_file: .env 12 | environment: 13 | - PORT=3002 14 | expose: 15 | - "3002" 16 | volumes: 17 | - /var/log/was:/app/was/logs 18 | - /var/log/ormlogs.log:/app/was/ormlogs.log 19 | networks: 20 | - backend 21 | image: "${DOCKER_USERNAME}/magicconch:was-green-${GITHUB_SHA}" 22 | 23 | signal-green: 24 | container_name: "signal-green" 25 | build: 26 | context: . 27 | cache_from: 28 | - ${DOCKER_USERNAME}/magicconch:signal-latest 29 | dockerfile: Dockerfile.signal 30 | env_file: .env 31 | environment: 32 | - PORT=3003 33 | expose: 34 | - "3003" 35 | networks: 36 | - backend 37 | image: "${DOCKER_USERNAME}/magicconch:signal-green-${GITHUB_SHA}" 38 | 39 | networks: 40 | backend: 41 | external: true 42 | name: backend 43 | -------------------------------------------------------------------------------- /backend/send-slack-message.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SLACK_WEBHOOK_URI=$1 4 | DEPLOYMENT_RESULT=$2 5 | GITHUB_REF=$3 6 | GITHUB_ACTOR=$4 7 | GITHUB_WORKFLOW=$5 8 | 9 | COLOR='' 10 | 11 | if [ "$DEPLOYMENT_RESULT" == 'Success' ]; then 12 | COLOR="good" 13 | else 14 | COLOR="danger" 15 | fi 16 | 17 | BRANCH=$(echo "$GITHUB_REF" | sed 's|^refs/heads/||') 18 | 19 | SLACK_MESSAGE="{ 20 | \"attachments\": [{ 21 | \"color\": \"$COLOR\", 22 | \"mrkdwn_in\": [\"text\", \"fields\"], 23 | \"pretext\": \"타로밀크티의 GitHub Actions에서 보내는 슬랙 알림입니다.\", 24 | \"title\": \":rocket: Deployment Result - $DEPLOYMENT_RESULT :rocket:\", 25 | \"fields\": [ 26 | {\"title\": \"Branch\", \"value\": \"$BRANCH\"}, 27 | {\"title\": \"Author\", \"value\": \"$GITHUB_ACTOR\"}, 28 | {\"title\": \"Workflow\", \"value\": \"$GITHUB_WORKFLOW\"} 29 | ], 30 | \"author_name\": \"타로밀크티 GitHub Actions\" 31 | }] 32 | }" 33 | 34 | curl -X POST -H 'Content-type: application/json' --data "$SLACK_MESSAGE" "$SLACK_WEBHOOK_URI" 35 | -------------------------------------------------------------------------------- /backend/signal/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | 'no-console': 'warn', 21 | '@typescript-eslint/no-unused-vars': 'off', 22 | '@typescript-eslint/interface-name-prefix': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/explicit-module-boundary-types': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | 'prettier/prettier': [ 27 | 'error', 28 | { 29 | endOfLine: 'auto', 30 | }, 31 | ], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /backend/signal/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 5 | "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 6 | "importOrderParserPlugins": ["typescript", "decorators-legacy"], 7 | "importOrderSeparation": false, 8 | "importOrderSortSpecifiers": true 9 | } 10 | -------------------------------------------------------------------------------- /backend/signal/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/signal/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | @Get('/health-check') 6 | healthCheck(): boolean { 7 | return true; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/signal/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { AppController } from './app.controller'; 4 | import { EventsModule } from './events/events.module'; 5 | import { LoggerModule } from './logger/logger.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | ConfigModule.forRoot({ isGlobal: true }), 10 | EventsModule, 11 | LoggerModule, 12 | ], 13 | controllers: [AppController], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /backend/signal/src/events/events.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { LoggerModule } from 'src/logger/logger.module'; 3 | import { LoggerService } from 'src/logger/logger.service'; 4 | import { EventsGateway } from './events.gateway'; 5 | 6 | @Module({ 7 | imports: [LoggerModule], 8 | providers: [EventsGateway, LoggerService], 9 | }) 10 | export class EventsModule {} 11 | -------------------------------------------------------------------------------- /backend/signal/src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { winstonLogger } from 'winston-logger'; 3 | import { LoggerService } from './logger.service'; 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: 'WINSTON', 9 | useValue: winstonLogger('SIGNAL'), 10 | }, 11 | LoggerService, 12 | ], 13 | exports: ['WINSTON', LoggerService], 14 | }) 15 | export class LoggerModule {} 16 | -------------------------------------------------------------------------------- /backend/signal/src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Logger } from 'winston'; 3 | 4 | @Injectable() 5 | export class LoggerService { 6 | constructor(@Inject('WINSTON') private readonly logger: Logger) {} 7 | 8 | log(message: string) { 9 | this.logger.log('info', message); 10 | } 11 | 12 | debug(message: string) { 13 | this.logger.debug(message); 14 | } 15 | 16 | info(message: string) { 17 | this.logger.info(message); 18 | } 19 | error(message: string, trace?: string) { 20 | this.logger.error(message, trace); 21 | } 22 | 23 | warn(message: string) { 24 | this.logger.warn(message); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/signal/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import * as dotenv from 'dotenv'; 3 | import { AppModule } from './app.module'; 4 | import { LoggerService } from './logger/logger.service'; 5 | 6 | dotenv.config(); 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | 11 | const logger: LoggerService = app.get(LoggerService); 12 | 13 | app.enableCors({ 14 | origin: process.env.CORS_ALLOW_DOMAIN, 15 | methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], 16 | credentials: true, 17 | allowedHeaders: ['Authorization', 'Content-type', 'samesite'], 18 | }); 19 | 20 | app.useLogger(logger); 21 | 22 | const port: number = parseInt(process.env.PORT || '3001'); 23 | const server: any = await app.listen(port); 24 | 25 | process.on('SIGTERM', async () => { 26 | logger.log('🖐️ Received SIGTERM signal. Start Graceful Shutdown...'); 27 | 28 | await server.close(); 29 | await app.close(); 30 | 31 | process.exit(0); 32 | }); 33 | } 34 | bootstrap(); 35 | -------------------------------------------------------------------------------- /backend/signal/src/mocks/events/logger.mock.ts: -------------------------------------------------------------------------------- 1 | export const loggerServiceMock = { 2 | debug: jest.fn(), 3 | info: jest.fn(), 4 | warn: jest.fn(), 5 | error: jest.fn(), 6 | fatal: jest.fn(), 7 | }; 8 | -------------------------------------------------------------------------------- /backend/signal/src/mocks/events/room.mock.ts: -------------------------------------------------------------------------------- 1 | import { guestSocketMock, hostSocketMock } from './socket.mock'; 2 | 3 | type Room = { 4 | roomId: string; 5 | password: string; 6 | wrongRoomId: string; 7 | wrongPassword: string; 8 | }; 9 | 10 | export const roomMock: Room = { 11 | roomId: 'testRoomId', 12 | password: 'testPassword', 13 | wrongRoomId: 'wrongRoomId', 14 | wrongPassword: 'wrongPassword', 15 | }; 16 | 17 | export const createRoomMock = (users: string[]) => ({ 18 | [roomMock.roomId]: { 19 | users, 20 | password: roomMock.password, 21 | }, 22 | }); 23 | 24 | export const twoPeopleInsideRoomMock = () => 25 | createRoomMock([hostSocketMock.id, guestSocketMock.id]); 26 | export const onlyGuestInsideRoomMock = () => 27 | createRoomMock([guestSocketMock.id]); 28 | export const onlyHostInsideRoomMock = () => createRoomMock([hostSocketMock.id]); 29 | -------------------------------------------------------------------------------- /backend/signal/src/mocks/events/socket.mock.ts: -------------------------------------------------------------------------------- 1 | export type SocketMock = { 2 | id: string; 3 | emit: jest.Mock; 4 | join: jest.Mock; 5 | to: jest.Mock; 6 | }; 7 | 8 | export const toRoomEmitMock = jest.fn(); 9 | 10 | export const guestSocketMock: SocketMock = { 11 | id: 'testGuestSocketId', 12 | emit: jest.fn(), 13 | join: jest.fn(), 14 | to: jest.fn().mockImplementation(() => ({ emit: toRoomEmitMock })), 15 | }; 16 | 17 | export const hostSocketMock: SocketMock = { 18 | id: 'testHostSocketId', 19 | emit: jest.fn(), 20 | join: jest.fn(), 21 | to: jest.fn().mockImplementation(() => ({ emit: toRoomEmitMock })), 22 | }; 23 | -------------------------------------------------------------------------------- /backend/signal/src/mocks/events/user.mock.ts: -------------------------------------------------------------------------------- 1 | import { guestSocketMock, hostSocketMock } from './socket.mock'; 2 | 3 | type User = { 4 | roomId: string; 5 | role: 'host' | 'guest'; 6 | }; 7 | 8 | export const hostUserMock: User = { 9 | roomId: 'testRoomId', 10 | role: 'host', 11 | }; 12 | export const guestUserMock: User = { 13 | roomId: 'testRoomId', 14 | role: 'guest', 15 | }; 16 | 17 | export const twoPeopleInsideUsersMock = () => 18 | createUsersMock([ 19 | { id: hostSocketMock.id, user: hostUserMock }, 20 | { id: guestSocketMock.id, user: guestUserMock }, 21 | ]); 22 | export const onlyGuestInsideUsersMock = () => 23 | createUsersMock([{ id: guestSocketMock.id, user: guestUserMock }]); 24 | export const onlyHostInsideUsersMock = () => 25 | createUsersMock([{ id: hostSocketMock.id, user: hostUserMock }]); 26 | 27 | export const createUsersMock = (mock: Array<{ id: string; user: User }>) => 28 | mock.reduce((acc, { id, user }) => ({ ...acc, [id]: user }), {}); 29 | -------------------------------------------------------------------------------- /backend/signal/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/signal/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "src", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | 10 | "moduleNameMapper": { 11 | "^src/(.*)$": "/../src/$1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/signal/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/mocks"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/signal/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/was/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js', 'jest*.config.js'], 19 | rules: { 20 | 'no-console': 'warn', 21 | '@typescript-eslint/no-unused-vars': 'off', 22 | '@typescript-eslint/interface-name-prefix': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/explicit-module-boundary-types': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | 'prettier/prettier': [ 27 | 'error', 28 | { 29 | endOfLine: 'auto', 30 | }, 31 | ], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /backend/was/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 5 | "importOrder": [ 6 | "^@nestjs/(.*)$", 7 | "^@core/(.*)$", 8 | "^@server/(.*)$", 9 | "^@ui/(.*)$", 10 | "^@common/(.*)$", 11 | "^@config/(.*)$", 12 | "^@constants/(.*)$", 13 | "^@interceptors/(.*)$", 14 | "^@exceptions/(.*)$", 15 | "^@logger/(.*)$", 16 | "^@(.*)$", 17 | "^[./]" 18 | ], 19 | "importOrderParserPlugins": ["typescript", "decorators-legacy"], 20 | "importOrderSeparation": false, 21 | "importOrderSortSpecifiers": true 22 | } 23 | -------------------------------------------------------------------------------- /backend/was/jest-e2e.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('./jest.config'); 2 | 3 | module.exports = jestConfig('e2e'); 4 | -------------------------------------------------------------------------------- /backend/was/jest-unit.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('./jest.config'); 2 | 3 | module.exports = jestConfig('unit'); 4 | -------------------------------------------------------------------------------- /backend/was/jest.config.js: -------------------------------------------------------------------------------- 1 | const commonJestConfig = require('./jest.config.json'); 2 | 3 | module.exports = (target) => { 4 | if (target === 'unit') { 5 | return { 6 | ...commonJestConfig, 7 | testRegex: '.*\\.spec\\.ts$', 8 | }; 9 | } 10 | if (target === 'e2e') { 11 | return { 12 | ...commonJestConfig, 13 | testRegex: '.e2e-spec.ts$', 14 | }; 15 | } 16 | return commonJestConfig; 17 | }; 18 | -------------------------------------------------------------------------------- /backend/was/jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "moduleDirectories": ["node_modules", "src"], 4 | "moduleFileExtensions": ["js", "json", "ts"], 5 | "transform": { 6 | "^.+\\.(t|j)s$": "ts-jest" 7 | }, 8 | "collectCoverageFrom": ["**/*.(t|j)s"], 9 | "coverageDirectory": "../coverage", 10 | "testEnvironment": "node", 11 | "moduleNameMapper": { 12 | "src/(.*)": "/src/$1", 13 | "@auth/(.*)$": "/src/auth/$1", 14 | "@chat/(.*)$": "/src/chat/$1", 15 | "@chatbot/(.*)$": "/src/chatbot/$1", 16 | "@common/(.*)$": "/src/common/$1", 17 | "@config/(.*)$": "/src/common/config/$1", 18 | "@constants/(.*)$": "/src/common/constants/$1", 19 | "@interceptors/(.*)$": "/src/common/interceptors/$1", 20 | "@exceptions/(.*)$": "/src/exceptions/$1", 21 | "@logger/(.*)$": "/src/logger/$1", 22 | "@members/(.*)$": "/src/members/$1", 23 | "@socket/(.*)$": "/src/socket/$1", 24 | "@tarot/(.*)$": "/src/tarot/$1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/was/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/was/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { HealthCheckDecorator } from './app.decorators'; 3 | 4 | @Controller() 5 | export class AppController { 6 | @Get('/health-check') 7 | @HealthCheckDecorator() 8 | healthCheck(): boolean { 9 | return true; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/was/src/app.decorators.ts: -------------------------------------------------------------------------------- 1 | import { SwaggerDecoratorBuilder } from '@kimyu0218/swagger-decorator-builder'; 2 | 3 | export const HealthCheckDecorator = () => 4 | new SwaggerDecoratorBuilder() 5 | .setOperation({ summary: '도커 컨테이너를 실행할 때 헬스체크용 API' }) 6 | .removeResponse(401) 7 | .removeResponse(403) 8 | .removeResponse(404) 9 | .build(); 10 | -------------------------------------------------------------------------------- /backend/was/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_INTERCEPTOR } from '@nestjs/core'; 3 | import { RedisCacheModule } from '@config/cache/redis-cache.module'; 4 | import { MysqlModule } from '@config/database/mysql.module'; 5 | import { JwtConfigModule } from '@config/jwt/jwt.module'; 6 | import { ErrorsInterceptor } from '@interceptors/errors.interceptor'; 7 | import { LoggerModule } from '@logger/logger.module'; 8 | import { AuthModule } from '@auth/auth.module'; 9 | import { ChatModule } from '@chat/chat.module'; 10 | import { ChatbotModule } from '@chatbot/chatbot.module'; 11 | import { MembersModule } from '@members/members.module'; 12 | import { SocketModule } from '@socket/socket.module'; 13 | import { TarotModule } from '@tarot/tarot.module'; 14 | import { AppController } from './app.controller'; 15 | 16 | @Module({ 17 | imports: [ 18 | RedisCacheModule.register(), 19 | JwtConfigModule.register(), 20 | MembersModule, 21 | MysqlModule, 22 | ChatModule, 23 | TarotModule, 24 | ChatbotModule, 25 | SocketModule, 26 | LoggerModule, 27 | AuthModule, 28 | ], 29 | controllers: [AppController], 30 | providers: [ 31 | { 32 | provide: APP_INTERCEPTOR, 33 | useClass: ErrorsInterceptor, 34 | }, 35 | ], 36 | }) 37 | export class AppModule {} 38 | -------------------------------------------------------------------------------- /backend/was/src/auth/auth.decorators.ts: -------------------------------------------------------------------------------- 1 | import { SwaggerDecoratorBuilder } from '@kimyu0218/swagger-decorator-builder'; 2 | 3 | export const AuthenticateDecorator = (returnType: any) => 4 | new SwaggerDecoratorBuilder() 5 | .setOperation({ summary: '로그인 여부 확인' }) 6 | .removeResponse(401) 7 | .removeResponse(403) 8 | .removeResponse(404) 9 | .addResponse({ 10 | status: 200, 11 | description: '로그인 여부 반환', 12 | type: returnType, 13 | }) 14 | .build(); 15 | 16 | export const KakaoLoginDecorator = () => 17 | new SwaggerDecoratorBuilder() 18 | .setOperation({ summary: '카카오 로그인' }) 19 | .addQuery({ 20 | name: 'code', 21 | type: 'string', 22 | description: '카카오 로그인의 인가 코드', 23 | }) 24 | .removeResponse(403) 25 | .removeResponse(404) 26 | .addResponse(400) 27 | .build(); 28 | 29 | export const LogoutDecorator = () => 30 | new SwaggerDecoratorBuilder() 31 | .setOperation({ summary: '로그아웃' }) 32 | .removeResponse(403) 33 | .removeResponse(404) 34 | .addResponse(400) 35 | .build(); 36 | -------------------------------------------------------------------------------- /backend/was/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Member } from '@members/entities'; 5 | import { AuthController } from './auth.controller'; 6 | import { JwtAuthGuard, SocketJwtAuthGuard } from './guard'; 7 | import { AuthService, KakaoAuthService } from './service'; 8 | import { JwtStrategy } from './strategies/jwt.strategy'; 9 | 10 | @Module({ 11 | imports: [PassportModule, TypeOrmModule.forFeature([Member])], 12 | controllers: [AuthController], 13 | providers: [ 14 | AuthService, 15 | KakaoAuthService, 16 | JwtStrategy, 17 | JwtAuthGuard, 18 | SocketJwtAuthGuard, 19 | ], 20 | exports: [PassportModule, JwtAuthGuard, SocketJwtAuthGuard], 21 | }) 22 | export class AuthModule {} 23 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/auth-status.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class AuthStatusDto { 5 | @IsBoolean() 6 | @ApiProperty({ description: '로그인 여부', required: true }) 7 | isAuthenticated: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-status.dto'; 2 | export * from './jwt-payload.dto'; 3 | export * from './oauth-token.dto'; 4 | export * from './profile.dto'; 5 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/jwt-payload.dto.ts: -------------------------------------------------------------------------------- 1 | export class JwtPayloadDto { 2 | readonly email: string; 3 | readonly providerId: number; 4 | readonly accessToken: string; 5 | 6 | static fromInfo( 7 | email: string, 8 | providerId: number, 9 | accessToken: string, 10 | ): JwtPayloadDto { 11 | return { 12 | email: email, 13 | providerId: providerId, 14 | accessToken: accessToken, 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/kakao/index.ts: -------------------------------------------------------------------------------- 1 | export * from './kakao-token.dto'; 2 | export * from './kakao-access-token-info.dto'; -------------------------------------------------------------------------------- /backend/was/src/auth/dto/kakao/kakao-access-token-info.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#get-token-info-response-body 3 | */ 4 | export class KakaoAccessTokenInfoDto { 5 | readonly id: number; 6 | readonly expires_in: number; 7 | readonly app_id: number; 8 | } 9 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/kakao/kakao-token.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token-response 3 | */ 4 | export class KakaoTokenDto { 5 | readonly token_type: string; 6 | readonly access_token: string; 7 | readonly id_token?: string; 8 | readonly expires_in: number; 9 | readonly refresh_token: string; 10 | readonly refresh_token_expires_in: number; 11 | readonly scope?: string; 12 | } 13 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/oauth-token.dto.ts: -------------------------------------------------------------------------------- 1 | import { KakaoTokenDto } from './kakao'; 2 | 3 | export class OAuthTokenDto { 4 | readonly token_type: string; 5 | readonly access_token: string; 6 | readonly id_token?: string; 7 | readonly expires_in: number; 8 | readonly refresh_token: string; 9 | readonly refresh_token_expires_in: number; 10 | readonly scope?: string; 11 | 12 | static fromKakao(kakao: KakaoTokenDto): OAuthTokenDto { 13 | return { 14 | token_type: kakao.token_type, 15 | access_token: kakao.access_token, 16 | id_token: kakao.id_token, 17 | expires_in: kakao.expires_in, 18 | refresh_token: kakao.refresh_token, 19 | refresh_token_expires_in: kakao.refresh_token_expires_in, 20 | scope: kakao.scope, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/was/src/auth/dto/profile.dto.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info-response 3 | * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#kakaoaccount 4 | * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#profile 5 | */ 6 | export class ProfileDto { 7 | readonly email: string; 8 | readonly nickname: string; 9 | readonly profileUrl?: string; 10 | 11 | static fromKakao(account: any): ProfileDto { 12 | return { 13 | email: account.email, 14 | nickname: account.profile.nickname, 15 | profileUrl: account.profile.picture, 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/was/src/auth/guard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jwt-auth.guard'; 2 | export * from './socket-jwt-auth.guard'; 3 | -------------------------------------------------------------------------------- /backend/was/src/auth/guard/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /backend/was/src/auth/guard/socket-jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { Observable } from 'rxjs'; 4 | import { JwtPayloadDto } from '../dto'; 5 | 6 | @Injectable() 7 | export class SocketJwtAuthGuard implements CanActivate { 8 | constructor(private readonly jwtService: JwtService) {} 9 | 10 | canActivate( 11 | context: ExecutionContext, 12 | ): boolean | Promise | Observable { 13 | const client = context.switchToWs().getClient(); 14 | const cookie: string | null = client.handshake.headers.cookie; 15 | if (!cookie) { 16 | return true; 17 | } 18 | const token: string = cookie.replace('magicconch=', ''); 19 | try { 20 | const decodedToken: JwtPayloadDto = this.verifyToken(token); 21 | client.user = { 22 | email: decodedToken.email, 23 | providerId: decodedToken.providerId, 24 | }; 25 | return true; 26 | } catch (err: unknown) { 27 | throw err; 28 | } 29 | } 30 | 31 | private verifyToken(token: string): JwtPayloadDto { 32 | return this.jwtService.verify(token); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/was/src/auth/interface/cache-key.ts: -------------------------------------------------------------------------------- 1 | export interface CacheKey { 2 | email: string; 3 | providerId: number; 4 | } 5 | -------------------------------------------------------------------------------- /backend/was/src/auth/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.service'; 2 | export * from './kakao.auth.service'; -------------------------------------------------------------------------------- /backend/was/src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import * as dotenv from 'dotenv'; 4 | import { Request } from 'express'; 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | import { JwtPayloadDto } from '../dto'; 7 | 8 | dotenv.config(); 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy) { 12 | constructor() { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromExtractors([ 15 | (req: Request) => req.cookies.magicconch, 16 | ]), 17 | ignoreExpiration: false, 18 | secretOrKey: process.env.JWT_SECRET_KEY, 19 | }); 20 | } 21 | 22 | async validate(payload: any): Promise { 23 | return { 24 | email: payload.email, 25 | providerId: payload.providerId, 26 | accessToken: payload.accessToken, 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/was/src/auth/util/kakao.ts: -------------------------------------------------------------------------------- 1 | export function makeRequestTokenForm( 2 | clientId: string, 3 | redirectUri: string, 4 | code: string, 5 | clientSecret: string, 6 | ): URLSearchParams { 7 | const formData = new URLSearchParams(); 8 | formData.append('grant_type', 'authorization_code'); 9 | formData.append('client_id', clientId); 10 | formData.append('redirect_uri', redirectUri); 11 | formData.append('code', code); 12 | formData.append('client_secret', clientSecret); 13 | return formData; 14 | } 15 | 16 | export function makeRefreshTokenForm( 17 | clientId: string, 18 | refreshToken: string, 19 | clientSecret: string, 20 | ): URLSearchParams { 21 | const formData = new URLSearchParams(); 22 | formData.append('grant_type', 'refresh_token'); 23 | formData.append('client_id', clientId); 24 | formData.append('refresh_token', refreshToken); 25 | formData.append('client_secret', clientSecret); 26 | return formData; 27 | } 28 | -------------------------------------------------------------------------------- /backend/was/src/chat/chat.decorators.ts: -------------------------------------------------------------------------------- 1 | import { SwaggerDecoratorBuilder } from '@kimyu0218/swagger-decorator-builder'; 2 | import { ApiBodyOptions, ApiParamOptions } from '@nestjs/swagger'; 3 | 4 | export const FindRoomsDecorator = (target: string, returnType: any) => 5 | new SwaggerDecoratorBuilder(target, 'GET', returnType) 6 | .removeResponse(403) 7 | .removeResponse(404) 8 | .addResponse(400) 9 | .build(); 10 | 11 | export const FindMessagesDecorator = ( 12 | target: string, 13 | param: ApiParamOptions, 14 | returnType: any, 15 | ) => 16 | new SwaggerDecoratorBuilder(target, 'GET', returnType) 17 | .addParam(param) 18 | .addResponse(400) 19 | .build(); 20 | 21 | export const UpdateRoomDecorator = ( 22 | target: string, 23 | param: ApiParamOptions, 24 | body: ApiBodyOptions, 25 | ) => 26 | new SwaggerDecoratorBuilder(target, 'PATCH') 27 | .addParam(param) 28 | .setBody(body) 29 | .addResponse(400) 30 | .build(); 31 | 32 | export const DeleteRoomDecorator = (target: string, param: ApiParamOptions) => 33 | new SwaggerDecoratorBuilder(target, 'DELETE') 34 | .addParam(param) 35 | .addResponse(400) 36 | .build(); 37 | -------------------------------------------------------------------------------- /backend/was/src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Member } from '@members/entities'; 4 | import { ChatController } from './chat.controller'; 5 | import { ChatService } from './chat.service'; 6 | import { ChattingMessage, ChattingRoom } from './entities'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([ChattingRoom, ChattingMessage, Member])], 10 | controllers: [ChatController], 11 | providers: [ChatService], 12 | }) 13 | export class ChatModule {} 14 | -------------------------------------------------------------------------------- /backend/was/src/chat/chatting-info.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ChattingInfo { 2 | memberId: string; 3 | roomId: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/was/src/chat/dto/chatting-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { ChattingMessage } from '../entities'; 4 | 5 | export class ChattingMessageDto { 6 | @IsBoolean() 7 | @ApiProperty({ description: '호스트 여부', required: true }) 8 | readonly isHost: boolean; 9 | 10 | @IsString() 11 | @ApiProperty({ description: '채팅 메시지', required: true }) 12 | readonly message: string; 13 | 14 | static fromEntity(entity: ChattingMessage): ChattingMessageDto { 15 | return { isHost: entity.isHost, message: entity.message }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/was/src/chat/dto/chatting-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsDate, IsString, IsUUID } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { ChattingRoom } from '../entities'; 4 | 5 | export class ChattingRoomDto { 6 | @IsUUID() 7 | @ApiProperty({ description: '채팅방 ID', required: true }) 8 | readonly id: string; 9 | 10 | @IsString() 11 | @ApiProperty({ description: '채팅방 제목', required: true }) 12 | readonly title?: string; 13 | 14 | @IsDate() 15 | @ApiProperty({ description: '채팅일자', required: true }) 16 | readonly createdAt?: string; 17 | 18 | static fromEntity(entity: ChattingRoom): ChattingRoomDto { 19 | return { 20 | id: entity.id, 21 | title: entity.title ?? entity.createdAt?.toLocaleDateString('ko-KR'), 22 | createdAt: entity.createdAt?.toLocaleDateString('ko-KR'), 23 | }; 24 | } 25 | } 26 | 27 | export class ChattingRoomGroupDto { 28 | @IsString() 29 | @ApiProperty({ description: '특정 일자', required: true }) 30 | readonly date?: string; 31 | 32 | @IsArray() 33 | @ApiProperty({ 34 | description: '특정 일자의 채팅방 목록', 35 | type: ChattingRoomDto, 36 | isArray: true, 37 | required: true, 38 | }) 39 | readonly rooms: ChattingRoomDto[] = []; 40 | 41 | static makeGroup(date: string, dto: ChattingRoomDto): ChattingRoomGroupDto { 42 | return { 43 | date: date, 44 | rooms: [dto], 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/was/src/chat/dto/create-chatting-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsString, IsUUID } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { ChatLog } from '@common/types/chatbot'; 4 | 5 | export class CreateChattingMessageDto { 6 | @IsUUID() 7 | @ApiProperty({ description: '채팅방 ID', required: true }) 8 | readonly roomId: string; 9 | 10 | @IsBoolean() 11 | @ApiProperty({ description: '호스트 여부', required: true }) 12 | readonly isHost: boolean; 13 | 14 | @IsString() 15 | @ApiProperty({ 16 | description: '채팅 메시지', 17 | minLength: 1, 18 | maxLength: 1000, 19 | required: true, 20 | }) 21 | readonly message: string; 22 | 23 | static fromChatLog( 24 | roomId: string, 25 | chatLog: ChatLog, 26 | ): CreateChattingMessageDto { 27 | return { roomId, ...chatLog }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/was/src/chat/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chatting-message.dto'; 2 | export * from './chatting-room.dto'; 3 | export * from './create-chatting-message.dto'; 4 | export * from './update-chatting-room.dto'; 5 | -------------------------------------------------------------------------------- /backend/was/src/chat/dto/update-chatting-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UpdateChattingRoomDto { 5 | @IsString() 6 | @ApiProperty({ description: '채팅방 제목', required: true }) 7 | title: string; 8 | } 9 | -------------------------------------------------------------------------------- /backend/was/src/chat/entities/chatting-room.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | ManyToOne, 8 | OneToMany, 9 | OneToOne, 10 | PrimaryGeneratedColumn, 11 | UpdateDateColumn, 12 | } from 'typeorm'; 13 | import { Member } from '@members/entities'; 14 | import { TarotResult } from '@tarot/entities'; 15 | import { ChattingMessage } from './chatting-message.entity'; 16 | 17 | @Entity() 18 | export class ChattingRoom { 19 | @PrimaryGeneratedColumn('uuid') 20 | id: string; 21 | 22 | @Column({ length: 30, nullable: true }) 23 | title?: string; 24 | 25 | @CreateDateColumn() 26 | createdAt?: Date; 27 | 28 | @UpdateDateColumn() 29 | updatedAt?: Date; 30 | 31 | @DeleteDateColumn({ name: 'deleted_at', nullable: true }) 32 | deletedAt?: Date; 33 | 34 | @OneToMany(() => ChattingMessage, (chattingMessage) => chattingMessage.id) 35 | chattingMessages?: ChattingMessage[]; 36 | 37 | @ManyToOne(() => Member, (member) => member.chattingRooms, { eager: true }) 38 | participant: Member; 39 | 40 | @OneToOne(() => TarotResult, (result) => result.id, { eager: true }) 41 | @JoinColumn() 42 | result: TarotResult; 43 | 44 | static fromInfo(result: TarotResult, member: Member): ChattingRoom { 45 | const room = new ChattingRoom(); 46 | room.result = result; 47 | room.participant = member; 48 | return room; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/was/src/chat/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chatting-message.entity'; 2 | export * from './chatting-room.entity'; 3 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/chatbot.interface.ts: -------------------------------------------------------------------------------- 1 | import type { ChatLog } from '@common/types/chatbot'; 2 | 3 | export interface ChatbotService { 4 | generateTalk( 5 | chatLogs: ChatLog[], 6 | message: string, 7 | ): Promise>; 8 | generateTarotReading( 9 | chatLogs: ChatLog[], 10 | cardIdx: number, 11 | ): Promise>; 12 | } 13 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/chatbot.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ClovaStudioService } from './clova-studio/clova-studio.service'; 3 | 4 | @Module({ 5 | providers: [{ provide: 'ChatbotService', useClass: ClovaStudioService }], 6 | exports: ['ChatbotService'], 7 | }) 8 | export class ChatbotModule {} 9 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/clova-studio/api.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ClovaStudioApiKeys, 3 | ClovaStudioMessage, 4 | } from '@common/types/clova-studio'; 5 | import { 6 | CLOVA_API_DEFAULT_BODY_OPTIONS, 7 | CLOVA_API_DEFAULT_HEADER_OPTIONS, 8 | CLOVA_URL, 9 | } from '@constants/clova-studio'; 10 | import { ERR_MSG } from '@constants/errors'; 11 | 12 | type APIOptions = { 13 | apiKeys: ClovaStudioApiKeys; 14 | messages: ClovaStudioMessage[]; 15 | maxTokens: number; 16 | }; 17 | 18 | export async function clovaStudioApi({ 19 | apiKeys, 20 | messages, 21 | maxTokens, 22 | }: APIOptions): Promise> { 23 | const response = await fetch(CLOVA_URL, { 24 | method: 'POST', 25 | headers: { 26 | ...CLOVA_API_DEFAULT_HEADER_OPTIONS, 27 | ...apiKeys, 28 | }, 29 | body: JSON.stringify({ 30 | ...CLOVA_API_DEFAULT_BODY_OPTIONS, 31 | maxTokens, 32 | messages, 33 | }), 34 | }); 35 | 36 | if (!response.ok) { 37 | const errorMessage = `${ERR_MSG.AI_API_FAILED}: 상태코드 ${response.statusText}`; 38 | throw new Error(errorMessage); 39 | } 40 | if (!response.body) { 41 | throw new Error(ERR_MSG.AI_API_RESPONSE_EMPTY); 42 | } 43 | return response.body; 44 | } 45 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/clova-studio/message/builder.ts: -------------------------------------------------------------------------------- 1 | import type { ClovaStudioMessage } from '@common/types/clova-studio'; 2 | import { 3 | createTalkSystemMessage, 4 | createTarotCardMessage, 5 | createTarotCardSystemMessage, 6 | createUserMessage, 7 | } from './creator'; 8 | 9 | export function buildTalkMessages( 10 | messages: ClovaStudioMessage[], 11 | userMessage: string, 12 | ): ClovaStudioMessage[] { 13 | return [ 14 | createTalkSystemMessage(), 15 | ...messages, 16 | createUserMessage(userMessage), 17 | ]; 18 | } 19 | 20 | export function buildTarotReadingMessages( 21 | messages: ClovaStudioMessage[], 22 | cardIdx: number, 23 | ): ClovaStudioMessage[] { 24 | return [ 25 | createTalkSystemMessage(), 26 | ...messages, 27 | createTarotCardSystemMessage(), 28 | createTarotCardMessage(cardIdx), 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/clova-studio/message/converter.ts: -------------------------------------------------------------------------------- 1 | import type { ChatLog } from '@common/types/chatbot'; 2 | import type { ClovaStudioMessage } from '@common/types/clova-studio'; 3 | 4 | export function chatLog2clovaStudioMessages( 5 | chatLogs: ChatLog[], 6 | ): ClovaStudioMessage[] { 7 | const convertedMessages = chatLogs.reduce((acc, { isHost, message }) => { 8 | acc.push({ 9 | role: isHost ? 'assistant' : 'user', 10 | content: message, 11 | }); 12 | return acc; 13 | }, [] as ClovaStudioMessage[]); 14 | 15 | return [...convertedMessages]; 16 | } 17 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/clova-studio/message/creator.ts: -------------------------------------------------------------------------------- 1 | import { WsException } from '@nestjs/websockets'; 2 | import type { ClovaStudioMessage } from '@common/types/clova-studio'; 3 | import { 4 | TALK_SYSTEM_MESSAGE, 5 | TAROTCARD_NAMES, 6 | TAROTREADING_SYSTEM_MESSAGE, 7 | } from '@constants/clova-studio'; 8 | import { ERR_MSG } from '@constants/errors'; 9 | 10 | export function createTalkSystemMessage(): ClovaStudioMessage { 11 | return { role: 'system', content: TALK_SYSTEM_MESSAGE }; 12 | } 13 | 14 | export function createTarotCardSystemMessage(): ClovaStudioMessage { 15 | return { role: 'system', content: TAROTREADING_SYSTEM_MESSAGE }; 16 | } 17 | 18 | export function createUserMessage(userMessage: string): ClovaStudioMessage { 19 | if (!userMessage.trim()) { 20 | throw new WsException(ERR_MSG.USER_CHAT_MESSAGE_INPUT_EMPTY); 21 | } 22 | return { role: 'user', content: userMessage }; 23 | } 24 | 25 | export function createTarotCardMessage(cardIdx: number): ClovaStudioMessage { 26 | if (cardIdx < 0 || cardIdx >= TAROTCARD_NAMES.length) { 27 | throw new WsException(ERR_MSG.TAROT_CARD_IDX_OUT_OF_RANGE); 28 | } 29 | return { role: 'user', content: TAROTCARD_NAMES[cardIdx] }; 30 | } 31 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/clova-studio/message/index.ts: -------------------------------------------------------------------------------- 1 | export { chatLog2clovaStudioMessages } from './converter'; 2 | 3 | export { buildTalkMessages, buildTarotReadingMessages } from './builder'; 4 | -------------------------------------------------------------------------------- /backend/was/src/chatbot/clova-studio/stream/index.ts: -------------------------------------------------------------------------------- 1 | export { apiResponseStream2TokenStream } from './converter'; 2 | -------------------------------------------------------------------------------- /backend/was/src/common/config/cache/redis-cache.module.ts: -------------------------------------------------------------------------------- 1 | import { CacheModule } from '@nestjs/cache-manager'; 2 | import { DynamicModule, Global, Module } from '@nestjs/common'; 3 | import { redisStore } from 'cache-manager-redis-store'; 4 | import * as dotenv from 'dotenv'; 5 | 6 | dotenv.config(); 7 | 8 | @Global() 9 | @Module({}) 10 | export class RedisCacheModule { 11 | static register(): DynamicModule { 12 | return { 13 | module: RedisCacheModule, 14 | imports: [ 15 | CacheModule.registerAsync({ 16 | useFactory: () => { 17 | return { 18 | config: { 19 | store: redisStore, 20 | host: process.env.CACHE_HOST, 21 | port: parseInt(process.env.CACHE_PORT ?? '6379'), 22 | }, 23 | }; 24 | }, 25 | }), 26 | ], 27 | exports: [CacheModule], 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/was/src/common/config/database/mysql.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, OnApplicationShutdown } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import * as dotenv from 'dotenv'; 4 | import { DataSource } from 'typeorm'; 5 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 6 | 7 | dotenv.config(); 8 | 9 | @Module({ 10 | imports: [ 11 | TypeOrmModule.forRootAsync({ 12 | useFactory: () => { 13 | return { 14 | type: 'mysql', 15 | host: process.env.DB_HOST, 16 | port: parseInt(process.env.DB_PORT ?? '3306'), 17 | username: process.env.DB_USERNAME, 18 | password: process.env.DB_PASSWORD, 19 | database: process.env.DB_DATABASE, 20 | synchronize: true, 21 | autoLoadEntities: true, 22 | namingStrategy: new SnakeNamingStrategy(), 23 | logging: ['query', 'error'], 24 | logger: 'file', 25 | }; 26 | }, 27 | }), 28 | ], 29 | }) 30 | export class MysqlModule implements OnApplicationShutdown { 31 | constructor(private readonly dataSource: DataSource) {} 32 | 33 | async onApplicationShutdown(signal?: string): Promise { 34 | if (this.dataSource.isInitialized) { 35 | await this.dataSource.destroy(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/was/src/common/config/jwt/jwt.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import * as dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | @Global() 8 | @Module({}) 9 | export class JwtConfigModule { 10 | static register(): DynamicModule { 11 | return { 12 | module: JwtConfigModule, 13 | imports: [ 14 | JwtModule.registerAsync({ 15 | useFactory: (): JwtModule => { 16 | return { 17 | secret: process.env.JWT_SECRET_KEY, 18 | signOptions: { expiresIn: process.env.JWT_EXPIRES_IN }, 19 | }; 20 | }, 21 | }), 22 | ], 23 | exports: [JwtModule], 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/was/src/common/config/sentry.setting.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common/interfaces/nest-application.interface'; 2 | import * as Sentry from '@sentry/node'; 3 | import { nodeProfilingIntegration } from '@sentry/profiling-node'; 4 | 5 | export function setupSentry(app: INestApplication, dsn: string): void { 6 | Sentry.init({ 7 | dsn: dsn, 8 | integrations: [nodeProfilingIntegration()], 9 | tracesSampleRate: 0.3, 10 | profilesSampleRate: 0.3, 11 | }); 12 | 13 | Sentry.setupExpressErrorHandler(app); 14 | } 15 | -------------------------------------------------------------------------------- /backend/was/src/common/config/swagger.setting.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common/interfaces/nest-application.interface'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export function setupSwagger(app: INestApplication): void { 5 | const config = new DocumentBuilder() 6 | .setTitle('MagicConch') 7 | .setDescription('The MagicConch API description') 8 | .setVersion('1.0') 9 | .build(); 10 | 11 | const document = SwaggerModule.createDocument(app, config); 12 | SwaggerModule.setup('api', app, document); 13 | } 14 | -------------------------------------------------------------------------------- /backend/was/src/common/constants/apis.ts: -------------------------------------------------------------------------------- 1 | export const METHODS = { 2 | GET: 'GET', 3 | POST: 'POST', 4 | }; 5 | 6 | export const CONTENT_TYPE = { 7 | KAKAO: 'application/x-www-form-urlencoded;charset=utf-8', 8 | }; 9 | 10 | export const OAUTH_URL = { 11 | /** 12 | * kakao 13 | */ 14 | KAKAO_USER: 'https://kapi.kakao.com/v2/user/me', 15 | KAKAO_TOKEN: 'https://kauth.kakao.com/oauth/token', 16 | KAKAO_ACCESS_TOKEN: 'https://kapi.kakao.com/v1/user/access_token_info', 17 | KAKAO_LOGOUT: 'https://kapi.kakao.com/v1/user/logout', 18 | KAKAO_LOGOUT_ALL: 'https://kauth.kakao.com/oauth/logout', 19 | }; 20 | -------------------------------------------------------------------------------- /backend/was/src/common/constants/errors.ts: -------------------------------------------------------------------------------- 1 | export const ERR_MSG = { 2 | /** 3 | * chatbot 4 | */ 5 | USER_CHAT_MESSAGE_INPUT_EMPTY: '사용자 입력한 채팅 메세지가 비어있습니다.', 6 | USER_CHAT_MESSAGE_INPUT_TOO_LONG: '사용자 입력한 채팅 메세지가 너무 깁니다.', 7 | TAROT_CARD_IDX_OUT_OF_RANGE: '타로 카드 인덱스가 범위를 벗어났습니다.', 8 | AI_API_KEY_NOT_FOUND: 'API 키를 찾을 수 없습니다.', 9 | AI_API_FAILED: '인공지능 API 호출에 실패했습니다.', 10 | AI_API_RESPONSE_EMPTY: '인공지능 API 응답이 비어있습니다.', 11 | 12 | /** 13 | * socket 14 | */ 15 | CREATE_ROOM: '채팅방을 생성하는 데 실패했습니다.', 16 | SAVE_CHATTING_LOG: '채팅 로그를 저장하는 데 실패했습니다.', 17 | SAVE_TAROT_RESULT: '타로 결과를 저장하는 데 실패했습니다.', 18 | HANDLE_MESSAGE: '서버에서 메시지를 처리하는 데 실패했습니다.', 19 | 20 | /** 21 | * common 22 | */ 23 | UNKNOWN: '알 수 없는 오류가 발생했습니다.', 24 | }; 25 | 26 | export const JWT_ERR = { 27 | TOKEN_EXPIRED: { code: 4000, message: 'TokenExpiredError' }, 28 | JSON_WEB_TOKEN: { code: 4000, message: 'JsonWebTokenError' }, 29 | NOT_BEFORE_ERROR: { code: 4000, message: 'NotBeforeError' }, 30 | }; 31 | -------------------------------------------------------------------------------- /backend/was/src/common/constants/etc.ts: -------------------------------------------------------------------------------- 1 | export const BUCKET_URL: string = 2 | 'https://kr.object.ncloudstorage.com/magicconch'; 3 | 4 | export enum ProviderName { 5 | KAKAO = 'KAKAO', 6 | NAVER = 'NAVER', 7 | GOOGLE = 'GOOGLE', 8 | } 9 | 10 | export enum ProviderIdEnum { 11 | KAKAO = 0, 12 | NAVER = 1, 13 | GOOGLE = 2, 14 | } 15 | 16 | export enum ExtEnum { 17 | JPG = 0, 18 | PNG = 1, 19 | } 20 | 21 | export const ExtArray = Object.values(ExtEnum); 22 | -------------------------------------------------------------------------------- /backend/was/src/common/constants/socket.ts: -------------------------------------------------------------------------------- 1 | export const WELCOME_MESSAGE = 2 | '안녕, 나는 어떤 고민이든지 들어주는 마법의 소라고둥이야!\n고민이 있으면 말해줘!'; 3 | 4 | export const ASK_TAROTCARD_MESSAGE_CANDIDATES = [ 5 | '타로 카드를 뽑', 6 | '타로를 뽑', 7 | '뽑아볼까?', 8 | ]; 9 | -------------------------------------------------------------------------------- /backend/was/src/common/types/chatbot.ts: -------------------------------------------------------------------------------- 1 | export type ChatLog = { 2 | isHost: boolean; 3 | message: string; 4 | }; 5 | -------------------------------------------------------------------------------- /backend/was/src/common/types/clova-studio.ts: -------------------------------------------------------------------------------- 1 | import { CLOVA_API_KEY_NAMES } from '../constants/clova-studio'; 2 | 3 | export type ClovaStudioEvent = { 4 | id: string; 5 | event: string; 6 | data: { 7 | message: ClovaStudioMessage; 8 | }; 9 | }; 10 | 11 | export type ClovaStudioMessage = { 12 | role: 'user' | 'system' | 'assistant'; 13 | content: string; 14 | }; 15 | 16 | export type ClovaStudioApiKeys = { 17 | [key in (typeof CLOVA_API_KEY_NAMES)[number]]: string; 18 | }; 19 | -------------------------------------------------------------------------------- /backend/was/src/common/types/socket.ts: -------------------------------------------------------------------------------- 1 | import { AiSocket } from 'socket-event'; 2 | import { TarotResult } from '@tarot/entities'; 3 | import { ChatLog } from './chatbot'; 4 | 5 | export interface UserInfo { 6 | email: string; 7 | providerId: number; 8 | } 9 | 10 | export interface ExtendedAiSocket extends AiSocket { 11 | user?: UserInfo; 12 | chatLog: ChatLog[]; 13 | chatEnd: boolean; 14 | result?: TarotResult; 15 | } 16 | -------------------------------------------------------------------------------- /backend/was/src/common/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import { LoggerService } from '@logger/logger.service'; 2 | 3 | export function makeErrorLogMessage(logMessage: string, err: any): string { 4 | const errorMessage: string = 5 | err instanceof Error ? err.message : JSON.stringify(err); 6 | return `${logMessage} : ${err.message || errorMessage}`; 7 | } 8 | 9 | export function logErrorWithStack( 10 | logger: LoggerService, 11 | message: string, 12 | stack: string, 13 | ) { 14 | logger.error(message, stack); 15 | } 16 | -------------------------------------------------------------------------------- /backend/was/src/common/utils/slack-webhook.ts: -------------------------------------------------------------------------------- 1 | export function makeSlackMessage(text: string, err: any): object { 2 | return { 3 | attachments: [ 4 | { 5 | color: 'danger', 6 | text: text, 7 | fields: [ 8 | { 9 | title: `Error Message: ${err.message}`, 10 | value: err.stack || JSON.stringify(err), 11 | short: false, 12 | }, 13 | ], 14 | ts: Math.floor(new Date().getTime() / 1000).toString(), 15 | }, 16 | ], 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /backend/was/src/common/utils/stream.ts: -------------------------------------------------------------------------------- 1 | export function string2Uint8Array(str: string): Uint8Array { 2 | return new TextEncoder().encode(str); 3 | } 4 | 5 | export function uint8Array2String(uint8Array: Uint8Array): string { 6 | return new TextDecoder().decode(uint8Array).trim(); 7 | } 8 | 9 | export async function string2Uint8ArrayStream( 10 | input: string, 11 | ): Promise> { 12 | const encoder = new TextEncoder(); 13 | const uint8Array = encoder.encode(input); 14 | 15 | const readableStream = new ReadableStream({ 16 | async start(controller) { 17 | controller.enqueue(uint8Array); 18 | controller.close(); 19 | }, 20 | }); 21 | 22 | return readableStream; 23 | } 24 | 25 | export function readStream( 26 | stream: ReadableStream, 27 | onStreaming: (token: string) => void, 28 | ): Promise { 29 | let message = ''; 30 | const reader = stream.getReader(); 31 | 32 | return new Promise((resolve) => { 33 | const readStream = () => { 34 | reader.read().then(({ done, value }) => { 35 | if (done) { 36 | resolve(message); 37 | return; 38 | } 39 | const token = new TextDecoder().decode(value); 40 | message += token; 41 | onStreaming(message); 42 | 43 | return readStream(); 44 | }); 45 | }; 46 | readStream(); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/codemap/chat-codemap.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionCodemap } from './type'; 2 | 3 | export const CHAT_CODEMAP: ExceptionCodemap = { 4 | ROOM_NOT_FOUND: { 5 | status: 404, 6 | message: '채팅방을 찾을 수 없습니다.', 7 | code: 'MCE001', 8 | description: '요청에 포함된 채팅방 아이디가 존재하지 않습니다.', 9 | }, 10 | ROOM_FORBIDDEN: { 11 | status: 403, 12 | message: '채팅방을 수정/삭제할 수 없습니다.', 13 | code: 'MCE002', 14 | description: 15 | '요청한 사용자가 해당 채팅방의 수정/삭제 권한을 가지고 있지 않습니다.', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/codemap/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-codemap'; 2 | export * from './chat-codemap'; 3 | export * from './tarot-codemap'; 4 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/codemap/members-codemap.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionCodemap } from './type'; 2 | 3 | export const MEMBERS_CODEMAP: ExceptionCodemap = { 4 | NOT_FOUND: { 5 | status: 404, 6 | message: '사용자를 찾을 수 없습니다.', 7 | code: 'MME001', 8 | description: 9 | '해당 이메일에 해당하는 사용자가 존재하지 않습니다. 쿠키에 들어있는 email, providerId가 올바른지 확인해주세요.', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/codemap/tarot-codemap.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionCodemap } from './type'; 2 | 3 | export const TAROT_CODEMAP: ExceptionCodemap = { 4 | CARD_NOT_FOUND: { 5 | status: 404, 6 | message: '타로 카드를 찾을 수 없습니다.', 7 | code: 'MTE001', 8 | description: 9 | '요청에 포함된 타로 카드 번호가 존재하지 않습니다. 해당 번호가 타로 카드 범위 내에 속하는지 확인해주세요.', 10 | }, 11 | RESULT_NOT_FOUND: { 12 | status: 404, 13 | message: '타로 결과를 찾을 수 없습니다.', 14 | code: 'MTE002', 15 | description: '요청에 포함된 타로 결과 아이디가 존재하지 않습니다.', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/codemap/type.ts: -------------------------------------------------------------------------------- 1 | import { ExceptionMetadata } from '../metadata'; 2 | 3 | export type ExceptionCodemap = Record; 4 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/custom-exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | import { ExceptionMetadata } from './metadata'; 3 | 4 | /** 5 | * https://github.com/nestjs/nest/blob/master/packages/common/exceptions/http.exception.ts 6 | */ 7 | export class CustomException extends HttpException { 8 | constructor({ status, message, code, description }: ExceptionMetadata) { 9 | super({ message, code, description }, status); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-exception'; 2 | -------------------------------------------------------------------------------- /backend/was/src/exceptions/metadata.ts: -------------------------------------------------------------------------------- 1 | export interface ExceptionMetadata { 2 | status: number; 3 | message: string; 4 | code: string; 5 | description?: string; 6 | } 7 | -------------------------------------------------------------------------------- /backend/was/src/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { winstonLogger } from 'winston-logger'; 2 | import { Module } from '@nestjs/common'; 3 | import { LoggerService } from './logger.service'; 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: 'WINSTON', 9 | useValue: winstonLogger('WAS'), 10 | }, 11 | LoggerService, 12 | ], 13 | exports: ['WINSTON', LoggerService], 14 | }) 15 | export class LoggerModule {} 16 | -------------------------------------------------------------------------------- /backend/was/src/logger/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'winston'; 2 | import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class LoggerService implements OnApplicationShutdown { 6 | constructor(@Inject('WINSTON') private readonly logger: Logger) {} 7 | 8 | async onApplicationShutdown(_signal?: string): Promise { 9 | this.logger.close(); 10 | } 11 | 12 | log(message: string) { 13 | this.logger.log('info', message); 14 | } 15 | 16 | debug(message: string) { 17 | this.logger.debug(message); 18 | } 19 | 20 | info(message: string) { 21 | this.logger.info(message); 22 | } 23 | 24 | warn(message: string) { 25 | this.logger.warn(message); 26 | } 27 | 28 | error(message: string, trace?: string) { 29 | this.logger.error(`${message}${trace ? `\n${trace}` : ''}`); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/was/src/members/dto/create-member.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsIn, 4 | IsInt, 5 | IsOptional, 6 | IsString, 7 | IsUrl, 8 | } from 'class-validator'; 9 | import { ApiProperty } from '@nestjs/swagger'; 10 | import { ProviderIdEnum } from '@constants/etc'; 11 | import { ProfileDto } from '@auth/dto'; 12 | 13 | export class CreateMemberDto { 14 | @IsEmail() 15 | readonly email: string; 16 | 17 | @IsInt() 18 | @IsIn(Object.values(ProviderIdEnum)) 19 | readonly providerId: number; 20 | 21 | @IsString() 22 | @ApiProperty({ 23 | description: '사용자 닉네임', 24 | required: true, 25 | }) 26 | readonly nickname: string; 27 | 28 | @IsUrl() 29 | @IsOptional() 30 | @ApiProperty({ 31 | description: '사용자 프로필 URL', 32 | required: false, 33 | }) 34 | readonly profileUrl?: string; 35 | 36 | static fromProfile(providerId: number, profile: ProfileDto): CreateMemberDto { 37 | return { 38 | email: profile.email, 39 | providerId: providerId, 40 | nickname: profile.nickname, 41 | profileUrl: profile.profileUrl, 42 | }; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/was/src/members/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-member.dto'; 2 | export * from './update-member.dto'; 3 | -------------------------------------------------------------------------------- /backend/was/src/members/dto/member.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { Member } from '../entities'; 3 | import { CreateMemberDto } from './create-member.dto'; 4 | 5 | export class MemberDto extends PartialType(CreateMemberDto) { 6 | static fromEntity(entity: Member): MemberDto { 7 | return { 8 | nickname: entity.nickname ?? '', 9 | profileUrl: entity.profileUrl, 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/was/src/members/dto/update-member.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { ProfileDto } from '@auth/dto'; 3 | import { CreateMemberDto } from './create-member.dto'; 4 | 5 | export class UpdateMemberDto extends PartialType(CreateMemberDto) { 6 | static fromProfile(providerId: number, profile: ProfileDto): UpdateMemberDto { 7 | return { 8 | email: profile.email, 9 | providerId: providerId, 10 | nickname: profile.nickname, 11 | profileUrl: profile.profileUrl, 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/was/src/members/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './member.entity'; 2 | -------------------------------------------------------------------------------- /backend/was/src/members/members.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Req, UseGuards } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { JwtAuthGuard } from '@auth/guard'; 4 | import { MemberDto } from './dto/member.dto'; 5 | import { FindMemberByEmailDecorator } from './members.decorators'; 6 | import { MembersService } from './members.service'; 7 | 8 | @UseGuards(JwtAuthGuard) 9 | @Controller('/members') 10 | @ApiTags('✅ Members API') 11 | export class MembersController { 12 | constructor(private readonly membersService: MembersService) {} 13 | 14 | @Get() 15 | @FindMemberByEmailDecorator('사용자 정보', MemberDto) 16 | async findMemberByEmail(@Req() req: any): Promise { 17 | return await this.membersService.findMemberByEmail( 18 | req.user.email, 19 | req.user.providerId, 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/was/src/members/members.decorators.ts: -------------------------------------------------------------------------------- 1 | import { SwaggerDecoratorBuilder } from '@kimyu0218/swagger-decorator-builder'; 2 | 3 | export const FindMemberByEmailDecorator = (target: string, returnType: any) => 4 | new SwaggerDecoratorBuilder(target, 'GET', returnType) 5 | .removeResponse(403) 6 | .build(); 7 | -------------------------------------------------------------------------------- /backend/was/src/members/members.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Member } from './entities'; 4 | import { MembersController } from './members.controller'; 5 | import { MembersService } from './members.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Member])], 9 | controllers: [MembersController], 10 | providers: [MembersService], 11 | }) 12 | export class MembersModule {} 13 | -------------------------------------------------------------------------------- /backend/was/src/members/members.service.ts: -------------------------------------------------------------------------------- 1 | import { Repository } from 'typeorm'; 2 | import { Injectable } from '@nestjs/common'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { MEMBERS_CODEMAP } from '@exceptions/codemap/members-codemap'; 5 | import { CustomException } from '@exceptions/custom-exception'; 6 | import { MemberDto } from './dto/member.dto'; 7 | import { Member } from './entities'; 8 | 9 | @Injectable() 10 | export class MembersService { 11 | constructor( 12 | @InjectRepository(Member) 13 | private readonly membersRepository: Repository, 14 | ) {} 15 | 16 | async findMemberByEmail( 17 | email: string, 18 | providerId: number, 19 | ): Promise { 20 | const member: Member | null = await this.membersRepository.findOne({ 21 | where: { email, providerId }, 22 | select: ['nickname', 'profileUrl'], 23 | }); 24 | if (!member) { 25 | throw new CustomException(MEMBERS_CODEMAP.NOT_FOUND); 26 | } 27 | return MemberDto.fromEntity(member); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/was/src/mocks/clova-studio/index.ts: -------------------------------------------------------------------------------- 1 | export * from './clova-studio.mocks'; 2 | -------------------------------------------------------------------------------- /backend/was/src/mocks/socket/index.ts: -------------------------------------------------------------------------------- 1 | export * from './socket.mocks'; 2 | -------------------------------------------------------------------------------- /backend/was/src/socket/socket.module.ts: -------------------------------------------------------------------------------- 1 | // socket.module.ts 2 | import { Module } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { LoggerModule } from '@logger/logger.module'; 5 | import { LoggerService } from '@logger/logger.service'; 6 | import { ChatService } from '@chat/chat.service'; 7 | import { ChattingMessage } from '@chat/entities/chatting-message.entity'; 8 | import { ChattingRoom } from '@chat/entities/chatting-room.entity'; 9 | import { ChatbotModule } from '@chatbot/chatbot.module'; 10 | import { Member } from '@members/entities/member.entity'; 11 | import { TarotCardPack } from '@tarot/entities/tarot-card-pack.entity'; 12 | import { TarotCard } from '@tarot/entities/tarot-card.entity'; 13 | import { TarotResult } from '@tarot/entities/tarot-result.entity'; 14 | import { TarotService } from '@tarot/tarot.service'; 15 | import { SocketGateway } from './socket.gateway'; 16 | import { SocketService } from './socket.service'; 17 | 18 | @Module({ 19 | imports: [ 20 | TypeOrmModule.forFeature([ 21 | Member, 22 | ChattingMessage, 23 | ChattingRoom, 24 | TarotResult, 25 | TarotCard, 26 | TarotCardPack, 27 | ]), 28 | LoggerModule, 29 | ChatbotModule, 30 | ], 31 | providers: [ 32 | SocketGateway, 33 | SocketService, 34 | ChatService, 35 | TarotService, 36 | LoggerService, 37 | ], 38 | }) 39 | export class SocketModule {} 40 | -------------------------------------------------------------------------------- /backend/was/src/socket/ws-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch } from '@nestjs/common'; 2 | import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets'; 3 | import { SocketService } from './socket.service'; 4 | 5 | @Catch(WsException) 6 | export class WsExceptionFilter extends BaseWsExceptionFilter { 7 | constructor(private readonly socketService: SocketService) { 8 | super(); 9 | } 10 | 11 | catch(exception: WsException, host: ArgumentsHost) { 12 | if (!(this.socketService instanceof SocketService)) { 13 | return; 14 | } 15 | const client = host.switchToWs().getClient(); 16 | client.emit('error', exception.message); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/was/src/tarot/dto/create-tarot-result.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsUrl } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { BUCKET_URL } from '@constants/etc'; 4 | 5 | export class CreateTarotResultDto { 6 | /** 7 | * TODO : 추후 변동 가능성 있음 8 | */ 9 | @IsUrl() 10 | @ApiProperty({ description: '타로 카드 URL', required: true }) 11 | readonly cardUrl: string; 12 | 13 | @IsString() 14 | @ApiProperty({ description: '타로 해설 결과', required: true }) 15 | readonly message: string; 16 | 17 | static fromResult(cardNo: number, message: string): CreateTarotResultDto { 18 | return { 19 | cardUrl: `${BUCKET_URL}/basic/${cardNo}.jpg`, 20 | message: message, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/was/src/tarot/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-tarot-result.dto'; 2 | export * from './tarot-card.dto'; 3 | export * from './tarot-result.dto'; 4 | -------------------------------------------------------------------------------- /backend/was/src/tarot/dto/tarot-card.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsUrl } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { BUCKET_URL, ExtArray } from '@constants/etc'; 4 | import { TarotCard } from '../entities'; 5 | 6 | export class TarotCardDto { 7 | @IsUrl() 8 | @ApiProperty({ description: '타로 카드 이미지 URL', required: true }) 9 | readonly cardUrl: string; 10 | 11 | static fromEntity(entity: TarotCard): TarotCardDto { 12 | const extStr: string = ExtArray[entity.ext] as string; 13 | return { 14 | cardUrl: `${BUCKET_URL}/basic/${entity.cardNo}.${extStr.toLowerCase()}`, 15 | }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/was/src/tarot/dto/tarot-result.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsUrl } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { TarotResult } from '../entities'; 4 | 5 | export class TarotResultDto { 6 | @IsUrl() 7 | @ApiProperty({ description: '타로 카드 이미지 URL', required: true }) 8 | readonly cardUrl: string; 9 | 10 | @IsString() 11 | @ApiProperty({ description: '타로 해설 결과', required: true }) 12 | readonly message: string; 13 | 14 | static fromEntity(entity: TarotResult): TarotResultDto { 15 | return { cardUrl: entity.cardUrl, message: entity.message }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/was/src/tarot/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './tarot-card-pack.entity'; 2 | export * from './tarot-card.entity'; 3 | export * from './tarot-result.entity'; 4 | -------------------------------------------------------------------------------- /backend/was/src/tarot/entities/tarot-card-pack.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | OneToMany, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { Member } from '@members/entities'; 11 | import { TarotCard } from './tarot-card.entity'; 12 | 13 | /** 14 | * TODO : 추후 개선 사항 15 | * 사용자별 카드 위치 : objectUrl/memberId/cardPackId/cardNo 16 | */ 17 | @Entity() 18 | export class TarotCardPack { 19 | @PrimaryGeneratedColumn('uuid') 20 | id: string; 21 | 22 | @Column({ length: 20 }) 23 | cardPackName: string; 24 | 25 | @CreateDateColumn() 26 | createdAt: Date; 27 | 28 | @UpdateDateColumn() 29 | updatedAt: Date; 30 | 31 | @Column({ nullable: true }) 32 | deletedAt: Date; 33 | 34 | @ManyToOne(() => Member, (member) => member.tarotCardPacks) 35 | owner: Member; 36 | 37 | @OneToMany(() => TarotCard, (tarotCard) => tarotCard.cardPack) 38 | tarotCards: TarotCard[]; 39 | } 40 | -------------------------------------------------------------------------------- /backend/was/src/tarot/entities/tarot-card.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 10 | import { ExtEnum } from '@constants/etc'; 11 | import { TarotCardPack } from './tarot-card-pack.entity'; 12 | 13 | @Entity() 14 | export class TarotCard { 15 | @PrimaryGeneratedColumn('uuid') 16 | id: string; 17 | 18 | @Column('tinyint') 19 | cardNo: number; 20 | 21 | @Column({ type: 'tinyint' }) 22 | ext: ExtEnum; 23 | 24 | @CreateDateColumn() 25 | createdAt?: Date; 26 | 27 | @UpdateDateColumn() 28 | updatedAt?: Date; 29 | 30 | @DeleteDateColumn({ name: 'deleted_at', nullable: true }) 31 | deletedAt?: Date; 32 | 33 | @ManyToOne(() => TarotCardPack, (tarotCardPack) => tarotCardPack.tarotCards) 34 | cardPack?: TarotCardPack; 35 | } 36 | -------------------------------------------------------------------------------- /backend/was/src/tarot/entities/tarot-result.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | import { CreateTarotResultDto } from '../dto'; 9 | 10 | @Entity() 11 | export class TarotResult { 12 | @PrimaryGeneratedColumn('uuid') 13 | id: string; 14 | 15 | @Column({ length: 255 }) 16 | cardUrl: string; 17 | 18 | @Column({ length: 1000 }) 19 | message: string; 20 | 21 | @CreateDateColumn() 22 | createdAt?: Date; 23 | 24 | @UpdateDateColumn() 25 | updatedAt?: Date; 26 | 27 | static fromDto(dto: CreateTarotResultDto): TarotResult { 28 | const result: TarotResult = new TarotResult(); 29 | result.cardUrl = dto.cardUrl; 30 | result.message = dto.message; 31 | return result; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/was/src/tarot/tarot.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Param, 5 | ParseIntPipe, 6 | ParseUUIDPipe, 7 | } from '@nestjs/common'; 8 | import { ApiTags } from '@nestjs/swagger'; 9 | import { TarotCardDto, TarotResultDto } from './dto'; 10 | import { 11 | FindTarotCardDecorator, 12 | FindTarotResultDecorator, 13 | } from './tarot.decorators'; 14 | import { TarotService } from './tarot.service'; 15 | 16 | @Controller('tarot') 17 | @ApiTags('✅ Tarot API') 18 | export class TarotController { 19 | constructor(private readonly tarotService: TarotService) {} 20 | 21 | @Get('card/:cardNo') 22 | @FindTarotCardDecorator( 23 | '타로 카드 이미지', 24 | { type: 'integer', name: 'cardNo' }, 25 | TarotCardDto, 26 | ) 27 | async findTarotCardByCardNo( 28 | @Param('cardNo', ParseIntPipe) cardNo: number, 29 | ): Promise { 30 | return await this.tarotService.findTarotCardByCardNo(cardNo); 31 | } 32 | 33 | @Get('result/:id') 34 | @FindTarotResultDecorator( 35 | '타로 결과', 36 | { type: 'uuid', name: 'id' }, 37 | TarotResultDto, 38 | ) 39 | async findTarotResultById( 40 | @Param('id', ParseUUIDPipe) id: string, 41 | ): Promise { 42 | return await this.tarotService.findTarotResultById(id); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/was/src/tarot/tarot.decorators.ts: -------------------------------------------------------------------------------- 1 | import { ApiParamOptions } from '@nestjs/swagger'; 2 | import { SwaggerDecoratorBuilder } from '@kimyu0218/swagger-decorator-builder'; 3 | 4 | export const FindTarotCardDecorator = ( 5 | target: string, 6 | param: ApiParamOptions, 7 | returnType: any, 8 | ) => 9 | new SwaggerDecoratorBuilder(target, 'GET', returnType) 10 | .addParam(param) 11 | .removeResponse(401) 12 | .removeResponse(403) 13 | .addResponse(400) 14 | .build(); 15 | 16 | export const FindTarotResultDecorator = ( 17 | target: string, 18 | param: ApiParamOptions, 19 | returnType: any, 20 | ) => 21 | new SwaggerDecoratorBuilder(target, 'GET', returnType) 22 | .addParam(param) 23 | .removeResponse(401) 24 | .removeResponse(403) 25 | .addResponse(400) 26 | .build(); 27 | -------------------------------------------------------------------------------- /backend/was/src/tarot/tarot.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { TarotCard, TarotCardPack, TarotResult } from './entities'; 4 | import { TarotController } from './tarot.controller'; 5 | import { TarotService } from './tarot.service'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([TarotCard, TarotResult, TarotCardPack])], 9 | controllers: [TarotController], 10 | providers: [TarotService], 11 | }) 12 | export class TarotModule {} 13 | -------------------------------------------------------------------------------- /backend/was/test/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const id: string = '12345678-1234-5678-1234-567812345670'; 2 | export const id2: string = '12345678-1234-5678-1234-567812345671'; 3 | export const wrongId: string = '12345678-0000-0000-1234-567812345678'; 4 | export const jwtToken: string = 5 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImV4cCI6IjFoIn0.eyJlbWFpbCI6InRhcm90bWlsa3RlYUBrYWthby5jb20iLCJwcm92aWRlcklkIjowLCJhY2Nlc3NUb2tlbiI6ImFjY2Vzc1Rva2VuIn0.DpYPxbwWGA6kYkyYb3vJSS0PTiyy3ihkiM54Bm6XAoM'; 6 | export const diffJwtToken: string = 7 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImV4cCI6IjFoIn0.eyJlbWFpbCI6InRhcm90bWlsa3RlYTJAa2FrYWtvLmNvbSIsInByb3ZpZGVySWQiOjAsImFjY2Vzc1Rva2VuIjoiYWNjZXNzVG9rZW4ifQ.W05BuKFRn3lMTMNA0uLALM3wiW-KhKwPXTAxXqbKAr0'; 8 | -------------------------------------------------------------------------------- /backend/was/test/common/database/sqlite.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 4 | 5 | @Module({ 6 | imports: [ 7 | TypeOrmModule.forRoot({ 8 | type: 'sqlite', 9 | database: ':memory:', 10 | entities: [__dirname + '/../../../src/**/entities/*.entity.{js,ts}'], 11 | synchronize: true, 12 | namingStrategy: new SnakeNamingStrategy(), 13 | }), 14 | ], 15 | }) 16 | export class SqliteModule {} 17 | -------------------------------------------------------------------------------- /backend/was/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "test", 6 | "dist", 7 | "**/*spec.ts", 8 | "**/mocks", 9 | "**/__mocks__" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /backend/was/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "ES2021", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": true, 17 | "noImplicitAny": true, 18 | "strictBindCallApply": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "noFallthroughCasesInSwitch": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/was/tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@auth/*": ["src/auth/*"], 6 | "@chat/*": ["src/chat/*"], 7 | "@chatbot/*": ["src/chatbot/*"], 8 | "@common/*": ["src/common/*"], 9 | "@config/*": ["src/common/config/*"], 10 | "@constants/*": ["src/common/constants/*"], 11 | "@interceptors/*": ["src/common/interceptors/*"], 12 | "@exceptions/*": ["src/exceptions/*"], 13 | "@logger/*": ["src/logger/*"], 14 | "@members/*": ["src/members/*"], 15 | "@socket/*": ["src/socket/*"], 16 | "@tarot/*": ["src/tarot/*"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": false, 4 | "singleAttributePerLine": true, 5 | "semi": true, 6 | "useTabs": false, 7 | "tabWidth": 2, 8 | "trailingComma": "all", 9 | "printWidth": 120, 10 | "bracketSpacing": true, 11 | "endOfLine": "auto", 12 | "arrowParens": "avoid", 13 | "bracketLine": true, 14 | "extends": ["airbnb", "prettier"], 15 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 16 | "importOrderSeparation": true, 17 | "importOrderSortSpecifiers": true, 18 | "importOrder": [ 19 | "^(@|./)pages/(.*)$", 20 | "^(@|./)components/(.*)$", 21 | "^(@|./)business/(.*)$", 22 | "^(@|./)stores/(.*)$", 23 | "^(@|./)utils/(.*)$", 24 | "^(@|./)constants/(.*)$", 25 | "^(@|./)mocks/(.*)$", 26 | "^@iconify/(.*)$", 27 | "^./(.*)$" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /frontend/__mocks__/socket.io-client.ts: -------------------------------------------------------------------------------- 1 | export const io = vi.fn().mockReturnValue({ 2 | on: vi.fn(), 3 | emit: vi.fn(), 4 | disconnect: vi.fn(), 5 | connected: true, 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 13 | 17 | 마법의 소라고둥 18 | 19 | 20 |
21 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/bg-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/bg-light.png -------------------------------------------------------------------------------- /frontend/public/bg-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/bg-night.png -------------------------------------------------------------------------------- /frontend/public/ddung.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/ddung.png -------------------------------------------------------------------------------- /frontend/public/flipCard.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/flipCard.mp3 -------------------------------------------------------------------------------- /frontend/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/logo.png -------------------------------------------------------------------------------- /frontend/public/moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/moon.png -------------------------------------------------------------------------------- /frontend/public/sponge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/sponge.png -------------------------------------------------------------------------------- /frontend/public/spreadCards.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web09-MagicConch/ff40991e7fbfc084b691272155b83425b89cafc9/frontend/public/spreadCards.mp3 -------------------------------------------------------------------------------- /frontend/src/@types/Kakao.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | Kakao: any; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/@types/close.d.ts: -------------------------------------------------------------------------------- 1 | interface ClosePopupFunc { 2 | closePopup: () => void; 3 | } 4 | interface CloseOverlayFunc { 5 | closeOverlay: () => void; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider } from 'react-router-dom'; 2 | import { router } from 'router'; 3 | 4 | import { BackgroundMusic, Cursor } from '@components/common'; 5 | 6 | export function App() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useKakaoOAuth'; 2 | export * from './useKakaoOAuthRedirect'; 3 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/auth/useKakaoOAuth.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { KAKAO_AUTH_URL, KAKAO_LOGOUT_URL } from '@constants/kakao'; 4 | 5 | export function useKakaoOAuth() { 6 | const requestAuthorization = () => { 7 | window.location.href = KAKAO_AUTH_URL; 8 | }; 9 | 10 | const logout = async () => { 11 | const res = await axios.get(KAKAO_LOGOUT_URL, { withCredentials: true }); 12 | if (res.status === 200) { 13 | window.location.reload(); 14 | } 15 | }; 16 | 17 | return { requestAuthorization, logout }; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/auth/useKakaoOAuthRedirect.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import { isProdctionMode } from '@utils/env'; 5 | 6 | import { KAKAO_LOGIN_URL } from '@constants/kakao'; 7 | 8 | export function useKakaoOAuthRedirect() { 9 | const navigate = useNavigate(); 10 | const code = new URLSearchParams(window.location.search).get('code'); 11 | 12 | const login = async () => { 13 | const res = await axios.get(KAKAO_LOGIN_URL, { 14 | params: { code }, 15 | withCredentials: true, 16 | headers: isProdctionMode() ? { SameSite: 'None' } : {}, 17 | }); 18 | 19 | if (!res || res.status !== 200) { 20 | navigate('/'); 21 | } 22 | navigate('/chat/ai'); 23 | }; 24 | 25 | return { login }; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/chatMessage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useAiChatMessage'; 2 | export * from './useHumanChatMessage'; 3 | export * from './useChatMessage'; 4 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/chatMessage/useChatMessage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import type { Message, MessageButton } from '@components/common/ChatContainer'; 4 | 5 | export function useChatMessage() { 6 | const [messages, setMessages] = useState([]); 7 | 8 | type pushMessageOptions = { 9 | message?: string; 10 | button?: MessageButton; 11 | tarotId?: number; 12 | }; 13 | const pushMessage = (type: 'left' | 'right', profile: string, options: pushMessageOptions) => { 14 | setMessages(messages => [...messages, { type, profile, ...options }]); 15 | }; 16 | 17 | const updateMessage = (setMessage: (message: Message) => Message) => { 18 | setMessages(messages => [...messages.slice(0, -1), setMessage(messages[messages.length - 1])]); 19 | }; 20 | 21 | return { messages, pushMessage, updateMessage }; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useBlocker'; 2 | export * from './useOverflowTextBoxCenter'; 3 | export * from './useShareButtons'; 4 | export * from './useSpeakerHighlighter'; 5 | export * from './useUserFeedback'; 6 | export * from './useOutSideClickEvent'; 7 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/overlay/OverlayProvider.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, PropsWithChildren, ReactNode, createContext, useCallback, useMemo, useState } from 'react'; 2 | 3 | export const OverlayContext = createContext<{ 4 | mount(id: string, element: ReactNode): void; 5 | unmount(id: string): void; 6 | } | null>(null); 7 | 8 | export function OverlayProvider({ children }: PropsWithChildren) { 9 | const [overlayById, setOverlayById] = useState>(new Map()); 10 | 11 | const mount = useCallback((id: string, element: ReactNode) => { 12 | setOverlayById(overlayById => { 13 | const cloned = new Map(overlayById); 14 | cloned.set(id, element); 15 | return cloned; 16 | }); 17 | }, []); 18 | 19 | const unmount = useCallback((id: string) => { 20 | setOverlayById(overlayById => { 21 | const cloned = new Map(overlayById); 22 | cloned.delete(id); 23 | return cloned; 24 | }); 25 | }, []); 26 | 27 | const context = useMemo(() => ({ mount, unmount }), [mount, unmount]); 28 | 29 | return ( 30 | 31 | {children} 32 | {[...overlayById.entries()].map(([id, element]) => ( 33 | {element} 34 | ))} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/overlay/__tests__/useOverlay.spec.tsx: -------------------------------------------------------------------------------- 1 | import { OverlayProvider, useOverlay } from '..'; 2 | import { act, renderHook, screen } from '@testing-library/react'; 3 | 4 | describe('useOvelay훅 테스트', () => { 5 | const wrapper = ({ children }: { children: React.ReactNode }) => {children}; 6 | 7 | it('useOverlay의 open, close함수 호출시 mount되고 unmount된다.', async () => { 8 | const { result } = renderHook(() => useOverlay(), { wrapper }); 9 | const { closeOverlay, openOverlay } = result.current; 10 | 11 | // 오버레이가 열린다. 12 | act(() => { 13 | openOverlay(() =>
testOverlay
); 14 | }); 15 | expect(screen.getByText('testOverlay')).toBeInTheDocument(); 16 | 17 | // 오버레이가 닫힌다. 18 | act(() => { 19 | closeOverlay(); 20 | }); 21 | expect(screen.queryByText('testOverlay')).not.toBeInTheDocument(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/overlay/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './OverlayProvider'; 2 | export * from './useOverlay'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/overlay/types.tsx: -------------------------------------------------------------------------------- 1 | export type CreateOverlayElement = (props: { opened: boolean; closeOverlay: () => void }) => JSX.Element; 2 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/overlay/useOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useMemo, useState } from 'react'; 2 | 3 | import { OverlayContext } from './OverlayProvider'; 4 | import { CreateOverlayElement } from './types'; 5 | 6 | // overlay element를 구분하기 위한 id 7 | let elementId = 1; 8 | 9 | export function useOverlay() { 10 | const context = useContext(OverlayContext); 11 | if (!context) { 12 | throw new Error('OverlayProvider를 찾을 수 없습니다.'); 13 | } 14 | 15 | const [id] = useState(() => String(elementId++)); 16 | const [opened] = useState(true); 17 | 18 | const { mount, unmount } = context; 19 | 20 | useEffect(() => { 21 | return () => { 22 | unmount(id); 23 | }; 24 | }, [id, unmount]); 25 | 26 | return useMemo( 27 | () => ({ 28 | openOverlay: (OverlayElement: CreateOverlayElement) => { 29 | mount( 30 | id, 31 |
32 | unmount(id)} 36 | /> 37 |
, 38 | ); 39 | }, 40 | closeOverlay: () => { 41 | unmount(id); 42 | }, 43 | }), 44 | [id, mount, unmount], 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/popup/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePasswordPopup'; 2 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/popup/__mocks__/usePasswordPopup.ts: -------------------------------------------------------------------------------- 1 | const openPasswordPopup = vi.fn(); 2 | 3 | export const usePasswordPopup = vi.fn().mockReturnValue({ 4 | openPasswordPopup, 5 | }); 6 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/popup/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useExitPopup'; 2 | export * from './useLoginPopup'; 3 | export * from './usePasswordPopup'; 4 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/popup/useExitPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useOverlay } from '../overlay'; 2 | 3 | import { Popup } from '@components/common'; 4 | 5 | interface openExitPopupParams { 6 | onConfirm: ({ closeOverlay }: CloseOverlayFunc) => void; 7 | onCancel?: () => void; 8 | } 9 | 10 | export function useExitPopup() { 11 | const { openOverlay } = useOverlay(); 12 | 13 | const openExitPopup = ({ onConfirm, onCancel }: openExitPopupParams) => { 14 | openOverlay(({ closeOverlay }) => ( 15 | onConfirm({ closeOverlay })} 18 | onCancel={onCancel} 19 | > 20 |
21 |
나갈거야?
22 |
23 |
24 | )); 25 | }; 26 | 27 | return { openExitPopup }; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/popup/useLoginPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useOverlay } from '../overlay'; 2 | 3 | import { LoginPopup } from '@components/common'; 4 | 5 | import { getAuthorizedQuery } from '@stores/queries'; 6 | 7 | interface UseLoginPopupParams { 8 | moveAiChat: () => void; 9 | } 10 | 11 | export function useLoginPopup({ moveAiChat }: UseLoginPopupParams) { 12 | const { openOverlay } = useOverlay(); 13 | const { data } = getAuthorizedQuery(); 14 | 15 | const openLoginPopup = () => { 16 | if (data?.isAuthenticated) { 17 | moveAiChat(); 18 | return; 19 | } 20 | openOverlay(({ closeOverlay }) => ( 21 | 25 | )); 26 | }; 27 | 28 | return { openLoginPopup }; 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/popup/usePasswordPopup.tsx: -------------------------------------------------------------------------------- 1 | import { useOverlay } from '../overlay'; 2 | 3 | import { PasswordPopup } from '@components/common/Popup'; 4 | import type { InitSocketEvents } from '@components/common/Popup'; 5 | 6 | import { randomString } from '@utils/random'; 7 | 8 | type openPasswordPopupParams = { 9 | host?: boolean; 10 | onSubmit?: ({ password, closePopup }: { password: string } & ClosePopupFunc) => void; 11 | onCancel?: () => void; 12 | initSocketEvents?: InitSocketEvents; 13 | }; 14 | 15 | export function usePasswordPopup() { 16 | const { openOverlay } = useOverlay(); 17 | 18 | const openPasswordPopup = ({ host, onSubmit, onCancel, initSocketEvents }: openPasswordPopupParams) => { 19 | const defaultValue = host ? randomString() : ''; 20 | 21 | openOverlay(({ closeOverlay: closePopup }) => ( 22 | { 26 | onSubmit?.({ password, closePopup }); 27 | }} 28 | defaultValue={defaultValue} 29 | initSocketEvents={initSocketEvents} 30 | /> 31 | )); 32 | }; 33 | 34 | return { openPasswordPopup }; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/sidbar_tmp/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useSideBarButton'; 2 | export * from './useSideBarAnimation'; 3 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/sidbar_tmp/useSideBarAnimation.ts: -------------------------------------------------------------------------------- 1 | import { useSideBarStore } from '@stores/zustandStores'; 2 | 3 | import { animationNames } from '@constants/animation'; 4 | 5 | export function useSideBarAnimation() { 6 | const { first, sideBarState } = useSideBarStore(); 7 | 8 | if (first) return { animation: 'mr-[-100%]' }; 9 | 10 | if (sideBarState) return { animation: animationNames.SHOW_SIDEBAR }; 11 | 12 | return { animation: animationNames.HIDE_SIDEBAR }; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/sidbar_tmp/useSideBarButton.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { initSideBarStore, useSideBarStore } from '@stores/zustandStores'; 4 | 5 | export function useSideBarButton() { 6 | const { sideBarState, sideBarButtonState, toggleSideBarState, hideSideBar, visited } = useSideBarStore(); 7 | 8 | const buttonDisabled = !sideBarButtonState; 9 | 10 | const handleClick = () => { 11 | toggleSideBarState(); 12 | visited(); 13 | }; 14 | 15 | useEffect(() => { 16 | initSideBarStore(); 17 | }, []); 18 | 19 | useEffect(() => { 20 | if (sideBarButtonState) { 21 | return; 22 | } 23 | hideSideBar(); 24 | }, [sideBarButtonState]); 25 | 26 | return { sideBarState, handleClick, buttonDisabled }; 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './useSidebar'; 2 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/tarotSpread/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useAiTarotSpread'; 2 | export * from './useDisplayTarotCard'; 3 | export * from './useHumanTarotSpread'; 4 | export * from './useTarotSpread'; 5 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/tarotSpread/useAiTarotSpread.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { AISocketManager } from '@business/services/SocketManager'; 4 | 5 | import { useTarotSpread } from './useTarotSpread'; 6 | 7 | export function useAiTarotSpread(onPickCard: (idx: number) => void) { 8 | const socketManager = AISocketManager.getInstance(); 9 | 10 | const pickCard = (idx: number) => { 11 | socketManager.emit('tarotRead', idx); 12 | onPickCard(idx); 13 | }; 14 | 15 | const { openTarotSpread } = useTarotSpread(pickCard); 16 | 17 | useEffect(() => { 18 | socketManager.on('tarotCard', () => setTimeout(openTarotSpread, 1000)); 19 | }, []); 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/tarotSpread/useDisplayTarotCard.tsx: -------------------------------------------------------------------------------- 1 | import { useOverlay } from '@business/hooks/overlay'; 2 | 3 | import { getTarotImageQuery } from '@stores/queries'; 4 | 5 | export function useDisplayTarotCard() { 6 | const { openOverlay } = useOverlay(); 7 | 8 | const displayTarotCard = (tarotId: number) => { 9 | openOverlay(({ closeOverlay }) => { 10 | setTimeout(closeOverlay, 5000); 11 | 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }); 18 | }; 19 | 20 | return { displayTarotCard }; 21 | } 22 | 23 | function TarotCard({ tarotId }: { tarotId: number }) { 24 | const cardUrl = getTarotImageQuery(tarotId).data.cardUrl; 25 | 26 | return ( 27 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/tarotSpread/useTarotSpread.tsx: -------------------------------------------------------------------------------- 1 | import { useOverlay } from '../overlay'; 2 | 3 | import { TarotSpread } from '@components/common'; 4 | 5 | export function useTarotSpread(setTarotId: (idx: number) => void) { 6 | const { openOverlay } = useOverlay(); 7 | 8 | const pickCard = (idx: number) => { 9 | setTarotId(idx); 10 | }; 11 | 12 | const openTarotSpread = () => { 13 | openOverlay(({ opened, closeOverlay }) => ( 14 | 19 | )); 20 | }; 21 | 22 | return { openTarotSpread }; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/useOutSideClickEvent.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function useOutSideClickEvent(callback: () => void) { 4 | const ref = useRef(null); 5 | 6 | useEffect(() => { 7 | function handleOutSideClick(event: MouseEvent) { 8 | const clickedElement = event.target as HTMLElement; 9 | if (clickedElement === ref.current || ref.current?.contains(clickedElement)) { 10 | return; 11 | } 12 | callback(); 13 | } 14 | 15 | document.addEventListener('mousedown', handleOutSideClick); 16 | 17 | return () => document.removeEventListener('mousedown', handleOutSideClick); 18 | }, []); 19 | 20 | return ref; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/useOverflowTextBoxCenter.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function useOverflowTextBoxCenter() { 4 | const textBoxRef = useRef(null); 5 | 6 | useEffect(() => { 7 | if (textBoxRef.current === null) { 8 | return; 9 | } 10 | 11 | const { scrollHeight, clientHeight, scrollWidth, clientWidth } = textBoxRef.current; 12 | 13 | const scrollableWidth = scrollWidth > clientWidth; 14 | const scrollableHeight = scrollHeight > clientHeight; 15 | 16 | if (scrollableWidth) { 17 | textBoxRef.current.classList.remove('justify-center'); 18 | } 19 | if (scrollableHeight) { 20 | textBoxRef.current.classList.remove('items-center'); 21 | } 22 | }, []); 23 | 24 | return { 25 | textBoxRef, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/useUserFeedback.tsx: -------------------------------------------------------------------------------- 1 | import { feedbackForm } from '@constants/urls'; 2 | 3 | interface UserFeedbackPrams { 4 | type: keyof typeof feedbackForm; 5 | } 6 | 7 | export function useUserFeedback({ type }: UserFeedbackPrams) { 8 | const displayForm = () => window.open(feedbackForm[type], '_blank'); 9 | 10 | return { displayForm }; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/webRTC/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useDataChannel'; 2 | export * from './useMedia'; 3 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/webRTC/__mocks__/useDataChannel.ts: -------------------------------------------------------------------------------- 1 | import { mockMediaStream } from '@mocks/webRTC'; 2 | 3 | const initDataChannels = vi.fn(); 4 | const dataChannels = vi.fn().mockReturnValue(mockMediaStream); 5 | 6 | export const useDataChannel = vi.fn().mockReturnValue({ 7 | initDataChannels, 8 | dataChannels, 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/webRTC/__mocks__/useMedia.ts: -------------------------------------------------------------------------------- 1 | import { mockMediaStream } from '@mocks/webRTC'; 2 | 3 | const getLocalStream = vi.fn().mockReturnValue(Promise.resolve(mockMediaStream)); 4 | 5 | export const useMedia = vi.fn().mockReturnValue({ 6 | getLocalStream, 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/src/business/hooks/webRTC/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useDataChannel'; 2 | export * from './useDataChannelEventListener'; 3 | export * from './useMedia'; 4 | export * from './useSignalingSocket'; 5 | export * from './useWebRTC'; 6 | export * from './useMediaStream'; 7 | -------------------------------------------------------------------------------- /frontend/src/business/services/Media.ts: -------------------------------------------------------------------------------- 1 | export async function getCameraInputOptions() { 2 | const devices = await navigator.mediaDevices.enumerateDevices(); 3 | const cameraOptions = devices.filter(device => device.kind === 'videoinput'); 4 | return cameraOptions; 5 | } 6 | 7 | export async function getAudioInputOptions() { 8 | const devices = await navigator.mediaDevices.enumerateDevices(); 9 | const audiosOptions = devices.filter(device => device.kind === 'audioinput'); 10 | return audiosOptions; 11 | } 12 | 13 | export async function getMediaDeviceOptions() { 14 | const devices = await navigator.mediaDevices.enumerateDevices(); 15 | const cameraOptions = devices.filter(device => device.kind === 'videoinput'); 16 | const audiosOptions = devices.filter(device => device.kind === 'audioinput'); 17 | return { cameraOptions, audiosOptions }; 18 | } 19 | 20 | export async function getUserMediaStream({ 21 | audio = false, 22 | video = false, 23 | }: { 24 | audio?: boolean | MediaTrackConstraints | undefined; 25 | video?: boolean | MediaTrackConstraints | undefined; 26 | }) { 27 | return await navigator.mediaDevices.getUserMedia({ audio, video }); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/business/services/SocketManager/AISocketManager.ts: -------------------------------------------------------------------------------- 1 | import type { AiSocketEvent } from 'socket-event'; 2 | 3 | import { SocketManager } from './SocketManager'; 4 | 5 | export class AISocketManager extends SocketManager< 6 | AiSocketEvent['ServerToClientEvent'], 7 | AiSocketEvent['ClientToServerEvent'] 8 | > { 9 | static instance: AISocketManager | null = null; 10 | 11 | private constructor() { 12 | super(import.meta.env.VITE_WAS_URL); 13 | } 14 | 15 | static getInstance(): AISocketManager { 16 | if (!this.instance) { 17 | this.instance = new AISocketManager(); 18 | } 19 | return this.instance; 20 | } 21 | 22 | connect() { 23 | super.connect({ withCredentials: true }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/business/services/SocketManager/HumanSocketManager.ts: -------------------------------------------------------------------------------- 1 | import type { HumanSocketEvent } from 'socket-event'; 2 | 3 | import { SocketManager } from './SocketManager'; 4 | 5 | export class HumanSocketManager extends SocketManager< 6 | HumanSocketEvent['ServerToClientEvent'], 7 | HumanSocketEvent['ClientToServerEvent'] 8 | > { 9 | static instance: HumanSocketManager | null = null; 10 | 11 | private constructor() { 12 | super(import.meta.env.VITE_HUMAN_SOCKET_URL, '/signal'); 13 | } 14 | 15 | static getInstance(): HumanSocketManager { 16 | if (!this.instance) { 17 | this.instance = new HumanSocketManager(); 18 | } 19 | return this.instance; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/business/services/SocketManager/__mocks__/HumanSocketManager.ts: -------------------------------------------------------------------------------- 1 | export const HumanSocketManager = { 2 | getInstance: vi.fn().mockReturnValue({ 3 | connect: vi.fn(), 4 | disconnect: vi.fn(), 5 | on: vi.fn(), 6 | emit: vi.fn(), 7 | socket: {}, 8 | connected: false, 9 | }), 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/business/services/SocketManager/__mocks__/SocketManager.ts: -------------------------------------------------------------------------------- 1 | export class SocketManager { 2 | on = vi.fn(); 3 | emit = vi.fn(); 4 | connect = vi.fn(); 5 | disconnect = vi.fn(); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/business/services/SocketManager/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HumanSocketManager'; 2 | export * from './SocketManager'; 3 | -------------------------------------------------------------------------------- /frontend/src/business/services/SocketManager/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SocketManager'; 2 | export * from './AISocketManager'; 3 | export * from './HumanSocketManager'; 4 | -------------------------------------------------------------------------------- /frontend/src/business/services/__mocks__/Media.ts: -------------------------------------------------------------------------------- 1 | export const getCameraInputOptions = vi.fn(); 2 | export const getAudioInputOptions = vi.fn(); 3 | export const getMediaDeviceOptions = vi.fn(); 4 | export const getUserMediaStream = vi.fn(); 5 | -------------------------------------------------------------------------------- /frontend/src/business/services/__mocks__/Socket.ts: -------------------------------------------------------------------------------- 1 | export const initSignalingSocket = vi.fn(); 2 | -------------------------------------------------------------------------------- /frontend/src/business/services/__mocks__/WebRTC.ts: -------------------------------------------------------------------------------- 1 | export const WebRTC = { 2 | getInstance: vi.fn().mockReturnValue({ 3 | getPeerConnection: vi.fn(), 4 | getDataChannels: vi.fn(), 5 | getDataChannel: vi.fn(), 6 | resetForTesting: vi.fn(), 7 | connectRTCPeerConnection: vi.fn(), 8 | createOffer: vi.fn().mockResolvedValue(Promise.resolve('offer')), 9 | createAnswer: vi.fn().mockResolvedValue(Promise.resolve('answer')), 10 | setRemoteDescription: vi.fn(), 11 | setLocalDescription: vi.fn(), 12 | addIceCandidate: vi.fn(), 13 | closeRTCPeerConnection: vi.fn(), 14 | isConnectedPeerConnection: vi.fn(), 15 | addDataChannel: vi.fn(), 16 | closeDataChannels: vi.fn(), 17 | addTrack2PeerConnection: vi.fn(), 18 | replacePeerconnectionSendersTrack: vi.fn(), 19 | }), 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/src/business/services/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | // export * from './Kakao'; 2 | export * from './Media'; 3 | export * from './Socket'; 4 | export * from './WebRTC'; 5 | -------------------------------------------------------------------------------- /frontend/src/business/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Kakao'; 2 | export * from './Media'; 3 | export * from './Socket'; 4 | export * from './WebRTC'; 5 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/ChatLogContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ChatLogList } from './ChatLogList'; 2 | import { ContinueChatButton } from './ContinueChatButton'; 3 | import { useChatLogList } from './hooks'; 4 | 5 | export function ChatLogContainer() { 6 | const { list } = useChatLogList(); 7 | 8 | return ( 9 |
10 | 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/ChatLogList/ChatLogGroup.tsx: -------------------------------------------------------------------------------- 1 | import { ChatLogItem } from './ChatLogItem'; 2 | 3 | interface ChatLogGroupProps { 4 | date: string; 5 | rooms: { 6 | id: string; 7 | title: string; 8 | createdAt: string; 9 | }[]; 10 | } 11 | 12 | export function ChatLogGroup({ date, rooms }: ChatLogGroupProps) { 13 | return ( 14 |
    15 | {date} 16 | {rooms.map(({ id, title }) => ( 17 | 22 | ))} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/ChatLogList/ChatLogItem.tsx: -------------------------------------------------------------------------------- 1 | // import { IconButton } from '@components/common'; 2 | import { useAiChatLogId } from '@stores/zustandStores'; 3 | 4 | interface ChatLogItemProps { 5 | id: string; 6 | title: string; 7 | } 8 | 9 | export function ChatLogItem({ id, title }: ChatLogItemProps) { 10 | const { setId } = useAiChatLogId(); 11 | 12 | const handleClick = () => { 13 | setId(id); 14 | }; 15 | 16 | return ( 17 |
  • 21 | {title} 22 | {/*
    23 | 28 | 33 |
    */} 34 |
  • 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/ChatLogList/ChatLogList.tsx: -------------------------------------------------------------------------------- 1 | import { ChatLogGroup } from '.'; 2 | 3 | import { ChatLogListResponse } from '@stores/queries'; 4 | 5 | interface ChatLogListProps { 6 | list: ChatLogListResponse; 7 | } 8 | 9 | export function ChatLogList({ list }: ChatLogListProps) { 10 | return ( 11 |
    12 | {list.map(({ date, rooms }) => ( 13 | 18 | ))} 19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/ChatLogList/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatLogGroup'; 2 | export * from './ChatLogItem'; 3 | export * from './ChatLogList'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/ContinueChatButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from '@components/common/Buttons'; 2 | 3 | import { useAiChatLogId } from '@stores/zustandStores'; 4 | 5 | export function ContinueChatButton() { 6 | const { id, removeId } = useAiChatLogId(); 7 | 8 | if (!id) return null; 9 | 10 | const handleClick = () => { 11 | removeId(); 12 | }; 13 | 14 | return ( 15 |
    16 | 22 | 타로 상담 이어하기 23 | 24 |
    25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/__mocks__/data.ts: -------------------------------------------------------------------------------- 1 | export const chatlogs = [ 2 | { 3 | date: 'Today', 4 | logs: ['금전운', '연애운', '기타운'], 5 | }, 6 | { 7 | date: 'Yesterday', 8 | logs: ['금전운', '연애운', '기타운'], 9 | }, 10 | { 11 | date: 'Previous 7 days', 12 | logs: ['금전운', '연애운', '기타운'], 13 | }, 14 | { 15 | date: 'Previous 30 days', 16 | logs: ['금전운', '연애운', '기타운'], 17 | }, 18 | { 19 | date: 'October', 20 | logs: ['금전운', '연애운', '기타운'], 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/__mocks__/index.ts: -------------------------------------------------------------------------------- 1 | export { chatlogs } from './data'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useChatLogList } from './useChatLogList'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/hooks/useChatLogList.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { AISocketManager } from '@business/services/SocketManager'; 4 | 5 | import { getChatLogListQuery } from '@stores/queries'; 6 | import { useAiChatLogId } from '@stores/zustandStores'; 7 | 8 | export function useChatLogList() { 9 | const { data, refetch } = getChatLogListQuery(); 10 | const { removeId } = useAiChatLogId(); 11 | 12 | useEffect(() => { 13 | const socketManager = AISocketManager.getInstance(); 14 | socketManager.on('tarotCard', () => removeId()); 15 | socketManager.on('chatEnd', () => refetch()); 16 | }, []); 17 | 18 | return { list: data ?? [] }; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/ChatLogContainer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatLogList'; 2 | export * from './ChatLogContainer'; 3 | export * from './ContinueChatButton'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/aiChatPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatLogContainer'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Background/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Background'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/BackgroundMusic/BackgroundMusic.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { IconToggleButton } from '@components/common/Buttons'; 4 | 5 | import { getBgmQuery } from '@stores/queries'; 6 | 7 | export function BackgroundMusic() { 8 | const backgroundMusicURL = getBgmQuery().data.url; 9 | const [playing, setPlaying] = useState(false); 10 | 11 | return ( 12 |
    13 |
    14 | 19 |
    20 |
    29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/components/common/BackgroundMusic/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BackgroundMusic'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonColor } from '@constants/colors'; 2 | import { ButtonColorMap } from '@constants/colors'; 3 | import type { ButtonSize } from '@constants/sizes'; 4 | import { buttonSizeMap } from '@constants/sizes'; 5 | 6 | interface ButtonProps { 7 | color?: ButtonColor; 8 | size?: ButtonSize; 9 | disabled?: boolean; 10 | children?: React.ReactNode; 11 | circle?: boolean; 12 | onClick?: () => void; 13 | width?: number; 14 | height?: number; 15 | } 16 | 17 | export function Button({ 18 | size = 'm', 19 | color = 'active', 20 | disabled = false, 21 | children, 22 | onClick, 23 | circle = false, 24 | width, 25 | height, 26 | }: ButtonProps) { 27 | return ( 28 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '.'; 2 | 3 | import type { ButtonColor, IconColor } from '@constants/colors'; 4 | import { iconColorMap } from '@constants/colors'; 5 | import type { ButtonSize } from '@constants/sizes'; 6 | import { iconSizeMap } from '@constants/sizes'; 7 | 8 | import { Icon } from '@iconify/react'; 9 | 10 | interface IconButtonProps { 11 | icon: string; 12 | iconColor?: IconColor; 13 | size?: ButtonSize; 14 | buttonColor?: ButtonColor; 15 | disabled?: boolean; 16 | onClick?: () => void; 17 | children?: React.ReactNode; 18 | } 19 | 20 | export function IconButton({ 21 | icon, 22 | iconColor = 'textWhite', 23 | size = 'm', 24 | buttonColor = 'active', 25 | disabled = false, 26 | onClick, 27 | children, 28 | }: IconButtonProps) { 29 | return ( 30 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/IconToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from '.'; 2 | 3 | import type { ButtonColor, IconColor } from '@constants/colors'; 4 | import type { ButtonSize } from '@constants/sizes'; 5 | 6 | interface IconToggleButtonProps { 7 | activeIcon: string; 8 | inactiveIcon: string; 9 | iconColor?: IconColor; 10 | size?: ButtonSize; 11 | buttonActiveColor?: ButtonColor; 12 | buttonInactiveColor?: ButtonColor; 13 | onClick?: () => void; 14 | active: boolean; 15 | } 16 | 17 | export function IconToggleButton({ 18 | activeIcon, 19 | inactiveIcon, 20 | iconColor = 'textWhite', 21 | buttonActiveColor = 'active', 22 | buttonInactiveColor = 'cancel', 23 | size = 'm', 24 | onClick, 25 | active, 26 | }: IconToggleButtonProps) { 27 | return ( 28 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/InputFileButton.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, ReactElement, cloneElement, useRef } from 'react'; 2 | 3 | interface InputFileButtonProps { 4 | onChange?: (e: ChangeEvent) => void; 5 | accept?: string; 6 | children: ReactElement; 7 | } 8 | export function InputFileButton({ onChange, accept, children }: InputFileButtonProps) { 9 | const inputRef = useRef(null); 10 | 11 | const clonedChildren = cloneElement( 12 | children, 13 | { 14 | onClick: () => inputRef.current?.click(), 15 | }, 16 | children.props.children, 17 | ); 18 | 19 | return ( 20 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/KakaoLoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '.'; 2 | 3 | import { useKakaoOAuth } from '@business/hooks/auth'; 4 | 5 | import { Icon } from '@iconify/react'; 6 | 7 | interface KakaoLoginButtonProps { 8 | width?: number; 9 | } 10 | 11 | export function KakaoLoginButton({ width }: KakaoLoginButtonProps) { 12 | const { requestAuthorization } = useKakaoOAuth(); 13 | 14 | return ( 15 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/KakaoLoginoutButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '.'; 2 | 3 | import { useKakaoOAuth } from '@business/hooks/auth'; 4 | 5 | import { Icon } from '@iconify/react'; 6 | 7 | interface KakaoLoginoutButtonProps {} 8 | 9 | export function KakaoLoginoutButton({}: KakaoLoginoutButtonProps) { 10 | const { logout } = useKakaoOAuth(); 11 | 12 | return ( 13 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/LogoButton.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | interface LogoButtonProps {} 4 | 5 | export function LogoButton({}: LogoButtonProps) { 6 | return ( 7 | 11 | 마법의 소라고둥 로고 이미지 16 | 마법의 소라고둥 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/common/Buttons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button'; 2 | export * from './IconButton'; 3 | export * from './IconToggleButton'; 4 | export * from './InputFileButton'; 5 | export * from './KakaoLoginButton'; 6 | export * from './LogoButton'; 7 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/ChatContainer.tsx: -------------------------------------------------------------------------------- 1 | import type { Message } from '@components/common/ChatContainer'; 2 | 3 | import { ChatInput } from './ChatInput'; 4 | import { ChatList } from './ChatList'; 5 | 6 | interface ChatContainerProps { 7 | messages: Message[]; 8 | inputDisabled: boolean; 9 | onSubmitMessage: (message: string) => void; 10 | } 11 | export function ChatContainer({ messages, inputDisabled, onSubmitMessage }: ChatContainerProps) { 12 | return ( 13 |
    14 | 15 | 19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/ChatInput/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatInput'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/ChatList/ChatList.tsx: -------------------------------------------------------------------------------- 1 | import { MessageBox } from '../MessageBox'; 2 | import type { Message } from '../types'; 3 | import { useEffect, useRef } from 'react'; 4 | 5 | interface ChatListProps { 6 | size?: string; 7 | messages: Message[]; 8 | } 9 | 10 | export function ChatList({ messages }: ChatListProps) { 11 | const messagesRef = useRef(null); 12 | 13 | useEffect(() => { 14 | messagesRef.current!.scrollTop = messagesRef.current!.scrollHeight; 15 | }, [messages]); 16 | 17 | return ( 18 |
      22 | {messages.map(({ type, message, profile, tarotId, button, shareLinkId }, index) => { 23 | return ( 24 |
    • 28 | 36 |
    • 37 | ); 38 | })} 39 |
    40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/ChatList/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatList'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/MessageBox/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Message'; 2 | export * from './MessageBox'; 3 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ChatContainer'; 2 | export type * from './types'; 3 | -------------------------------------------------------------------------------- /frontend/src/components/common/ChatContainer/types.ts: -------------------------------------------------------------------------------- 1 | export interface MessageButton { 2 | content: string; 3 | onClick: () => void; 4 | } 5 | 6 | export interface Message { 7 | tarotId?: number; 8 | type: 'left' | 'right'; 9 | message?: string; 10 | profile: string; 11 | button?: MessageButton; 12 | shareLinkId?: string; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/components/common/Cursor/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import { detect } from 'detect-browser'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | const browser = detect(); 5 | const __iOS__ = browser?.os?.includes('iOS'); 6 | 7 | export function Cursor() { 8 | const cursorRef = useRef(null); 9 | 10 | const setCursor = (e: Event) => { 11 | const { pageX, pageY } = e as MouseEvent; 12 | 13 | if (cursorRef.current) { 14 | cursorRef.current.style.left = pageX + 'px'; 15 | cursorRef.current.style.top = pageY + 'px'; 16 | } 17 | }; 18 | 19 | useEffect(() => { 20 | addEventListener('scroll', setCursor); 21 | addEventListener('mousemove', setCursor); 22 | 23 | return () => { 24 | removeEventListener('scroll', setCursor); 25 | removeEventListener('mousemove', setCursor); 26 | }; 27 | }); 28 | 29 | return ( 30 | !__iOS__ && ( 31 |
    35 | ) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/components/common/Cursor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Cursor'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { LogoButton } from '@components/common/Buttons'; 2 | 3 | import Toast from './Toast'; 4 | 5 | interface HeaderProps { 6 | rightItems?: React.ReactNode[]; 7 | } 8 | 9 | export function Header({ rightItems }: HeaderProps) { 10 | return ( 11 |
    12 | 13 |
    14 | {rightItems?.map((item, index) => ( 15 |
    {item}
    16 | ))} 17 |
    18 | 19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify/react/dist/iconify.js'; 2 | 3 | import { useToast } from './hooks'; 4 | 5 | export function Toast() { 6 | const { message } = useToast(); 7 | 8 | return ( 9 | <> 10 | {message && ( 11 |
    12 |
    13 | 17 | {message} 18 |
    19 |
    20 | )} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/Toast/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useToast'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/Toast/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useToastStore } from '@stores/zustandStores'; 4 | 5 | export function useToast() { 6 | const { message, removeToast } = useToastStore(state => ({ 7 | message: state.message, 8 | removeToast: state.removeToast, 9 | })); 10 | 11 | useEffect(removeToast, []); 12 | 13 | useEffect(() => { 14 | if (!message) return; 15 | 16 | if (message) { 17 | const timeout = setTimeout(() => { 18 | removeToast(); 19 | }, 2000); 20 | return () => clearTimeout(timeout); 21 | } 22 | }, [message]); 23 | 24 | return { message }; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/Toast/index.ts: -------------------------------------------------------------------------------- 1 | export { Toast as default } from './Toast'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Header/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Header'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/InputText/InputText.tsx: -------------------------------------------------------------------------------- 1 | interface InputTextProps { 2 | onChange: (e: React.ChangeEvent) => void; 3 | } 4 | 5 | export const InputText = ({ onChange }: InputTextProps) => { 6 | return ( 7 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/components/common/InputText/index.ts: -------------------------------------------------------------------------------- 1 | export * from './InputText'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Popup/LoginPopup.tsx: -------------------------------------------------------------------------------- 1 | import { Button, KakaoLoginButton } from '@components/common/Buttons'; 2 | 3 | import { useOutSideClickEvent } from '@business/hooks'; 4 | 5 | interface LoginPopupProps { 6 | moveAiChat: () => void; 7 | closePopup: () => void; 8 | } 9 | export function LoginPopup({ moveAiChat, closePopup }: LoginPopupProps) { 10 | const ref = useOutSideClickEvent(closePopup); 11 | 12 | return ( 13 |
    14 |
    18 |
    19 | 로그인을 하면 20 |
    21 | 이전 상담 기록을 다시 볼 수 있어요 22 |
    23 |
    24 | 25 | 32 |
    33 |
    34 |
    35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/components/common/Popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@components/common/Buttons'; 2 | 3 | interface PopupProps { 4 | closePopup: () => void; 5 | onCancel?: () => void; 6 | onConfirm?: () => void; 7 | children: React.ReactNode; 8 | } 9 | 10 | export function Popup({ closePopup, onCancel, onConfirm, children }: PopupProps) { 11 | const closeWithCancel = () => { 12 | onCancel?.(); 13 | closePopup(); 14 | }; 15 | return ( 16 |
    17 |
    18 |
    {children}
    19 |
    20 | 27 | 34 |
    35 |
    36 |
    37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/components/common/Popup/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './LoginPopup'; 2 | export * from './Popup'; 3 | export * from './PasswordPopup'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/common/Portals/DocumentBodyPortal.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | export function DocumentBodyPortal({ children }: PropsWithChildren) { 5 | return createPortal(children, document.body); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/components/common/Portals/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DocumentBodyPortal.tsx'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/common/Select/index.ts: -------------------------------------------------------------------------------- 1 | import { SelectOptions } from './Select'; 2 | 3 | export * from './Select'; 4 | export type { SelectOptions }; 5 | -------------------------------------------------------------------------------- /frontend/src/components/common/SideBar/ContentAreaWithSideBar.tsx: -------------------------------------------------------------------------------- 1 | import { SideBar } from '.'; 2 | 3 | interface ContentAreaWithSideBarProps { 4 | children: React.ReactNode; 5 | sideBar: React.ReactNode; 6 | } 7 | 8 | export function ContentAreaWithSideBar({ children, sideBar }: ContentAreaWithSideBarProps) { 9 | return ( 10 |
    11 |
    12 |
    {children}
    13 | {sideBar} 14 |
    15 |
    16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/common/SideBar/SideBar.tsx: -------------------------------------------------------------------------------- 1 | import { type ForwardedRef, type PropsWithChildren, forwardRef } from 'react'; 2 | 3 | function SideBarComponent({ children }: PropsWithChildren, ref: ForwardedRef) { 4 | return ( 5 | 11 | ); 12 | } 13 | 14 | export const SideBar = forwardRef(SideBarComponent); 15 | -------------------------------------------------------------------------------- /frontend/src/components/common/SideBar/SideBarButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from '@components/common/Buttons'; 2 | 3 | interface SideBarButtonProps { 4 | onClick?: () => void; 5 | sideBarOpened: boolean; 6 | } 7 | 8 | export function SideBarButton({ onClick, sideBarOpened }: SideBarButtonProps) { 9 | const toggleSideBar = () => { 10 | onClick?.(); 11 | }; 12 | 13 | return ( 14 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/common/SideBar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ContentAreaWithSideBar'; 2 | export * from './SideBar'; 3 | export * from './SideBarButton'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/common/TarotSpread/TarotCard.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | interface TarotCardProps { 4 | dragging: boolean; 5 | backImg: string; 6 | frontImg: string; 7 | } 8 | 9 | export function TarotCard({ dragging, backImg, frontImg }: TarotCardProps) { 10 | const tarotCardStyle = useMemo( 11 | () => `${!dragging && 'hover:animate-tarotHovering'} animate-tarotLeaving w-full h-full -translate-y-1000 absolute`, 12 | [dragging], 13 | ); 14 | 15 | return ( 16 | <> 17 | 타로 카드 뒷면 이미지 23 | 타로 카드 앞면 이미지 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/components/common/TarotSpread/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './TarotCard'; 2 | export * from './TarotSpread'; 3 | -------------------------------------------------------------------------------- /frontend/src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Background'; 2 | export * from './BackgroundMusic'; 3 | export * from './Buttons'; 4 | export * from './ChatContainer'; 5 | export * from './Cursor'; 6 | export * from './Header'; 7 | export * from './InputText'; 8 | export * from './Popup'; 9 | export * from './Select'; 10 | export * from './SideBar'; 11 | export * from './TarotSpread'; 12 | -------------------------------------------------------------------------------- /frontend/src/components/humanChatPage/CamBox/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CamBox'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/humanChatPage/CamContainer/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './CamContainer'; 2 | -------------------------------------------------------------------------------- /frontend/src/components/humanChatPage/ProfileSetting/DeviceSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Select, SelectOptions } from '@components/common'; 2 | 3 | interface DeviceSelectProps { 4 | name: string; 5 | deviceList: SelectOptions[]; 6 | onChange: (deviceId: string) => void; 7 | defaultId?: string; 8 | } 9 | 10 | export function DeviceSelect({ name, deviceList, defaultId, onChange }: DeviceSelectProps) { 11 | return ( 12 |
    13 | 사용할 {name} 장치를 선택하세요. 14 |