├── .github ├── CODEOWNERS ├── pr-badge.yml ├── pull_request_template.md ├── release-drafter.yml └── workflows │ ├── backend_ci_tool.yml │ ├── be-cd-dev.yml │ └── release-drafter.yml ├── .gitignore ├── .husky └── pre-commit ├── BE ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src │ ├── answer │ │ ├── answer.module.ts │ │ ├── controller │ │ │ ├── answer.controller.spec.ts │ │ │ └── answer.controller.ts │ │ ├── dto │ │ │ ├── answerResponse.ts │ │ │ ├── createAnswerRequest.ts │ │ │ └── defaultAnswerRequest.ts │ │ ├── entity │ │ │ └── answer.ts │ │ ├── exception │ │ │ └── answer.exception.ts │ │ ├── fixture │ │ │ └── answer.fixture.ts │ │ ├── repository │ │ │ └── answer.repository.ts │ │ ├── service │ │ │ ├── answer.service.spec.ts │ │ │ └── answer.service.ts │ │ └── util │ │ │ └── answer.util.ts │ ├── app.controller.ts │ ├── app.entity.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth.module.ts │ │ ├── controller │ │ │ └── auth.controller.ts │ │ ├── interface │ │ │ └── auth.interface.ts │ │ ├── service │ │ │ └── auth.service.ts │ │ └── strategy │ │ │ └── google.strategy.ts │ ├── category │ │ ├── category.module.ts │ │ ├── controller │ │ │ ├── category.controller.spec.ts │ │ │ └── category.controller.ts │ │ ├── dto │ │ │ └── categoryResponse.ts │ │ ├── entity │ │ │ └── category.ts │ │ ├── exception │ │ │ └── category.exception.ts │ │ ├── fixture │ │ │ └── category.fixture.ts │ │ ├── repository │ │ │ └── category.repository.ts │ │ ├── service │ │ │ ├── category.service.spec.ts │ │ │ └── category.service.ts │ │ └── util │ │ │ └── category.util.ts │ ├── config │ │ ├── cors.config.ts │ │ ├── idrive.config.ts │ │ ├── logger.config.ts │ │ ├── swagger.config.ts │ │ └── typeorm.config.ts │ ├── constant │ │ └── constant.ts │ ├── health │ │ ├── health.module.ts │ │ └── health.scheduler.ts │ ├── main.ts │ ├── member │ │ ├── controller │ │ │ ├── member.controller.spec.ts │ │ │ └── member.controller.ts │ │ ├── dto │ │ │ ├── memberNicknameResponse.ts │ │ │ └── memberResponse.ts │ │ ├── entity │ │ │ └── member.ts │ │ ├── exception │ │ │ └── member.exception.ts │ │ ├── fixture │ │ │ └── member.fixture.ts │ │ ├── member.module.ts │ │ ├── repository │ │ │ └── member.repository.ts │ │ └── service │ │ │ ├── member.service.spec.ts │ │ │ └── member.service.ts │ ├── question │ │ ├── controller │ │ │ ├── question.controller.spec.ts │ │ │ └── question.controller.ts │ │ ├── dto │ │ │ ├── copyQuestionRequest.ts │ │ │ ├── createQuestionRequest.ts │ │ │ └── questionResponse.ts │ │ ├── entity │ │ │ └── question.ts │ │ ├── exception │ │ │ └── question.exception.ts │ │ ├── fixture │ │ │ └── question.fixture.ts │ │ ├── question.module.ts │ │ ├── repository │ │ │ └── question.repository.ts │ │ ├── service │ │ │ ├── question.service.spec.ts │ │ │ └── question.service.ts │ │ └── util │ │ │ └── question.util.ts │ ├── token │ │ ├── controller │ │ │ └── token.controller.ts │ │ ├── exception │ │ │ └── token.exception.ts │ │ ├── guard │ │ │ ├── token.hard.guard.ts │ │ │ └── token.soft.guard.ts │ │ ├── interface │ │ │ └── token.interface.ts │ │ ├── service │ │ │ └── token.service.ts │ │ ├── strategy │ │ │ ├── access.token.strategy.spec.ts │ │ │ └── access.token.strategy.ts │ │ └── token.module.ts │ ├── util │ │ ├── decorator.util.ts │ │ ├── encoder.util.ts │ │ ├── exception.util.ts │ │ ├── idrive.util.ts │ │ ├── redis.util.ts │ │ ├── swagger.util.ts │ │ ├── test.util.ts │ │ ├── token.util.ts │ │ └── util.ts │ ├── video │ │ ├── controller │ │ │ ├── video.controller.spec.ts │ │ │ └── video.controller.ts │ │ ├── dto │ │ │ ├── createVideoRequest.ts │ │ │ ├── preSignedUrlResponse.ts │ │ │ ├── singleVideoResponse.ts │ │ │ ├── uploadVideoRequest.ts │ │ │ ├── videoDetailResponse.ts │ │ │ └── videoHashResponse.ts │ │ ├── entity │ │ │ └── video.ts │ │ ├── exception │ │ │ └── video.exception.ts │ │ ├── fixture │ │ │ └── video.fixture.ts │ │ ├── repository │ │ │ └── video.repository.ts │ │ ├── service │ │ │ ├── video.service.spec.ts │ │ │ └── video.service.ts │ │ └── video.module.ts │ └── workbook │ │ ├── controller │ │ ├── workbook.controller.spec.ts │ │ └── workbook.controller.ts │ │ ├── dto │ │ ├── createWorkbookRequest.ts │ │ ├── updateWorkbookRequest.ts │ │ ├── workbookIdResponse.ts │ │ ├── workbookResponse.ts │ │ └── workbookTitleResponse.ts │ │ ├── entity │ │ └── workbook.ts │ │ ├── exception │ │ └── workbook.exception.ts │ │ ├── fixture │ │ └── workbook.fixture.ts │ │ ├── repository │ │ └── workbook.repository.ts │ │ ├── service │ │ ├── workbook.service.spec.ts │ │ └── workbook.service.ts │ │ ├── util │ │ └── workbook.util.ts │ │ └── workbook.module.ts ├── test │ ├── app.e2e-spec.ts │ ├── jest-e2e.json │ └── test.util.ts ├── tsconfig.build.json └── tsconfig.json ├── FE ├── .babelrc ├── .eslintrc.json ├── .githooks │ └── pre-commit ├── .gitignore ├── .prettierrc.json ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── _headers │ ├── favicon.ico │ ├── index.html │ └── mockServiceWorker.js ├── src │ ├── APIErrorBoundary.tsx │ ├── App.tsx │ ├── AppRouter.tsx │ ├── GlobalSvgProvider.tsx │ ├── KakaoInAppBrowserDetect.tsx │ ├── UnknownErrorBoundary.tsx │ ├── apis │ │ ├── answer.ts │ │ ├── axios.ts │ │ ├── category.ts │ │ ├── idrive.ts │ │ ├── member.ts │ │ ├── question.ts │ │ ├── video.ts │ │ └── workbook.ts │ ├── assets │ │ └── images │ │ │ ├── blank-bear.png │ │ │ ├── error-bear.png │ │ │ ├── landing-bear.png │ │ │ └── logo.png │ ├── atoms │ │ ├── interviewSetting.ts │ │ ├── media.ts │ │ └── modal.ts │ ├── components │ │ ├── WorkbookDetailPage │ │ │ ├── AddWorkbookListModal.tsx │ │ │ ├── NewWorkbookListButton.tsx │ │ │ ├── StartWithSelectedQuestionModal.tsx │ │ │ ├── WorkbookDetailPageLayout.tsx │ │ │ └── index.ts │ │ ├── common │ │ │ ├── Loading │ │ │ │ └── LoadingBounce.tsx │ │ │ ├── Mirror │ │ │ │ └── Mirror.tsx │ │ │ ├── ProgressStepBar │ │ │ │ ├── ProgressStepBar.tsx │ │ │ │ └── ProgressStepBarItem.tsx │ │ │ ├── QuestionAccordion │ │ │ │ └── QuestionAccordion.tsx │ │ │ ├── QuestionSelectionBox │ │ │ │ ├── AnswerSelectionModal │ │ │ │ │ ├── AnswerForm.tsx │ │ │ │ │ ├── AnswerScript.tsx │ │ │ │ │ └── AnswerSelectionModal.tsx │ │ │ │ ├── NoticeDialog │ │ │ │ │ └── NoticeDialog.tsx │ │ │ │ ├── QuestionAccordionList.tsx │ │ │ │ ├── QuestionAddForm.tsx │ │ │ │ ├── QuestionSelectionBox.styles.ts │ │ │ │ ├── QuestionSelectionBox.tsx │ │ │ │ ├── QuestionSelectionBoxAccordion.tsx │ │ │ │ ├── QuestionTabList.tsx │ │ │ │ ├── QuestionTabPanelBlank.tsx │ │ │ │ ├── QuestionTabPanelHeader.tsx │ │ │ │ ├── QuestionTabPanelItem.tsx │ │ │ │ ├── WorkbookAddButton.tsx │ │ │ │ ├── WorkbookEditModeDialog.tsx │ │ │ │ └── WorkbookGeneratorModal │ │ │ │ │ ├── LabelBox.tsx │ │ │ │ │ ├── WorkbookAddForm.tsx │ │ │ │ │ ├── WorkbookCategory.tsx │ │ │ │ │ ├── WorkbookEditForm.tsx │ │ │ │ │ └── WorkbookGeneratorModal.tsx │ │ │ ├── ResponsiveMenu │ │ │ │ └── ResponsiveMenu.tsx │ │ │ ├── ShareRangeToggle │ │ │ │ └── ShareRangeToggle.tsx │ │ │ ├── StartButton │ │ │ │ └── StartButton.tsx │ │ │ ├── VideoPlayer │ │ │ │ ├── IconButton.tsx │ │ │ │ ├── VideoPlayer.tsx │ │ │ │ ├── VideoPlayerFrame.tsx │ │ │ │ └── index.ts │ │ │ ├── WorkbookCard │ │ │ │ └── WorkbookCard.tsx │ │ │ └── index.ts │ │ ├── errorPage │ │ │ ├── ErrorPageLayout.tsx │ │ │ └── index.ts │ │ ├── foundation │ │ │ ├── Accordion │ │ │ │ ├── Accordion.tsx │ │ │ │ ├── AccordionDetails.tsx │ │ │ │ ├── AccordionSummary.tsx │ │ │ │ └── index.ts │ │ │ ├── Avatar │ │ │ │ └── Avatar.tsx │ │ │ ├── Box │ │ │ │ └── Box.tsx │ │ │ ├── Button │ │ │ │ ├── Button.styles.ts │ │ │ │ └── Button.tsx │ │ │ ├── CardCover │ │ │ │ └── CardCover.tsx │ │ │ ├── CheckBox │ │ │ │ └── CheckBox.tsx │ │ │ ├── Icon │ │ │ │ └── Icon.tsx │ │ │ ├── Input │ │ │ │ └── Input.tsx │ │ │ ├── InputArea │ │ │ │ └── InputArea.tsx │ │ │ ├── LeadingDot │ │ │ │ └── LeadingDot.tsx │ │ │ ├── Menu │ │ │ │ ├── Menu.tsx │ │ │ │ └── MenuItem.tsx │ │ │ ├── Modal │ │ │ │ ├── ModalContent.tsx │ │ │ │ ├── ModalFooter.tsx │ │ │ │ ├── ModalHeader.tsx │ │ │ │ ├── ModalLayout.tsx │ │ │ │ └── index.tsx │ │ │ ├── SelectionBox │ │ │ │ ├── SelectionBox.styles.tsx │ │ │ │ └── SelectionBox.tsx │ │ │ ├── StepPages │ │ │ │ ├── Step.tsx │ │ │ │ └── index.tsx │ │ │ ├── Tabs │ │ │ │ ├── Tab.tsx │ │ │ │ ├── TabPanel.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── useTabs.ts │ │ │ ├── Toast │ │ │ │ ├── ToastContainer.tsx │ │ │ │ ├── ToastItem │ │ │ │ │ ├── ToastItem.tsx │ │ │ │ │ └── ToastToggleButton.tsx │ │ │ │ ├── collapseToast.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── eventManger.ts │ │ │ │ ├── styles │ │ │ │ │ ├── Toast.styles.ts │ │ │ │ │ └── ToastAnimation.styles.ts │ │ │ │ ├── toast.ts │ │ │ │ ├── type.ts │ │ │ │ └── useToastContainer.ts │ │ │ ├── Toggle │ │ │ │ ├── Toggle.styles.ts │ │ │ │ └── Toggle.tsx │ │ │ ├── Tooltip │ │ │ │ ├── Tooltip.styles.ts │ │ │ │ └── Tooltip.tsx │ │ │ ├── Typography │ │ │ │ └── Typography.tsx │ │ │ └── index.ts │ │ ├── interviewPage │ │ │ ├── InterviewCamera.tsx │ │ │ ├── InterviewFooter │ │ │ │ ├── AnswerToggleButton.tsx │ │ │ │ ├── InterviewExitButton.tsx │ │ │ │ ├── InterviewFooter.tsx │ │ │ │ ├── NextButton.tsx │ │ │ │ ├── RecordControlButton.tsx │ │ │ │ └── index.tsx │ │ │ ├── InterviewHeader │ │ │ │ ├── InterviewHeader.tsx │ │ │ │ ├── IntervieweeName.tsx │ │ │ │ ├── RecordStatus.tsx │ │ │ │ ├── RecordTimer.tsx │ │ │ │ ├── VolumeStatus.tsx │ │ │ │ └── index.tsx │ │ │ ├── InterviewMain │ │ │ │ ├── InterviewAnswer.tsx │ │ │ │ ├── InterviewMain.tsx │ │ │ │ ├── InterviewQuestion.tsx │ │ │ │ └── index.tsx │ │ │ ├── InterviewModal │ │ │ │ ├── InterviewExitModal.tsx │ │ │ │ ├── InterviewFinishModal.tsx │ │ │ │ ├── InterviewIntroModal.tsx │ │ │ │ ├── InterviewTimeOverModal.tsx │ │ │ │ ├── MediaDisconnectedModal.tsx │ │ │ │ ├── RecordStartModal.tsx │ │ │ │ └── index.tsx │ │ │ ├── InterviewPageLayout.tsx │ │ │ └── index.ts │ │ ├── interviewSettingPage │ │ │ ├── Description.tsx │ │ │ ├── InterviewSettingContentLayout.tsx │ │ │ ├── InterviewSettingPageLayout.tsx │ │ │ ├── RecordPage │ │ │ │ └── RecordRadio.tsx │ │ │ ├── index.ts │ │ │ └── interviewSettingFooter.tsx │ │ ├── interviewVideoPage │ │ │ ├── InterviewVideoPageLayout.tsx │ │ │ ├── PrivateVideoPlayer.tsx │ │ │ ├── ShareRangeModal │ │ │ │ ├── ShareRangeIcon.tsx │ │ │ │ ├── VideoShareModal.tsx │ │ │ │ ├── VideoShareModalFooter.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── interviewVideoPublicPage │ │ │ ├── InterviewVideoPublicPageLayout.tsx │ │ │ ├── PublicVideoPlayer.tsx │ │ │ └── index.ts │ │ ├── landingPage │ │ │ ├── GoogleLoginButton.tsx │ │ │ ├── LandingImage.tsx │ │ │ ├── LandingPageLayout.tsx │ │ │ ├── WelcomeBlurb.tsx │ │ │ └── index.ts │ │ ├── layout │ │ │ ├── CenterLayout.tsx │ │ │ ├── Header │ │ │ │ ├── Header.tsx │ │ │ │ ├── Logo.tsx │ │ │ │ ├── NavigationMenu.tsx │ │ │ │ └── Navigations.tsx │ │ │ ├── Layout.tsx │ │ │ └── index.ts │ │ ├── myPage │ │ │ ├── DeleteCheckModal.tsx │ │ │ ├── MyPageHeader.tsx │ │ │ ├── MyPageLayout.tsx │ │ │ ├── MyPageTabs.tsx │ │ │ ├── Profile.tsx │ │ │ ├── TabPanel │ │ │ │ ├── QuestionSelectTabPanel.tsx │ │ │ │ ├── VideoListTabPanel.tsx │ │ │ │ └── index.ts │ │ │ ├── Thumbnail.tsx │ │ │ ├── VideoItem │ │ │ │ ├── VideoItem.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ └── workbookPage │ │ │ ├── CategoryMenu.tsx │ │ │ ├── GridWorkbookList.tsx │ │ │ ├── RequestLoginModal.tsx │ │ │ ├── Workbook.tsx │ │ │ ├── WorkbookList.tsx │ │ │ ├── WorkbookPageLayout.tsx │ │ │ ├── WorkbookPlusButton.tsx │ │ │ └── index.tsx │ ├── constants │ │ ├── api.ts │ │ ├── path.ts │ │ └── queryKey.ts │ ├── hooks │ │ ├── apis │ │ │ ├── mutations │ │ │ │ ├── useAddVideoMutation.ts │ │ │ │ ├── useAnswerDefaultMutation.ts │ │ │ │ ├── useDeleteQuestionMutation.tsx │ │ │ │ ├── useDeleteVideoMutation.ts │ │ │ │ ├── useGetPreSignedUrlMutation.ts │ │ │ │ ├── useQuestionAnswerMutation.ts │ │ │ │ ├── useQuestionCopyMutation.ts │ │ │ │ ├── useQuestionMutation.ts │ │ │ │ ├── useToggleVideoPublicMutation.ts │ │ │ │ ├── useWorkbookDeleteMutation.ts │ │ │ │ ├── useWorkbookPatchMutation.ts │ │ │ │ └── useWorkbookPostMutation.ts │ │ │ └── queries │ │ │ │ ├── useCategoryQuery.ts │ │ │ │ ├── useMemberNameQuery.ts │ │ │ │ ├── useQuestionAnswerQuery.ts │ │ │ │ ├── useQuestionWorkbookQuery.ts │ │ │ │ ├── useVideoHashQuery.ts │ │ │ │ ├── useVideoItemQuery.ts │ │ │ │ ├── useVideoListQuery.ts │ │ │ │ ├── useWorkbookListQuery.ts │ │ │ │ ├── useWorkbookQuery.ts │ │ │ │ └── useWorkbookTitleListQuery.ts │ │ ├── atoms │ │ │ ├── useInterviewSettings.ts │ │ │ └── useSelectQuestions.ts │ │ ├── pages │ │ │ └── Interview │ │ │ │ ├── useInterview.ts │ │ │ │ └── useInterviewFlow.ts │ │ ├── useBreakPoint.ts │ │ ├── useDebounce.ts │ │ ├── useInput.ts │ │ ├── useKakaoInAppBrowserDetect.ts │ │ ├── useMedia.tsx │ │ ├── useModal.ts │ │ ├── useOutsideClick.ts │ │ ├── useQuestionAdd.ts │ │ ├── useThrottleScroll.ts │ │ ├── useTimeTracker.ts │ │ ├── useUploadToIdrive.ts │ │ ├── useUserInfo.ts │ │ ├── useWindowSize.ts │ │ ├── useWorkbookAdd.ts │ │ ├── useWorkbookDelete.ts │ │ ├── useWorkbookEdit.ts │ │ └── useWorkbookQuestionDelete.ts │ ├── index.tsx │ ├── mocks │ │ ├── browser.ts │ │ ├── data │ │ │ ├── answer.json │ │ │ ├── category.json │ │ │ ├── member.json │ │ │ ├── question.json │ │ │ ├── video.json │ │ │ └── workbook.json │ │ ├── handlers │ │ │ ├── A01Error │ │ │ │ ├── answer.ts │ │ │ │ └── index.ts │ │ │ ├── A02Error │ │ │ │ ├── answer.ts │ │ │ │ └── index.ts │ │ │ ├── C02Error │ │ │ │ ├── category.ts │ │ │ │ └── index.ts │ │ │ ├── M01Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── Q01Error │ │ │ │ ├── answer.ts │ │ │ │ ├── index.ts │ │ │ │ └── question.ts │ │ │ ├── Q02Error │ │ │ │ ├── answer.ts │ │ │ │ └── index.ts │ │ │ ├── T01Error │ │ │ │ ├── answer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── member.ts │ │ │ │ ├── question.ts │ │ │ │ └── workbook.ts │ │ │ ├── T02Error │ │ │ │ ├── answer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── member.ts │ │ │ │ ├── question.ts │ │ │ │ └── workbook.ts │ │ │ ├── V01Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V02Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V03Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V04Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V05Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V06Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V07Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── V08Error │ │ │ │ ├── index.ts │ │ │ │ └── video.ts │ │ │ ├── W01Error │ │ │ │ ├── index.ts │ │ │ │ ├── question.ts │ │ │ │ └── workbook.ts │ │ │ ├── W02Error │ │ │ │ ├── index.ts │ │ │ │ ├── question.ts │ │ │ │ └── workbook.ts │ │ │ ├── W03Error │ │ │ │ ├── index.ts │ │ │ │ └── question.ts │ │ │ ├── default │ │ │ │ ├── answer.ts │ │ │ │ ├── category.ts │ │ │ │ ├── index.ts │ │ │ │ ├── member.ts │ │ │ │ ├── question.ts │ │ │ │ ├── video.ts │ │ │ │ └── workbook.ts │ │ │ ├── member.ts │ │ │ ├── serverError │ │ │ │ ├── answer.ts │ │ │ │ ├── category.ts │ │ │ │ ├── index.ts │ │ │ │ ├── member.ts │ │ │ │ ├── question.ts │ │ │ │ ├── video.ts │ │ │ │ └── workbook.ts │ │ │ └── video.ts │ │ └── scenarios.ts │ ├── modalProvider.tsx │ ├── page │ │ ├── LandingPage │ │ │ └── index.tsx │ │ ├── WorkbookDetailPage │ │ │ └── index.tsx │ │ ├── errorPage │ │ │ ├── Loader.tsx │ │ │ └── SomethingWrong.tsx │ │ ├── interviewPage │ │ │ └── index.tsx │ │ ├── interviewSettingPage │ │ │ ├── QuestionSettingPage.tsx │ │ │ ├── RecordSettingPage.tsx │ │ │ ├── ServiceTermsPage.tsx │ │ │ ├── VideoSettingPage.tsx │ │ │ └── index.tsx │ │ ├── interviewVideoPage │ │ │ └── index.tsx │ │ ├── interviewVideoPublicPage │ │ │ └── index.tsx │ │ ├── mediaStreamPage │ │ │ └── index.tsx │ │ ├── myPage │ │ │ └── index.tsx │ │ └── workbookPage │ │ │ └── index.tsx │ ├── routes │ │ ├── interviewVideoPublicLoader.ts │ │ ├── interviewWorkbookDetailLoader.ts │ │ ├── myPageLoader.ts │ │ └── rootLoader.ts │ ├── styles │ │ ├── _breakpoints.ts │ │ ├── _colors.ts │ │ ├── _global.ts │ │ ├── _gradient.ts │ │ ├── _shadow.ts │ │ ├── _typography.ts │ │ ├── _zIndex.ts │ │ └── theme.ts │ ├── types │ │ ├── answer.ts │ │ ├── category.ts │ │ ├── member.ts │ │ ├── question.ts │ │ ├── type.d.ts │ │ ├── utils.ts │ │ ├── video.ts │ │ └── workbook.ts │ └── utils │ │ ├── enhanceChildElement.ts │ │ ├── getAPIResponseData.ts │ │ ├── logAPIErrorToSentry.ts │ │ ├── media.ts │ │ ├── record.ts │ │ ├── redirectToGoogleLogin.ts │ │ ├── textUtils.ts │ │ └── userAgent.ts ├── tsconfig.json └── webpack.config.js └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /FE/ @Yoon-Hae-Min @adultlee @milk717 2 | 3 | /BE/ @quiet-honey @JangAJang 4 | -------------------------------------------------------------------------------- /.github/pr-badge.yml: -------------------------------------------------------------------------------- 1 | - label: "JIRA" 2 | message: "$issuePrefix" 3 | icon: "jira" 4 | color: "blue" 5 | url: "https://milk717.atlassian.net/browse/$issuePrefix" 6 | when: "$issuePrefix" 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Why 5 | 6 | 7 | 8 | # How 9 | 10 | 11 | 12 | # Result 13 | 14 | 15 | 16 | 17 | # Prize 18 | 19 | 20 | 21 | # Reference 22 | 23 | 24 | 25 | # Link 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION 🌈" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "Bug Fixes" 5 | label: "bug" 6 | - title: "New Features" 7 | label: "feature" 8 | - title: "Documentation" 9 | label: "documentation" 10 | - title: "Chore" 11 | label: "chore" 12 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 13 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 14 | template: | 15 | ## Changes 16 | 17 | $CHANGES 18 | -------------------------------------------------------------------------------- /.github/workflows/backend_ci_tool.yml: -------------------------------------------------------------------------------- 1 | name: BE CI 2 | on: 3 | pull_request: 4 | branches: [main, dev] 5 | 6 | defaults: 7 | run: 8 | working-directory: ./BE 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@v2 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 20.x 20 | - name: Install Dependencies 21 | run: npm install 22 | - name: Make .env 23 | run: | 24 | touch ./.env 25 | echo "${{ secrets.BE_CI_ENV }}" > ./.env 26 | - name: Make Cors Config 27 | run: | 28 | cd src/config 29 | touch ./cors.secure.ts 30 | echo "${{ secrets.BE_CONFIG }}" > ./cors.secure.ts 31 | shell: sh 32 | - name: Run Tests 33 | run: npm test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /BE/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | Dockerfile 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /BE/.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: false, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /BE/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | .env 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | # CORS 38 | cors.secure.ts -------------------------------------------------------------------------------- /BE/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /BE/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | RUN apk update && apk add ffmpeg 4 | 5 | RUN mkdir -p /var/app/ 6 | WORKDIR /var/app/ 7 | 8 | COPY package.json . 9 | COPY package-lock.json . 10 | 11 | RUN npm install 12 | 13 | COPY . . 14 | 15 | RUN npm run build 16 | 17 | 18 | EXPOSE 8080 19 | CMD ["node", "dist/main.js"] -------------------------------------------------------------------------------- /BE/README.md: -------------------------------------------------------------------------------- 1 | # be 2 | -------------------------------------------------------------------------------- /BE/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 | -------------------------------------------------------------------------------- /BE/src/answer/dto/createAnswerRequest.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from '../../util/swagger.util'; 3 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; 4 | 5 | export class CreateAnswerRequest { 6 | @ApiProperty(createPropertyOption(1, '문제 ID', Number)) 7 | @IsNumber() 8 | @IsNotEmpty() 9 | questionId: number; 10 | 11 | @ApiProperty( 12 | createPropertyOption( 13 | '이장희는 존잘 백엔드 캠퍼!', 14 | '등록할 답변 내용', 15 | String, 16 | ), 17 | ) 18 | @IsString() 19 | @IsNotEmpty() 20 | content: string; 21 | 22 | constructor(questionId: number, content: string) { 23 | this.questionId = questionId; 24 | this.content = content; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BE/src/answer/dto/defaultAnswerRequest.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from '../../util/swagger.util'; 3 | import { IsNotEmpty, IsNumber } from 'class-validator'; 4 | 5 | export class DefaultAnswerRequest { 6 | @ApiProperty(createPropertyOption(1, '문제 ID', Number)) 7 | @IsNumber() 8 | @IsNotEmpty() 9 | questionId: number; 10 | 11 | @ApiProperty(createPropertyOption(1, '답변 ID', Number)) 12 | @IsNumber() 13 | @IsNotEmpty() 14 | answerId: number; 15 | 16 | constructor(questionId: number, answerId: number) { 17 | this.questionId = questionId; 18 | this.answerId = answerId; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /BE/src/answer/entity/answer.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; 2 | import { DefaultEntity } from '../../app.entity'; 3 | import { Member } from '../../member/entity/member'; 4 | import { Question } from '../../question/entity/question'; 5 | 6 | @Entity({ name: 'Answer' }) 7 | export class Answer extends DefaultEntity { 8 | @Column({ type: 'blob' }) 9 | content: string; 10 | 11 | @ManyToOne(() => Member, { onDelete: 'CASCADE', eager: true, nullable: true }) 12 | @JoinColumn() 13 | member: Member; 14 | 15 | @ManyToOne(() => Question, { onDelete: 'CASCADE' }) 16 | @JoinColumn() 17 | question: Question; 18 | 19 | constructor( 20 | id: number, 21 | createdAt: Date, 22 | content: string, 23 | member: Member, 24 | question: Question, 25 | ) { 26 | super(id, createdAt); 27 | this.content = content; 28 | this.member = member; 29 | this.question = question; 30 | } 31 | 32 | static of(content: string, member: Member, question: Question) { 33 | return new Answer(null, new Date(), content, member, question); 34 | } 35 | 36 | isOwnedBy(member: Member) { 37 | return this.member.id === member.id; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BE/src/answer/exception/answer.exception.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpForbiddenException, 3 | HttpNotFoundException, 4 | } from '../../util/exception.util'; 5 | 6 | class AnswerNotFoundException extends HttpNotFoundException { 7 | constructor() { 8 | super('해당 답변을 찾을 수 없습니다.', 'A01'); 9 | } 10 | } 11 | 12 | class AnswerForbiddenException extends HttpForbiddenException { 13 | constructor() { 14 | super('답변에 대한 권한이 없습니다.', 'A02'); 15 | } 16 | } 17 | 18 | export { AnswerNotFoundException, AnswerForbiddenException }; 19 | -------------------------------------------------------------------------------- /BE/src/answer/fixture/answer.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Answer } from '../entity/answer'; 2 | import { memberFixture } from '../../member/fixture/member.fixture'; 3 | import { questionFixture } from '../../question/fixture/question.fixture'; 4 | import { CreateAnswerRequest } from '../dto/createAnswerRequest'; 5 | import { DefaultAnswerRequest } from '../dto/defaultAnswerRequest'; 6 | 7 | export const answerFixture = Answer.of( 8 | 'testContent', 9 | memberFixture, 10 | questionFixture, 11 | ); 12 | 13 | export const createAnswerRequestFixture = new CreateAnswerRequest( 14 | questionFixture.id, 15 | 'test', 16 | ); 17 | 18 | export const defaultAnswerRequestFixture = new DefaultAnswerRequest( 19 | questionFixture.id, 20 | answerFixture.id, 21 | ); 22 | -------------------------------------------------------------------------------- /BE/src/answer/util/answer.util.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'class-validator'; 2 | import { AnswerNotFoundException } from '../exception/answer.exception'; 3 | import { Answer } from '../entity/answer'; 4 | 5 | export const validateAnswer = (answer: Answer) => { 6 | if (isEmpty(answer)) { 7 | throw new AnswerNotFoundException(); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /BE/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { ApiExcludeEndpoint } from '@nestjs/swagger'; 4 | 5 | @Controller() 6 | export class AppController { 7 | constructor(private readonly appService: AppService) {} 8 | 9 | @Get() 10 | @ApiExcludeEndpoint() 11 | async getHello() { 12 | return this.appService.getHello(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /BE/src/app.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | } from 'typeorm'; 7 | 8 | @Entity() 9 | export class DefaultEntity extends BaseEntity { 10 | @PrimaryGeneratedColumn() 11 | readonly id: number; 12 | 13 | @CreateDateColumn() 14 | readonly createdAt: Date; 15 | 16 | constructor(id: number, createdAt: Date) { 17 | super(); 18 | this.id = id; 19 | this.createdAt = createdAt; 20 | } 21 | 22 | static new(): DefaultEntity { 23 | return new DefaultEntity(undefined, new Date()); 24 | } 25 | 26 | getId() { 27 | return this.id; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BE/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | constructor() {} 6 | 7 | getHello(): string { 8 | return 'Hello World!'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /BE/src/auth/interface/auth.interface.ts: -------------------------------------------------------------------------------- 1 | interface OAuthRequest { 2 | name: string; 3 | email: string; 4 | img: string; 5 | } 6 | 7 | export { OAuthRequest }; 8 | -------------------------------------------------------------------------------- /BE/src/auth/strategy/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Profile } from 'passport'; 4 | import 'dotenv/config'; 5 | import { Strategy } from 'passport-google-oauth20'; 6 | 7 | @Injectable() 8 | export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { 9 | constructor() { 10 | super({ 11 | clientID: process.env.OAUTH_GOOGLE_ID, 12 | clientSecret: process.env.OAUTH_GOOGLE_SECRET, 13 | callbackURL: process.env.OAUTH_GOOGLE_REDIRECT, 14 | scope: ['email', 'profile'], 15 | }); 16 | } 17 | 18 | validate(accessToken: string, refreshToken: string, profile: Profile) { 19 | const { id, name, emails, photos } = profile; 20 | 21 | return { 22 | provider: 'google', 23 | providerId: id, 24 | name: (name.familyName || '') + (name.givenName || ''), 25 | email: emails[0].value, 26 | img: photos[0].value, 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BE/src/category/category.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CategoryRepository } from './repository/category.repository'; 3 | import { CategoryService } from './service/category.service'; 4 | import { CategoryController } from './controller/category.controller'; 5 | import { TypeOrmModule } from '@nestjs/typeorm'; 6 | import { Category } from './entity/category'; 7 | import { Question } from '../question/entity/question'; 8 | import { TokenModule } from '../token/token.module'; 9 | import { Answer } from '../answer/entity/answer'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Category, Question, Answer]), 14 | TokenModule, 15 | ], 16 | providers: [CategoryRepository, CategoryService], 17 | controllers: [CategoryController], 18 | }) 19 | export class CategoryModule {} 20 | -------------------------------------------------------------------------------- /BE/src/category/controller/category.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 3 | import { CategoryService } from '../service/category.service'; 4 | import { createApiResponseOption } from '../../util/swagger.util'; 5 | import { CategoryResponse } from '../dto/categoryResponse'; 6 | 7 | @Controller('/api/category') 8 | @ApiTags('category') 9 | export class CategoryController { 10 | constructor(private categoryService: CategoryService) {} 11 | 12 | @Get() 13 | @ApiOperation({ 14 | summary: '전체 카테고리를 조회한다.', 15 | }) 16 | @ApiResponse( 17 | createApiResponseOption(200, '사용중인 카테고리 조회 추가', [ 18 | CategoryResponse, 19 | ]), 20 | ) 21 | async findCategories() { 22 | return await this.categoryService.findUsingCategories(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /BE/src/category/dto/categoryResponse.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '../entity/category'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { createPropertyOption } from '../../util/swagger.util'; 4 | 5 | export class CategoryResponse { 6 | @ApiProperty(createPropertyOption(1, '카테고리 ID', Number)) 7 | id: number; 8 | 9 | @ApiProperty(createPropertyOption('BE', '카테고리 이름', String)) 10 | name: string; 11 | 12 | constructor(id: number, name: string) { 13 | this.id = id; 14 | this.name = name; 15 | } 16 | 17 | static from(category: Category) { 18 | return new CategoryResponse(category.id, category.name); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /BE/src/category/entity/category.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm'; 2 | import { DefaultEntity } from '../../app.entity'; 3 | 4 | @Entity({ name: 'Category' }) 5 | export class Category extends DefaultEntity { 6 | @Column() 7 | name: string; 8 | 9 | constructor(id: number, name: string, createdAt: Date) { 10 | super(id, createdAt); 11 | this.name = name; 12 | } 13 | 14 | static of(name: string) { 15 | return new Category(null, name, new Date()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /BE/src/category/exception/category.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpNotFoundException } from '../../util/exception.util'; 2 | 3 | class CategoryNotFoundException extends HttpNotFoundException { 4 | constructor() { 5 | super('카테고리가 존재하지 않습니다.', 'C02'); 6 | } 7 | } 8 | 9 | export { CategoryNotFoundException }; 10 | -------------------------------------------------------------------------------- /BE/src/category/fixture/category.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '../entity/category'; 2 | 3 | export const categoryFixtureWithId = new Category( 4 | 100, 5 | '나만의 질문', 6 | new Date(), 7 | ); 8 | 9 | export const categoryListFixture = [ 10 | new Category(1, 'BE', new Date()), 11 | new Category(2, 'CS', new Date()), 12 | new Category(3, 'FE', new Date()), 13 | new Category(4, '나만의 질문', new Date()), 14 | ]; 15 | -------------------------------------------------------------------------------- /BE/src/category/repository/category.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Category } from '../entity/category'; 4 | import { Repository } from 'typeorm'; 5 | 6 | @Injectable() 7 | export class CategoryRepository { 8 | constructor( 9 | @InjectRepository(Category) private repository: Repository, 10 | ) {} 11 | 12 | async save(category: Category) { 13 | return await this.repository.save(category); 14 | } 15 | 16 | async findByCategoryId(categoryId: number) { 17 | return await this.repository.findOne({ 18 | where: { id: categoryId }, 19 | cache: true, 20 | }); 21 | } 22 | 23 | async findAll() { 24 | return await this.repository.find({ cache: true }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BE/src/category/service/category.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CategoryRepository } from '../repository/category.repository'; 3 | import { CategoryResponse } from '../dto/categoryResponse'; 4 | import { Transactional } from 'typeorm-transactional'; 5 | 6 | @Injectable() 7 | export class CategoryService { 8 | constructor(private categoryRepository: CategoryRepository) {} 9 | 10 | @Transactional() 11 | async findUsingCategories() { 12 | const categories = await this.categoryRepository.findAll(); 13 | 14 | return categories.map(CategoryResponse.from); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /BE/src/category/util/category.util.ts: -------------------------------------------------------------------------------- 1 | import { Category } from '../entity/category'; 2 | import { isEmpty } from 'class-validator'; 3 | import { CategoryNotFoundException } from '../exception/category.exception'; 4 | 5 | export const validateCategory = (category: Category) => { 6 | if (isEmpty(category)) throw new CategoryNotFoundException(); 7 | }; 8 | -------------------------------------------------------------------------------- /BE/src/config/cors.config.ts: -------------------------------------------------------------------------------- 1 | import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; 2 | import { CORS_HEADERS, CORS_ORIGIN } from './cors.secure'; 3 | 4 | export const CORS_CONFIG: CorsOptions = { 5 | origin: CORS_ORIGIN, 6 | credentials: true, 7 | exposedHeaders: CORS_HEADERS, 8 | methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTION', 'HEADER'], 9 | }; 10 | -------------------------------------------------------------------------------- /BE/src/config/idrive.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | export const IDRIVE_CONFIG = { 4 | region: 'e2', 5 | endpoint: process.env.IDRIVE_ENDPOINT, 6 | credentials: { 7 | accessKeyId: process.env.IDRIVE_ACCESS_KEY_ID, 8 | secretAccessKey: process.env.IDRIVE_SECRET_ACCESS_KEY, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /BE/src/config/swagger.config.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 3 | 4 | export function setupSwagger(app: INestApplication): void { 5 | const options = new DocumentBuilder() 6 | .setTitle('NDD') 7 | .setDescription("봉준호, BTS, 손흥민, NDD Let's go") 8 | .setVersion('1.0.0') 9 | .build(); 10 | 11 | const document = SwaggerModule.createDocument(app, options); 12 | SwaggerModule.setup('api-docs', app, document); 13 | } 14 | -------------------------------------------------------------------------------- /BE/src/constant/constant.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | export const BEARER_PREFIX: string = 'Bearer '; 4 | 5 | export const companies = [ 6 | '네이버', 7 | '카카오', 8 | '라인', 9 | '쿠팡', 10 | '우아한형제들', 11 | '당근', 12 | '비바리퍼블리카', 13 | 'Microsoft', 14 | 'Apple', 15 | 'Google', 16 | 'Amazon', 17 | 'Meta', 18 | ]; 19 | export const DEFAULT_THUMBNAIL = process.env.DEFAULT_THUMBNAIL; 20 | 21 | export const BAD_REQUEST = 400; 22 | export const UNAUTHORIZED = 401; 23 | export const FORBIDDEN = 403; 24 | export const NOT_FOUND = 404; 25 | export const GONE = 410; 26 | export const INTERNAL_SERVER_ERROR = 500; 27 | 28 | export const ACCESS_TOKEN_EXPIRES_IN = process.env.ACCESS_TOKEN_EXPIRES_IN; // 1 시간 29 | export const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN; // 7 일 30 | 31 | export const NO_CACHE_URL = ['/api/member', '/api/auth/reissue']; 32 | export const HOUR_IN_SECONDS = 60 * 60; 33 | export const WEEK_IN_SECONDS = 60 * 60 * 24 * 7; -------------------------------------------------------------------------------- /BE/src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module } from '@nestjs/common'; 2 | import { 3 | HealthCheckService, 4 | TerminusModule, 5 | TypeOrmHealthIndicator, 6 | } from '@nestjs/terminus'; 7 | import { HttpModule } from '@nestjs/axios'; 8 | import { HealthCheckExecutor } from '@nestjs/terminus/dist/health-check/health-check-executor.service'; 9 | import { LoggerService } from '../config/logger.config'; 10 | import { ScheduleModule } from '@nestjs/schedule'; 11 | import { HealthCheckScheduler } from './health.scheduler'; 12 | 13 | const logger = new LoggerService('healthcheck'); 14 | 15 | @Module({ 16 | imports: [TerminusModule, HttpModule, ScheduleModule.forRoot()], 17 | providers: [ 18 | HealthCheckScheduler, 19 | HealthCheckService, 20 | TypeOrmHealthIndicator, 21 | HealthCheckExecutor, 22 | { 23 | provide: 'TERMINUS_ERROR_LOGGER', 24 | useValue: Logger.error.bind(logger), 25 | }, 26 | { 27 | provide: 'TERMINUS_LOGGER', 28 | useValue: logger, 29 | }, 30 | ], 31 | controllers: [], 32 | }) 33 | export class HealthModule {} 34 | -------------------------------------------------------------------------------- /BE/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { setupSwagger } from './config/swagger.config'; 5 | import { CORS_CONFIG } from './config/cors.config'; 6 | import * as cookieParser from 'cookie-parser'; 7 | import { initializeTransactionalContext } from 'typeorm-transactional'; 8 | import { LoggerService } from './config/logger.config'; 9 | 10 | async function bootstrap() { 11 | initializeTransactionalContext(); 12 | const app = await NestFactory.create(AppModule, { 13 | abortOnError: true, 14 | }); 15 | const expressApp = app.getHttpAdapter().getInstance(); 16 | const logger = new LoggerService('traffic'); 17 | app.use(cookieParser()); 18 | app.useGlobalPipes(new ValidationPipe()); 19 | app.enableCors(CORS_CONFIG); 20 | setupSwagger(app); 21 | // 캐시 제어 미들웨어 등록 22 | expressApp.use((req, res, next) => { 23 | res.setHeader('Cache-Control', 'no-cache'); 24 | logger.info(req.url); 25 | next(); 26 | }); 27 | await app.listen(8080, '0.0.0.0'); 28 | } 29 | 30 | bootstrap(); 31 | -------------------------------------------------------------------------------- /BE/src/member/dto/memberNicknameResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from 'src/util/swagger.util'; 3 | 4 | export class MemberNicknameResponse { 5 | @ApiProperty(createPropertyOption('foobar', '회원의 닉네임', String)) 6 | nickname: string; 7 | 8 | constructor(nickname: string) { 9 | this.nickname = nickname; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /BE/src/member/dto/memberResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Member } from '../entity/member'; 3 | import { createPropertyOption } from 'src/util/swagger.util'; 4 | 5 | class MemberResponse { 6 | @ApiProperty(createPropertyOption(1, '회원의 ID', Number)) 7 | private id: number; 8 | 9 | @ApiProperty(createPropertyOption('foo@example.com', '회원의 이메일', String)) 10 | private email: string; 11 | 12 | @ApiProperty(createPropertyOption('foobar', '회원의 닉네임', String)) 13 | private nickname: string; 14 | 15 | @ApiProperty( 16 | createPropertyOption('https://example.com', '프로필 이미지의 주소', String), 17 | ) 18 | private profileImg: string; 19 | 20 | constructor(id: number, email: string, nickname: string, profileImg: string) { 21 | this.id = id; 22 | this.email = email; 23 | this.nickname = nickname; 24 | this.profileImg = profileImg; 25 | } 26 | 27 | static from(user: Member) { 28 | return new MemberResponse( 29 | user.id, 30 | user.email, 31 | user.nickname, 32 | user.profileImg, 33 | ); 34 | } 35 | } 36 | 37 | export { MemberResponse }; 38 | -------------------------------------------------------------------------------- /BE/src/member/entity/member.ts: -------------------------------------------------------------------------------- 1 | import { DefaultEntity } from 'src/app.entity'; 2 | import { Column, Entity } from 'typeorm'; 3 | 4 | @Entity({ name: 'Member' }) 5 | export class Member extends DefaultEntity { 6 | @Column() 7 | readonly email: string; 8 | 9 | @Column() 10 | readonly nickname: string; 11 | 12 | @Column({ 13 | length: 1000, 14 | }) 15 | readonly profileImg: string; 16 | 17 | constructor( 18 | id: number, 19 | email: string, 20 | nickname: string, 21 | profileImg: string, 22 | createdAt: Date, 23 | ) { 24 | super(id, createdAt); 25 | this.email = email; 26 | this.nickname = nickname; 27 | this.profileImg = profileImg; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BE/src/member/exception/member.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpNotFoundException } from 'src/util/exception.util'; 2 | 3 | export class MemberNotFoundException extends HttpNotFoundException { 4 | constructor() { 5 | super('회원을 찾을 수 없습니다.', 'M01'); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /BE/src/member/fixture/member.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Member } from '../entity/member'; 2 | import { OAuthRequest } from '../../auth/interface/auth.interface'; 3 | import { Request } from 'express'; 4 | 5 | export const memberFixture = new Member( 6 | 1, 7 | 'test@example.com', 8 | 'TestUser', 9 | 'https://example.com', 10 | new Date(), 11 | ); 12 | 13 | export const differentMemberFixture = new Member( 14 | 2, 15 | 'jang@jang.com', 16 | 'jang', 17 | 'https://jangsarchive.tistory.com', 18 | new Date(), 19 | ); 20 | 21 | export const otherMemberFixture = new Member( 22 | 999, 23 | 'other@example.com', 24 | 'other', 25 | 'https://other.com', 26 | new Date(), 27 | ); 28 | 29 | export const memberFixturesOAuthRequest = { 30 | email: 'test@example.com', 31 | name: 'TestUser', 32 | img: 'https://example.com', 33 | } as OAuthRequest; 34 | 35 | export const oauthRequestFixture = { 36 | email: 'fixture@example.com', 37 | name: 'fixture', 38 | img: 'https://test.com', 39 | } as OAuthRequest; 40 | 41 | export const mockReqWithMemberFixture = { 42 | user: memberFixture, 43 | } as unknown as Request; 44 | -------------------------------------------------------------------------------- /BE/src/member/member.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Member } from './entity/member'; 4 | import { MemberRepository } from './repository/member.repository'; 5 | import { MemberController } from './controller/member.controller'; 6 | import { TokenModule } from 'src/token/token.module'; 7 | import { MemberService } from './service/member.service'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Member]), TokenModule], 11 | providers: [MemberRepository, MemberService], 12 | exports: [MemberRepository], 13 | controllers: [MemberController], 14 | }) 15 | export class MemberModule {} 16 | -------------------------------------------------------------------------------- /BE/src/member/repository/member.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { Member } from '../entity/member'; 5 | import { HOUR_IN_SECONDS } from '../../constant/constant'; 6 | 7 | @Injectable() 8 | export class MemberRepository { 9 | constructor( 10 | @InjectRepository(Member) private memberRepository: Repository, 11 | ) {} 12 | 13 | async save(member: Member) { 14 | return await this.memberRepository.save(member); 15 | } 16 | 17 | async findById(id: number) { 18 | return await this.memberRepository.findOne({ 19 | where: { id }, 20 | cache: HOUR_IN_SECONDS * 1000, // milliSecond로 21 | }); 22 | } 23 | 24 | async findByEmail(email: string) { 25 | return await this.memberRepository.findOne({ 26 | where: { email }, 27 | cache: HOUR_IN_SECONDS * 1000, 28 | }); 29 | } 30 | 31 | async query(query: string) { 32 | return await this.memberRepository.query(query); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /BE/src/question/dto/copyQuestionRequest.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from '../../util/swagger.util'; 3 | import { IsNotEmpty, IsNumber } from 'class-validator'; 4 | 5 | export class CopyQuestionRequest { 6 | @ApiProperty(createPropertyOption('1', '문제집 id', Number)) 7 | @IsNotEmpty() 8 | @IsNumber() 9 | workbookId: number; 10 | 11 | @ApiProperty( 12 | createPropertyOption([1, 2, 3, 4, 5], '복사할 질문들의 id', Number), 13 | ) 14 | @IsNotEmpty() 15 | questionIds: number[]; 16 | 17 | constructor(workbookId: number, questionIds: number[]) { 18 | this.workbookId = workbookId; 19 | this.questionIds = questionIds; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BE/src/question/dto/createQuestionRequest.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { createPropertyOption } from '../../util/swagger.util'; 4 | 5 | export class CreateQuestionRequest { 6 | @ApiProperty(createPropertyOption('1', '문제집 id', Number)) 7 | @IsNotEmpty() 8 | @IsNumber() 9 | workbookId: number; 10 | 11 | @ApiProperty(createPropertyOption('이장희는 누구일까요', '질문 내용', String)) 12 | @IsNotEmpty() 13 | @IsString() 14 | content: string; 15 | 16 | constructor(workbookId: number, content: string) { 17 | this.workbookId = workbookId; 18 | this.content = content; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /BE/src/question/exception/question.exception.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpForbiddenException, 3 | HttpNotFoundException, 4 | } from '../../util/exception.util'; 5 | 6 | class QuestionNotFoundException extends HttpNotFoundException { 7 | constructor() { 8 | super('해당 질문을 찾을 수 없습니다.', 'Q01'); 9 | } 10 | } 11 | 12 | class QuestionForbiddenException extends HttpForbiddenException { 13 | constructor() { 14 | super('질문에 대한 권한이 없습니다.', 'Q02'); 15 | } 16 | } 17 | 18 | export { QuestionNotFoundException, QuestionForbiddenException }; 19 | -------------------------------------------------------------------------------- /BE/src/question/fixture/question.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Question } from '../entity/question'; 2 | import { CreateQuestionRequest } from '../dto/createQuestionRequest'; 3 | import { workbookFixtureWithId } from '../../workbook/fixture/workbook.fixture'; 4 | import { CopyQuestionRequest } from '../dto/copyQuestionRequest'; 5 | 6 | export const questionFixture = new Question( 7 | 1, 8 | 'tester', 9 | workbookFixtureWithId, 10 | null, 11 | new Date(), 12 | null, 13 | ); 14 | 15 | export const createQuestionRequestFixture = new CreateQuestionRequest( 16 | workbookFixtureWithId.id, 17 | 'tester', 18 | ); 19 | 20 | export const copyQuestionRequestFixture = new CopyQuestionRequest( 21 | workbookFixtureWithId.id, 22 | [1, 2, 3], 23 | ); 24 | -------------------------------------------------------------------------------- /BE/src/question/question.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Question } from './entity/question'; 4 | import { TokenModule } from 'src/token/token.module'; 5 | import { QuestionService } from './service/question.service'; 6 | import { QuestionController } from './controller/question.controller'; 7 | import { QuestionRepository } from './repository/question.repository'; 8 | import { Member } from '../member/entity/member'; 9 | import { Answer } from '../answer/entity/answer'; 10 | import { Workbook } from '../workbook/entity/workbook'; 11 | import { WorkbookModule } from '../workbook/workbook.module'; 12 | import { WorkbookRepository } from '../workbook/repository/workbook.repository'; 13 | 14 | @Module({ 15 | imports: [ 16 | TypeOrmModule.forFeature([Question, Workbook, Member, Answer]), 17 | TokenModule, 18 | WorkbookModule, 19 | ], 20 | providers: [QuestionService, QuestionRepository, WorkbookRepository], 21 | controllers: [QuestionController], 22 | }) 23 | export class QuestionModule {} 24 | -------------------------------------------------------------------------------- /BE/src/question/util/question.util.ts: -------------------------------------------------------------------------------- 1 | import { Question } from '../entity/question'; 2 | import { isEmpty } from 'class-validator'; 3 | import { QuestionNotFoundException } from '../exception/question.exception'; 4 | 5 | export const validateQuestion = (question: Question) => { 6 | if (isEmpty(question)) { 7 | throw new QuestionNotFoundException(); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /BE/src/token/controller/token.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Res } from '@nestjs/common'; 2 | import { TokenService } from '../service/token.service'; 3 | import { Response } from 'express'; 4 | import { BEARER_PREFIX } from 'src/constant/constant'; 5 | import { ApiExcludeEndpoint } from '@nestjs/swagger'; 6 | 7 | @Controller('/api/token') 8 | export class TokenController { 9 | constructor(private tokenService: TokenService) {} 10 | 11 | @Get() 12 | @ApiExcludeEndpoint() 13 | async getDevToken(@Res() res: Response) { 14 | const devToken = await this.tokenService.getDevToken(); 15 | res.setHeader('Authorization', `${BEARER_PREFIX} ${devToken}`).send(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /BE/src/token/exception/token.exception.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpGoneException, 3 | HttpInternalServerError, 4 | HttpUnauthorizedException, 5 | } from '../../util/exception.util'; 6 | 7 | class InvalidTokenException extends HttpUnauthorizedException { 8 | constructor() { 9 | super('유효하지 않은 토큰입니다.', 'T01'); 10 | } 11 | } 12 | 13 | class TokenExpiredException extends HttpGoneException { 14 | constructor() { 15 | super('토큰이 만료되었습니다', 'T02'); 16 | } 17 | } 18 | 19 | class NeedToLoginException extends HttpUnauthorizedException { 20 | constructor() { 21 | super('다시 로그인해주세요.', 'T03'); 22 | } 23 | } 24 | 25 | class ManipulatedTokenNotFiltered extends HttpInternalServerError { 26 | constructor() { 27 | super('', 'SERVER'); 28 | } 29 | } 30 | 31 | export { 32 | InvalidTokenException, 33 | TokenExpiredException, 34 | ManipulatedTokenNotFiltered, 35 | NeedToLoginException, 36 | }; 37 | -------------------------------------------------------------------------------- /BE/src/token/guard/token.hard.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | import { TokenService } from '../service/token.service'; 4 | import { getTokenValue } from 'src/util/token.util'; 5 | import { isEmpty } from 'class-validator'; 6 | import { InvalidTokenException } from '../exception/token.exception'; 7 | 8 | @Injectable() 9 | export class TokenHardGuard extends AuthGuard('jwt') { 10 | constructor(private tokenService: TokenService) { 11 | super(); 12 | } 13 | 14 | async canActivate(context: ExecutionContext) { 15 | const request = context.switchToHttp().getRequest(); 16 | const token = getTokenValue(request); 17 | 18 | if (isEmpty(token)) { 19 | throw new InvalidTokenException(); 20 | } 21 | 22 | try { 23 | request.user = await this.validateToken(token); 24 | return true; 25 | } catch (error) { 26 | throw error; 27 | } 28 | } 29 | 30 | private async validateToken(token: string) { 31 | return await this.tokenService.findMemberByToken(token, true); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /BE/src/token/guard/token.soft.guard.ts: -------------------------------------------------------------------------------- 1 | // auth.guard.ts 2 | 3 | import { ExecutionContext, Injectable } from '@nestjs/common'; 4 | import { AuthGuard } from '@nestjs/passport'; 5 | import { TokenService } from '../service/token.service'; 6 | 7 | @Injectable() 8 | export class TokenSoftGuard extends AuthGuard('jwt') { 9 | constructor(private tokenService: TokenService) { 10 | super(); 11 | } 12 | 13 | async canActivate(context: ExecutionContext) { 14 | const request = context.switchToHttp().getRequest(); 15 | const token = request.cookies['accessToken']; 16 | 17 | // validate 메소드 내에서 토큰을 사용 18 | request.user = await this.validateToken(token); 19 | 20 | return true; 21 | } 22 | 23 | private async validateToken(token: string) { 24 | try { 25 | return this.tokenService.findMemberByToken(token.replace('Bearer ', '')); 26 | } catch (error) { 27 | return null; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /BE/src/token/interface/token.interface.ts: -------------------------------------------------------------------------------- 1 | export interface TokenPayload { 2 | id: number; 3 | } 4 | -------------------------------------------------------------------------------- /BE/src/util/decorator.util.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const OPTIONAL_GUARD = 'OptionalGuard'; 4 | export const OptionalGuard = () => { 5 | return SetMetadata(OPTIONAL_GUARD, true); 6 | }; 7 | -------------------------------------------------------------------------------- /BE/src/util/idrive.util.ts: -------------------------------------------------------------------------------- 1 | import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; 2 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; 3 | import { IDRIVE_CONFIG } from 'src/config/idrive.config'; 4 | 5 | let s3Client: S3Client; // 싱글톤으로 유지할 S3 Client 인스턴스 6 | 7 | const getIdriveS3Client = (): S3Client => { 8 | if (!s3Client) { 9 | s3Client = new S3Client(IDRIVE_CONFIG); 10 | } 11 | return s3Client; 12 | }; 13 | 14 | const getPutCommandObject = (key: string): PutObjectCommand => 15 | new PutObjectCommand({ Bucket: 'videos', Key: key }); 16 | 17 | export async function getSignedUrlWithKey(key: string) { 18 | const s3 = getIdriveS3Client(); 19 | const command = getPutCommandObject(key); 20 | const expiresIn = 10; 21 | 22 | return await getSignedUrl(s3, command, { expiresIn }); 23 | } 24 | -------------------------------------------------------------------------------- /BE/src/util/swagger.util.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptions } from '@nestjs/swagger/dist/decorators/api-property.decorator'; 2 | import { ApiHeaderOptions, ApiResponseOptions } from '@nestjs/swagger'; 3 | 4 | export const createPropertyOption = ( 5 | example: unknown, 6 | description: string, 7 | type: unknown, 8 | ): ApiPropertyOptions => { 9 | return { 10 | example, 11 | description, 12 | type, 13 | } as ApiPropertyOptions; 14 | }; 15 | 16 | export const createApiResponseOption = ( 17 | status: number, 18 | description: string, 19 | type: unknown, 20 | ) => { 21 | return { 22 | status, 23 | description, 24 | type, 25 | } as ApiResponseOptions; 26 | }; 27 | 28 | export const createApiResponseOptionWithHeaders = ( 29 | status: number, 30 | description: string, 31 | headers: unknown, 32 | ) => { 33 | return { 34 | status, 35 | description, 36 | headers, 37 | } as ApiResponseOptions; 38 | }; 39 | 40 | export const createApiHeaderOption = ( 41 | name: string, 42 | description: string, 43 | required: boolean, 44 | ) => { 45 | return { 46 | name, 47 | description, 48 | required, 49 | } as ApiHeaderOptions; 50 | }; 51 | -------------------------------------------------------------------------------- /BE/src/util/token.util.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty } from 'class-validator'; 2 | import { Request } from 'express'; 3 | import { Member } from 'src/member/entity/member'; 4 | import { ManipulatedTokenNotFiltered } from 'src/token/exception/token.exception'; 5 | 6 | export const getTokenValue = (request: Request) => { 7 | if (request.cookies && request.cookies['accessToken']) { 8 | return request.cookies['accessToken'].split(' ').pop(); 9 | } 10 | 11 | return null; 12 | }; 13 | 14 | export const validateManipulatedToken = (member: Member | undefined) => { 15 | if (isEmpty(member)) throw new ManipulatedTokenNotFiltered(); 16 | }; 17 | -------------------------------------------------------------------------------- /BE/src/util/util.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/BE/src/util/util.ts -------------------------------------------------------------------------------- /BE/src/video/dto/preSignedUrlResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from 'src/util/swagger.util'; 3 | 4 | export class PreSignedUrlResponse { 5 | @ApiProperty( 6 | createPropertyOption( 7 | 'https://example.com', 8 | '비디오 업로드를 위한 Pre-Signed URL', 9 | String, 10 | ), 11 | ) 12 | readonly preSignedUrl: string; 13 | 14 | @ApiProperty(createPropertyOption('example.webm', '저장할 파일 이름', String)) 15 | readonly key: string; 16 | 17 | constructor(preSignedUrl: string, key: string) { 18 | this.preSignedUrl = preSignedUrl; 19 | this.key = key; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BE/src/video/dto/uploadVideoRequest.ts: -------------------------------------------------------------------------------- 1 | // import { ApiProperty } from '@nestjs/swagger'; 2 | // import { createPropertyOption } from '../../util/swagger.util'; 3 | // import { IsNotEmpty, IsNumberString, IsString, Matches } from 'class-validator'; 4 | 5 | // export class UploadVideoRequest { 6 | // @ApiProperty(createPropertyOption(1, '문제 ID', Number)) 7 | // @IsNumberString() 8 | // @IsNotEmpty() 9 | // questionId: string; 10 | 11 | // @ApiProperty(createPropertyOption('03:29', '비디오 길이', String)) 12 | // @IsString() 13 | // @IsNotEmpty() 14 | // @Matches(/^\d{2}:\d{2}$/, { 15 | // message: `유효하지 않은 비디오 길이 형태입니다. "mm:ss" 형태로 요청해주세요.`, 16 | // }) 17 | // videoLength: string; 18 | 19 | // constructor(questionId: number, videoLength: string) { 20 | // this.questionId = String(questionId); 21 | // this.videoLength = videoLength; 22 | // } 23 | // } 24 | -------------------------------------------------------------------------------- /BE/src/video/dto/videoHashResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from 'src/util/swagger.util'; 3 | 4 | export class VideoHashResponse { 5 | @ApiProperty( 6 | createPropertyOption( 7 | '65f031b26799cc74755bdd3ef4a304eaec197e402582ef4a834edb58e71261a0', 8 | '비디오의 URL 해시값', 9 | String, 10 | ), 11 | ) 12 | @ApiProperty({ nullable: true }) 13 | readonly hash: string; 14 | constructor(hash: string) { 15 | this.hash = hash; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /BE/src/video/video.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { VideoController } from './controller/video.controller'; 3 | import { VideoService } from './service/video.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Video } from './entity/video'; 6 | import { VideoRepository } from './repository/video.repository'; 7 | import { QuestionRepository } from 'src/question/repository/question.repository'; 8 | import { Question } from 'src/question/entity/question'; 9 | import { Member } from 'src/member/entity/member'; 10 | import { MemberRepository } from 'src/member/repository/member.repository'; 11 | import { TokenModule } from 'src/token/token.module'; 12 | 13 | @Module({ 14 | imports: [TypeOrmModule.forFeature([Video, Question, Member]), TokenModule], 15 | controllers: [VideoController], 16 | providers: [ 17 | VideoService, 18 | VideoRepository, 19 | QuestionRepository, 20 | MemberRepository, 21 | ], 22 | }) 23 | export class VideoModule {} 24 | -------------------------------------------------------------------------------- /BE/src/workbook/dto/createWorkbookRequest.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from '../../util/swagger.util'; 3 | import { IsNotEmpty, IsString } from '@nestjs/class-validator'; 4 | import { IsBoolean, IsNumber } from 'class-validator'; 5 | 6 | export class CreateWorkbookRequest { 7 | @ApiProperty(createPropertyOption('장희문제집', '문제집 이름', String)) 8 | @IsString() 9 | @IsNotEmpty() 10 | title: string; 11 | 12 | @ApiProperty( 13 | createPropertyOption('나만볼꺼다요 메롱', '문제집에 대한 설명', String), 14 | ) 15 | @IsString() 16 | content: string; 17 | 18 | @ApiProperty(createPropertyOption(1, '카테고리 id', Number)) 19 | @IsNumber() 20 | @IsNotEmpty() 21 | categoryId: number; 22 | 23 | @ApiProperty(createPropertyOption(true, '문제집 공개여부', Boolean)) 24 | @IsBoolean() 25 | isPublic: boolean; 26 | 27 | constructor( 28 | title: string, 29 | content: string, 30 | categoryId: number, 31 | isPublic: boolean, 32 | ) { 33 | this.title = title; 34 | this.content = content; 35 | this.categoryId = categoryId; 36 | this.isPublic = isPublic; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BE/src/workbook/dto/workbookIdResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from '../../util/swagger.util'; 3 | import { Workbook } from '../entity/workbook'; 4 | 5 | export class WorkbookIdResponse { 6 | @ApiProperty(createPropertyOption(1, '답변 ID', Number)) 7 | workbookId: number; 8 | 9 | constructor(workbookId: number) { 10 | this.workbookId = workbookId; 11 | } 12 | 13 | static of(workbook: Workbook) { 14 | return new WorkbookIdResponse(workbook.id); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /BE/src/workbook/dto/workbookTitleResponse.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { createPropertyOption } from '../../util/swagger.util'; 3 | import { Workbook } from '../entity/workbook'; 4 | 5 | export class WorkbookTitleResponse { 6 | @ApiProperty(createPropertyOption(1, '문제집 ID', Number)) 7 | workbookId: number; 8 | @ApiProperty( 9 | createPropertyOption('이장희의 면접 문제집', '문제집 제목', String), 10 | ) 11 | title: string; 12 | 13 | constructor(workbookId: number, title: string) { 14 | this.workbookId = workbookId; 15 | this.title = title; 16 | } 17 | 18 | static of(workbook: Workbook) { 19 | return new WorkbookTitleResponse(workbook.id, workbook.title); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BE/src/workbook/exception/workbook.exception.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpBadRequestException, 3 | HttpForbiddenException, 4 | HttpNotFoundException, 5 | } from '../../util/exception.util'; 6 | 7 | class WorkbookNotFoundException extends HttpNotFoundException { 8 | constructor() { 9 | super('문제집을 찾을 수 없습니다.', 'W01'); 10 | } 11 | } 12 | 13 | class WorkbookForbiddenException extends HttpForbiddenException { 14 | constructor() { 15 | super('문제집에 대한 권한이 없습니다.', 'W02'); 16 | } 17 | } 18 | 19 | class NeedToFindByWorkbookIdException extends HttpBadRequestException { 20 | constructor() { 21 | super('문제집 id를 입력해주세요.', 'W03'); 22 | } 23 | } 24 | 25 | export { 26 | WorkbookNotFoundException, 27 | WorkbookForbiddenException, 28 | NeedToFindByWorkbookIdException, 29 | }; 30 | -------------------------------------------------------------------------------- /BE/src/workbook/util/workbook.util.ts: -------------------------------------------------------------------------------- 1 | import { Workbook } from '../entity/workbook'; 2 | import { isEmpty } from 'class-validator'; 3 | import { 4 | WorkbookForbiddenException, 5 | WorkbookNotFoundException, 6 | } from '../exception/workbook.exception'; 7 | import { Member } from '../../member/entity/member'; 8 | 9 | export const validateWorkbook = (workbook: Workbook) => { 10 | if (isEmpty(workbook)) throw new WorkbookNotFoundException(); 11 | }; 12 | 13 | export const validateWorkbookOwner = (workbook: Workbook, member: Member) => { 14 | if (!workbook.isOwnedBy(member)) { 15 | throw new WorkbookForbiddenException(); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /BE/src/workbook/workbook.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Workbook } from './entity/workbook'; 4 | import { WorkbookRepository } from './repository/workbook.repository'; 5 | import { WorkbookService } from './service/workbook.service'; 6 | import { WorkbookController } from './controller/workbook.controller'; 7 | import { Category } from '../category/entity/category'; 8 | import { CategoryRepository } from '../category/repository/category.repository'; 9 | import { CategoryModule } from '../category/category.module'; 10 | import { TokenSoftGuard } from '../token/guard/token.soft.guard'; 11 | import { TokenModule } from '../token/token.module'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([Workbook, Category]), 16 | CategoryModule, 17 | TokenModule, 18 | ], 19 | providers: [ 20 | WorkbookRepository, 21 | WorkbookService, 22 | CategoryRepository, 23 | TokenSoftGuard, 24 | ], 25 | controllers: [WorkbookController], 26 | }) 27 | export class WorkbookModule {} 28 | -------------------------------------------------------------------------------- /BE/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 | -------------------------------------------------------------------------------- /BE/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /BE/test/test.util.ts: -------------------------------------------------------------------------------- 1 | import { ModuleMetadata } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | 4 | export const createTestModuleFixture = async ( 5 | imports: unknown, 6 | controllers: unknown, 7 | providers: unknown, 8 | ) => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: imports, 11 | controllers: controllers, 12 | providers: providers, 13 | } as ModuleMetadata).compile(); 14 | 15 | return moduleFixture; 16 | }; 17 | -------------------------------------------------------------------------------- /BE/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /BE/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "baseUrl": "", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FE/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | [ 5 | "@babel/preset-react", 6 | { "runtime": "automatic", "importSource": "@emotion/react" } 7 | ], 8 | "@babel/preset-typescript" 9 | ], 10 | "plugins": ["@emotion/babel-plugin"], 11 | "targets": "> 0.5%, not dead" 12 | } 13 | -------------------------------------------------------------------------------- /FE/.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd FE 4 | npx lint-staged -------------------------------------------------------------------------------- /FE/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env.* 4 | *.log 5 | src/dev/ 6 | -------------------------------------------------------------------------------- /FE/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "endOfLine": "auto", 6 | "arrowParens": "always", 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /FE/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/FE/README.md -------------------------------------------------------------------------------- /FE/public/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Cross-Origin-Opener-Policy: same-origin 3 | Cross-Origin-Embedder-Policy: require-corp 4 | 5 | -------------------------------------------------------------------------------- /FE/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/FE/public/favicon.ico -------------------------------------------------------------------------------- /FE/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 곰터뷰 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /FE/src/UnknownErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import { ErrorBoundary } from 'react-error-boundary'; 3 | import * as Sentry from '@sentry/react'; 4 | import SomethingWrongErrorPage from '@page/errorPage/SomethingWrong'; 5 | 6 | const UnknownErrorBoundary: React.FC = ({ children }) => { 7 | return ( 8 | { 11 | Sentry.withScope((scope) => { 12 | scope.setLevel('fatal'); 13 | scope.setTags({ 14 | status: 'unknown', 15 | }); 16 | scope.setTag('environment', process.env.NODE_ENV); 17 | scope.setContext('trace', { 18 | message: error.message, 19 | stack: error.stack, 20 | name: error.name, 21 | }); 22 | Sentry.captureMessage(error.name); 23 | }); 24 | }} 25 | > 26 | {children} 27 | 28 | ); 29 | }; 30 | 31 | export default UnknownErrorBoundary; 32 | -------------------------------------------------------------------------------- /FE/src/apis/answer.ts: -------------------------------------------------------------------------------- 1 | import { AnswerItemResDto } from '@/types/answer'; 2 | import { API } from '@/constants/api'; 3 | import getAPIResponseData from '@/utils/getAPIResponseData'; 4 | 5 | export const getQuestionAnswer = async (questionId: number) => { 6 | return await getAPIResponseData({ 7 | method: 'get', 8 | url: API.ANSWER_ID(questionId), 9 | }); 10 | }; 11 | 12 | export const postAnswer = async ({ 13 | questionId, 14 | content, 15 | }: { 16 | questionId: number; 17 | content: string; 18 | }) => { 19 | return await getAPIResponseData({ 20 | method: 'post', 21 | url: API.ANSWER, 22 | data: { questionId, content }, 23 | }); 24 | }; 25 | 26 | export const postDefaultAnswer = async ({ 27 | questionId, 28 | answerId, 29 | }: { 30 | questionId: number; 31 | answerId: number; 32 | }) => { 33 | return await getAPIResponseData({ 34 | method: 'post', 35 | url: API.ANSWER_DEFAULT, 36 | data: { questionId, answerId }, 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /FE/src/apis/category.ts: -------------------------------------------------------------------------------- 1 | import { API } from '@/constants/api'; 2 | import { CategoryListResDto } from '@/types/category'; 3 | import getAPIResponseData from '@/utils/getAPIResponseData'; 4 | 5 | export const getCategory = async () => { 6 | return await getAPIResponseData({ 7 | method: 'get', 8 | url: API.CATEGORY, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /FE/src/apis/idrive.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | type IdriveUploadParams = { 4 | url?: string; 5 | blob: Blob; 6 | }; 7 | 8 | export const putVideoToIdrive = async ({ 9 | url, 10 | blob, 11 | }: IdriveUploadParams): Promise => { 12 | if (!url) { 13 | throw new Error('URL is required for uploading the video'); 14 | } 15 | 16 | try { 17 | await axios.put(url, blob, { 18 | headers: { 'Content-Type': 'video/webm; codecs=vp8' }, 19 | }); 20 | } catch (error) { 21 | console.error('Error occurred while uploading video:', error); 22 | throw error; 23 | } 24 | return; 25 | }; 26 | -------------------------------------------------------------------------------- /FE/src/apis/member.ts: -------------------------------------------------------------------------------- 1 | import getAPIResponseData from '@/utils/getAPIResponseData'; 2 | import { MemberItemResDto, MemberNameResDto } from '@/types/member'; 3 | import { API } from '@constants/api'; 4 | 5 | export const getMemberInfo = async () => { 6 | return await getAPIResponseData({ 7 | method: 'get', 8 | url: API.MEMBER(), 9 | }); 10 | }; 11 | 12 | export const getMemberName = async () => { 13 | return await getAPIResponseData({ 14 | method: 'get', 15 | url: API.MEMBER_NAME(), 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /FE/src/assets/images/blank-bear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/FE/src/assets/images/blank-bear.png -------------------------------------------------------------------------------- /FE/src/assets/images/error-bear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/FE/src/assets/images/error-bear.png -------------------------------------------------------------------------------- /FE/src/assets/images/landing-bear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/FE/src/assets/images/landing-bear.png -------------------------------------------------------------------------------- /FE/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm2023/web14-gomterview/014abdfcc30cfad2218c2f29b7584fd7e2459288/FE/src/assets/images/logo.png -------------------------------------------------------------------------------- /FE/src/atoms/interviewSetting.ts: -------------------------------------------------------------------------------- 1 | import { Question } from '@/types/question'; 2 | import { WorkbookEntity } from '@/types/workbook'; 3 | import { atom } from 'recoil'; 4 | 5 | export type RecordMethod = 'local' | 'idrive' | 'none' | undefined; 6 | 7 | export type SelectedQuestion = Question & Pick; 8 | 9 | export const questionSetting = atom<{ 10 | isSuccess: boolean; 11 | selectedData: SelectedQuestion[]; 12 | from?: 'workbook' | undefined; 13 | }>({ 14 | key: 'questionSetting', 15 | default: { 16 | isSuccess: false, 17 | selectedData: [], 18 | from: undefined, 19 | }, 20 | }); 21 | 22 | export const videoSetting = atom<{ 23 | isSuccess: boolean; 24 | }>({ 25 | key: 'videoSetting', 26 | default: { 27 | isSuccess: false, 28 | }, 29 | }); 30 | 31 | export const recordSetting = atom<{ 32 | isSuccess: boolean; 33 | method: RecordMethod; 34 | }>({ 35 | key: 'recordSetting', 36 | default: { 37 | isSuccess: false, 38 | method: undefined, 39 | }, 40 | }); 41 | 42 | export const serviceTerms = atom<{ 43 | isSuccess: boolean; 44 | }>({ 45 | key: 'serviceTerms', 46 | default: { 47 | isSuccess: false, 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /FE/src/atoms/media.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | const mediaConnectStatus = [ 4 | 'start', //스트림이 생성된 상태 5 | 'connect', //스트림이 비디오에 연결된 상태 6 | 'pending', //초기상태 7 | 'fail', //위 과정중 어디선가 오류가 발생했을 때 8 | ] as const; 9 | export type ConnectStatus = (typeof mediaConnectStatus)[number]; 10 | 11 | export const mediaState = atom({ 12 | key: 'mediaState', 13 | default: null, 14 | }); 15 | 16 | export const connectStatusState = atom({ 17 | key: 'connectStatusState', 18 | default: 'pending', 19 | }); 20 | 21 | export const selectedMimeTypeState = atom({ 22 | key: 'selectedMimeTypeState', 23 | default: '', 24 | }); 25 | -------------------------------------------------------------------------------- /FE/src/atoms/modal.ts: -------------------------------------------------------------------------------- 1 | import { Question } from '@/types/question'; 2 | import { atom } from 'recoil'; 3 | 4 | export const QuestionAnswerSelectionModal = atom<{ 5 | isOpen: boolean; 6 | workbookId?: number; 7 | question?: Question; 8 | }>({ 9 | key: 'questionAnswerSelectionModal', 10 | default: { 11 | isOpen: false, 12 | }, 13 | }); 14 | 15 | export const modalState = atom<{ id: string; element: React.FC }[]>({ 16 | key: 'modalState', 17 | default: [], 18 | }); 19 | -------------------------------------------------------------------------------- /FE/src/components/WorkbookDetailPage/WorkbookDetailPageLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Header, Layout } from '@components/layout'; 2 | import { css } from '@emotion/react'; 3 | import React, { PropsWithChildren } from 'react'; 4 | 5 | const WorkbookDetailPageLayout: React.FC = ({ 6 | children, 7 | }) => { 8 | return ( 9 |
10 |
11 | 17 | {children} 18 | 19 |
20 | ); 21 | }; 22 | 23 | export default WorkbookDetailPageLayout; 24 | -------------------------------------------------------------------------------- /FE/src/components/WorkbookDetailPage/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AddWorkbookListModal } from './AddWorkbookListModal'; 2 | export { default as WorkbookDetailPageLayout } from './WorkbookDetailPageLayout'; 3 | export { default as StartWithSelectedQuestionModal } from './StartWithSelectedQuestionModal'; 4 | -------------------------------------------------------------------------------- /FE/src/components/common/Loading/LoadingBounce.tsx: -------------------------------------------------------------------------------- 1 | import { css, keyframes } from '@emotion/react'; 2 | import logo from '@assets/images/logo.png'; 3 | 4 | const LoadingBounce = () => { 5 | const bounce = keyframes` 6 | 0%, 100% { 7 | transform: translateY(0); 8 | } 9 | 50% { 10 | transform: translateY(-4rem); /* 공이 최대 높이까지 튀어오르는 지점 */ 11 | } 12 | `; 13 | return ( 14 | {'곰돌이 24 | ); 25 | }; 26 | 27 | export default LoadingBounce; 28 | -------------------------------------------------------------------------------- /FE/src/components/common/ProgressStepBar/ProgressStepBar.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { PropsWithChildren } from 'react'; 3 | import ProgressStepBarItem from './ProgressStepBarItem'; 4 | 5 | const ProgressStepBar = ({ children }: PropsWithChildren) => { 6 | return ( 7 |
13 | {children} 14 |
15 | ); 16 | }; 17 | 18 | ProgressStepBar.Item = ProgressStepBarItem; 19 | 20 | export default ProgressStepBar; 21 | -------------------------------------------------------------------------------- /FE/src/components/common/ProgressStepBar/ProgressStepBarItem.tsx: -------------------------------------------------------------------------------- 1 | import Typography from '@/components/foundation/Typography/Typography'; 2 | import { theme } from '@/styles/theme'; 3 | import { HTMLElementTypes } from '@/types/utils'; 4 | import { css } from '@emotion/react'; 5 | 6 | type ProgressStepBarItemProps = { 7 | name?: string; 8 | isCompleted?: boolean; 9 | } & HTMLElementTypes; 10 | 11 | const ProgressStepBarItem: React.FC = ({ 12 | name, 13 | isCompleted, 14 | }) => { 15 | return ( 16 |
22 |
32 | {name} 33 |
34 | ); 35 | }; 36 | 37 | export default ProgressStepBarItem; 38 | -------------------------------------------------------------------------------- /FE/src/components/common/QuestionSelectionBox/QuestionSelectionBoxAccordion.tsx: -------------------------------------------------------------------------------- 1 | import { Question } from '@/types/question'; 2 | import QuestionAccordion from '@common/QuestionAccordion/QuestionAccordion'; 3 | import useSelectQuestions from '@hooks/atoms/useSelectQuestions'; 4 | 5 | type QuestionSelectionBoxAccordionProps = { 6 | question: Question; 7 | workbookId: number; 8 | isSelectable?: boolean; 9 | }; 10 | 11 | const QuestionSelectionBoxAccordion: React.FC< 12 | QuestionSelectionBoxAccordionProps 13 | > = ({ question, workbookId, isSelectable = true }) => { 14 | const { isSelected, toggleSelected } = useSelectQuestions({ 15 | question: question, 16 | workbookId: workbookId, 17 | }); 18 | return ( 19 | 25 | ); 26 | }; 27 | 28 | export default QuestionSelectionBoxAccordion; 29 | -------------------------------------------------------------------------------- /FE/src/components/common/QuestionSelectionBox/QuestionTabList.tsx: -------------------------------------------------------------------------------- 1 | import { SelectionBox, Tabs, Typography } from '@foundation/index'; 2 | import { WorkbookTitleListResDto } from '@/types/workbook'; 3 | import { css } from '@emotion/react'; 4 | 5 | type QuestionTabListProps = { 6 | workbookListData: WorkbookTitleListResDto; 7 | }; 8 | const QuestionTabList: React.FC = ({ 9 | workbookListData, 10 | }) => { 11 | return ( 12 |
19 | {workbookListData.map((workbook, index) => ( 20 | 21 | 25 | 26 | {workbook.title} 27 | 28 | 29 | 30 | ))} 31 |
32 | ); 33 | }; 34 | 35 | export default QuestionTabList; 36 | -------------------------------------------------------------------------------- /FE/src/components/common/QuestionSelectionBox/WorkbookAddButton.tsx: -------------------------------------------------------------------------------- 1 | import { theme } from '@styles/theme'; 2 | import { Button, Icon, Typography } from '@foundation/index'; 3 | import WorkbookGeneratorModal from '@common/QuestionSelectionBox/WorkbookGeneratorModal/WorkbookGeneratorModal'; 4 | import useModal from '@hooks/useModal'; 5 | import { css } from '@emotion/react'; 6 | 7 | const WorkbookAddButton: React.FC = () => { 8 | const { openModal, closeModal } = useModal(() => { 9 | return ; 10 | }); 11 | 12 | return ( 13 | 31 | ); 32 | }; 33 | 34 | export default WorkbookAddButton; 35 | -------------------------------------------------------------------------------- /FE/src/components/common/QuestionSelectionBox/WorkbookGeneratorModal/LabelBox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { css } from '@emotion/react'; 3 | import { Typography } from '@foundation/index'; 4 | import { theme } from '@styles/theme'; 5 | 6 | type LabelBoxProps = { 7 | children: React.ReactNode; 8 | labelName: string; 9 | labelColor?: string; 10 | }; 11 | 12 | const LabelBox: React.FC = ({ 13 | children, 14 | labelName, 15 | labelColor, 16 | }) => { 17 | return ( 18 |
25 | 33 | {labelName} 34 | 35 | {children} 36 |
37 | ); 38 | }; 39 | 40 | export default LabelBox; 41 | -------------------------------------------------------------------------------- /FE/src/components/common/VideoPlayer/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@foundation/Button/Button'; 2 | import { theme } from '@styles/theme'; 3 | import Icon from '@foundation/Icon/Icon'; 4 | import Typography from '@foundation/Typography/Typography'; 5 | import { css } from '@emotion/react'; 6 | import { MouseEventHandler } from 'react'; 7 | 8 | type IconButtonProps = { 9 | text: string; 10 | iconName: string; 11 | onClick: MouseEventHandler; 12 | }; 13 | const IconButton: React.FC = ({ text, iconName, onClick }) => { 14 | return ( 15 | 33 | ); 34 | }; 35 | 36 | export default IconButton; 37 | -------------------------------------------------------------------------------- /FE/src/components/common/VideoPlayer/VideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import { theme } from '@styles/theme'; 3 | 4 | type VideoPlayerProps = { 5 | url: string; 6 | }; 7 | 8 | const VideoPlayer: React.FC = ({ url }) => { 9 | return ( 10 |