From cb81be2413ad32ec8366aef13c485e51781e66ae Mon Sep 17 00:00:00 2001 From: badahertz52 Date: Wed, 21 Aug 2024 17:40:18 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20release=20=EB=B2=84=EC=A0=84=20CD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=20(#510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FE] 프론트엔드 초기 세팅 (#5) * ci: webpack, react, typescript 설정 * ci: eslint, prettier, stylelint 설정 * [BE] feat: 초기 환경 설정 (#8) * feat: 초기 환경 설정 * refactor: 클래스 종료 전 개행 제거 * build: application.yml 설정 * [BE] feat: 초기 엔티티 작성 (#10) * feat: 초기 엔티티 작성 * refactor: 리뷰 내용과 리뷰를 다대일 연결 * refactor: 패키지 구조 고려하여 ProjectGroup으로 수정 * refactor: Project -> Reviewer로 변경 * refactor: 테이블명 일치 (reviewer_group) * [BE] feat: 리뷰 작성 (#15) * feat: 초기 엔티티 작성 * refactor: 리뷰 내용과 리뷰를 다대일 연결 * refactor: 패키지 구조 고려하여 ProjectGroup으로 수정 * refactor: Project -> Reviewer로 변경 * refactor: 테이블명 일치 (reviewer_group) * feat: 멤버 레포지토리 생성 * feat: 리뷰어그룹 레포지토리 생성 * feat: 리뷰 문항 레포지토리 생성 * feat: 키워드 레포지토리 생성 * feat: 리뷰 키워드 레포지토리 생성 * feat: 리뷰 레포지토리 생성 * feat: 리뷰 작성 기능 구현 * test: 리뷰 작성 기능 테스트 추가 --------- Co-authored-by: donghoony Co-authored-by: nayonsoso * [BE] feat: 테스트 메서드별 격리 (#19) * feat: 테스트 격리를 위한 DB Cleaner 및 Extension 구현 * feat: Service에서 공통적으로 사용할 수 있는 어노테이션 제공 * test: 서비스 테스트 어노테이션 사용하도록 수정 * fix: 누락된 리뷰 작성 매핑 URL 추가 (#17) * [BE] feat: 리뷰 조회 (#22) * feat: 리뷰 조회 기능 추가 * style: 개행 변경 * test: 리뷰 조회 기능 테스트 추가 * refactor: 리뷰 조회 메서드명 변경 * refactor: id 타입 변경 * style: 개행 수정 * fix: PostMapping 수정 --------- Co-authored-by: KIMGYUTAE Co-authored-by: donghoony * [BE] feat: 커스텀 예외 처리 (#20) * feat: 커스텀 예외 생성 * feat: 커스텀 예외 적용 * feat: 글로벌 예외 처리 * [BE] feat: 리뷰어 그룹 정보 및 키워드 조회 API (#24) * feat: 키워드 조회 API * feat: 리뷰 그룹 조회 API * refactor: PathVariable 변수명 변경 * [FE] 리뷰 작성화면과 상세 리뷰 보기 화면 결합 (#26) * fix : webpack dev sever 열 때 오류 수정 - package.json 에서 type :"module" 삭제 , stylelint 관련 설정 삭제 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * ci : svg파일 관련 환경 셋팅 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * design : reset 스타일, global style에서 폰트 적용 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 리뷰 잠금 버튼 추가 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 상세 리뷰 보기 화면의 질문,답 컴포넌트 추가 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 상세 리뷰 보기 화면의 프로젝트 설명 컴포넌트 추가 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat : 상세 리뷰 보기 화면 페이지 추가 및 상세 리뷰 데이터 타입 추가 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * fix: webpack dev server 열 때 오류 수정 Co-authored-by: soosoo22 * design: reset css 적용 Co-authored-by: soosoo22 * feat: 리뷰 작성 페이지 구현 Co-authored-by: soosoo22 * ci: svg 파일 관련 설정 * fix : 머지충돌 해결 및 오타 수정 * feat: react-router-dom, react-router 설치 및 설정 * refactor: 리뷰 작성 페이지와 상세 리뷰 보기 페이지 결합 및 리팩토링 * refactor: 리뷰 타입 파일 리팩토링 및 타입 추가 * feat: api 설정 추가 및 상세 리뷰 보기 페이지에 api 핸들러 추가 --------- Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: chysis Co-authored-by: soosoo22 * [BE] refactor: 패키지 구조 리팩터링 (#30) * refactor: 패키지 구조 구체화 * refactor: 패키지 구조 구체화(서비스, 컨트롤러, 레포지토리) * [BE] feat: 리뷰어 그룹 설명 작성, 마감 기한 및 검증 로직 추가 (#34) * feat: BadRequestException 추가 * feat: 리뷰어 그룹 이름, 설명 길이 검증 * [FE] design: theme ,전역 스타일을 설정 (#41) * chore: 불필요한 파일 삭제 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * design: theme 생성 - 색상, 폰트 사이즈, 폰트 wieght 설정 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * design: emotion ThemeProvider 적용 및 Theme 타입 확장 - emotion에서 제공하는 Theme 타입이 빈 객체여서 코드에서 사용하는 theme에 맞게 타입을 확장함 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * design : rem 셋팅 및 reset.css를 globalStyle에 적용 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: App에서 불필요한 코드 삭제 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> --------- Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> * [FE] ci: 리액트 쿼리 및 msw 설치, eslint import rule 추가 및 적용 (#44) * ci: eslint 오류 수정 및 import rule 추가 및 관련 플러그인 설치 - eslint-config-prettier - eslint-plugin-import Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * style: eslint 적용으로 인한 코드 포맷팅 변경 import 순서 적용 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * ci: msw 설치 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> --------- Co-authored-by: badahertz52 Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> * [BE] docs: Swagger를 활용한 API 문서 자동화 (#31) * chore: swagger-ui 설정 추가 * feat: swaggerConfig 추가 * docs: 컨트롤러에 swagger 어노테이션 추가 * docs: dto에 swagger 어노테이션 추가 * chore: api 문서 설정 파일 추가 * refactor: SwaggerConfig info 설정 파일 변수 참조하도록 변경 * docs: api 문서에 상태코드 올바르게 노출되도록 변경 * chore: api 문서 파일명 변경 * chore: api-docs 프로퍼티명 변경 * refactor: 프로퍼티 정보와 설정 클래스 분리 * refactor: 컨트롤러 내 swagger 어노테이션 분리 --------- Co-authored-by: KIMGYUTAE * [BE] feat: 리뷰 도메인 구체화, 정책 구현 (#43) * feat: dto 검증을 위한 의존성 추가 * feat: dto에 jakarta validation 어노테이션 추가 * feat: 컨트롤러에서 요청을 검증할 수 있도록 설정 * feat: FieldError 내용을 보여줄 응답 객체 생성 * feat: FieldError 핸들러 추가 * refactor: 문자열 공백 검증을 위해 NotBlank 사용 * style: todo 주석 추가 * feat: 데드라인이 지나면 리뷰를 작성할 수 없도록 하는 기능 추가 * refactor: 사이즈 검증문 제거 * style: 개행 변경 * refactor: 예외 이름을 과거형으로 변경 * refactor: RFC 9457 형식을 지키면서 필드 에러 정보를 반환하도록 수정 * chore: 오타 수정 * fix: conflict 해결 * feat: dto에 jakarta validation 어노테이션 추가 * refactor: 문자열 공백 검증을 위해 NotBlank 사용 * refactor: 사이즈 검증문 제거 * [BE] feat: 키워드 도메인 구체화, 정책 구현 (#40) * feat: 키워드 일급 컬렉션 및 제약사항 구현 * build: 테스트 환경에서 Lombok 의존성 추가 * feat: 리뷰에 추가된 키워드 삭제 * feat: 리뷰에 키워드 추가 * feat: 일급 컬렉션의 이름을 SelectedKeywords로 변경 * refactor: 키워드 개수 정책 5개 반영 * refactor: 중복 키워드 멘트 수정 * refactor: `deleteAll`로 메서드명 수정 * refactor: `duplicated` -> `duplicate`로 수정 * refactor: 키워드 동등성 비교 * test: 키워드 등록 시 기존 키워드 삭제 테스트 작성 * refactor: 사용하지 않는 필드 삭제 * style: add newlines between comments * refactor: 키워드 ID 기반 비교 * refactor: id가 없는 경우 detail 비교 * refactor: 키워드 테스트 도메인 기반으로 수정 * [BE] feat: 리뷰어 그룹 도메인 구체화, 정책 구현 (#45) * feat: Member 엔티티에 GitHub ID 속성 추가 * feat: 작성한 리뷰어가 리뷰어 그룹에 속하는지 검증 * feat: 리뷰 작성 시, 이미 작성한 리뷰가 있지 않은지 검증 * refactor: Review 엔티티의 reviewer와의 연관관계 ManyToOne으로 변경 * refactor: 컬럼명 오류 수정 * refactor: gitHub 표기 -> github으로 변경 * refactor: GithubReviewGroup -> GithubReviewGroup 클래스명 변경 * refactor: 누락된 @Column 추가 * test: 리뷰 작성 테스트 시, 깃헙 사용자 그룹 데이터 저장 부분 추가 * refactor: 리뷰 작성 시, 중복 리뷰 검증 로직 수정 * refactor: GithubReviewerGroupNotFoundException을 UnAuthorizedException 구현하도록 수정 * refactor: ReviewContentExistException -> ReviewAlreadySubmittedException 클래스명 변경 * feat: UnAuthorizedException 추가 * refactor: 메서드 순서 변경 * test: 불필요한 given절 삭제 * test: 테스트를 위한 데이터 삽입 repository를 활용하도록 변경 --------- Co-authored-by: Donghoon Lee * [FE] 리뷰 쓰기 페이지에 확정된 디자인을 반영하고, 리팩터링한다. (#53) * fix: eslint에서 절대 경로를 읽지 못하는 오류 수정 절대 경로 지정 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * design: 시스템 기본 폰트 크기 지정 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * chore: 사용하지 않는 import 제거 emotion.ts에서 theme 제거 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 공통 버튼 컴포넌트 구현 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 키워드 버튼 컴포넌트 구현 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 리뷰 작성 페이지 퍼블리싱 및 컴포넌트 분리 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * feat: 리뷰이 코멘트 컴포넌트 구현 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * refactor: 리뷰 작성 페이지에서 리뷰 아이템 컴포넌트 분리 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * refactor: 리뷰 작성 페이지 리팩터링 - 리뷰이 코멘트 추가 - 리뷰 아이템 컴포넌트 분리 - 부가적인 스타일링 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> --------- Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * [FE] feat: sidebar 리팩토링 및 기능 추가 , topbar 추가, 반응형 페이지 레이아웃 적용 (#55) * style: globalStyles.ts에서 경로 공백 추가 * docs: 불필요한 import 삭제 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * design : theme 추가 - 추가된 theme : breakpoints, sidebarWidth Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * chore: Header 컴포넌트 삭제 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * feat : Topbar 생성, logo 이미지 변경 및 SearchInput 생성 - logo 이미지 변경 - Topbar에 들어가는 컴포넌트들 (SidebarOpenButtion, Logo )생성 - userProfile 이미지 생성 - 공통 컴포넌트 : SearchInput ui 생성 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * feat : 페이지 레이아웃 생성 - 레이아웃 관련 컴포넌트들 생성 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * refactor : App에 페이지 레이아웃 적용 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * refactor: Sidebar 리팩토링 - 메뉴명 상수처리 - 피그마 디자인 변경에 따른 수정 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> * feat: sidebar 열고 닫는 기능 추가 Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> --------- Co-authored-by: soosoo22 <80167893+soosoo22@users.noreply.github.com> * fix: 컴파일 에러 해결 (#60) Co-authored-by: nayonsoso * [FE] fix : stylelint 적용 오류 수정 (#66) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * ci: CI 스크립트 작성 (#70) * [BE] test: 깃헙 리뷰어 그룹의 테스트 추가 및 fixture 미적용 부분 보완 (#68) * test: GithubReviewerGroupRepository 테스트 추가 * test: ReviewServiceTest에 테스트 fixture 적용 * test: given, when, then절 추가 * test: given 데이터 오류 수정 * [BE] chore: workflow 디렉터리 이동 (#74) * chore: workflow 디렉터리 이동 * fix: 백엔드 디렉터리로 이동 * fix: 백엔드 디렉토리로 이동 * fix: checkout actions의 디렉토리 설정 * fix: 디렉토리 설정... * infra: CD 파이프라인 작성 (#76) * fix: cd gradle 디렉토리 이동 (#77) * [BE] refactor: 단건 리뷰 조회 시 리뷰어 정보 삭제, 리뷰 작성 시점 추가 (#65) * refactor: 리뷰 조회 시 리뷰 생성 시각 추가, 리뷰어 정보 삭제 * fix: 테스트 어노테이션 활성화 및 수정 * chore: 테스트 클래스 패키지 이동 * refactor: 불필요한 given절 제거 * [FE] 리뷰 작성, 상세 리뷰 보기 페이지에 백엔드 API 연동 및 dotenv-webpack 설치, BASE_URL을 .env로 이동 (#78) * chore: gitignore에 yarn-error.log 추가 * ci: env 파일 사용을 위한 dotenv-webpack 설치 * refactor: 엔드포인트에 env 파일을 통한 API_BASE_URL 경로 적용 * fix: 리뷰 상세보기 데이터에서 백엔드 응답과 속성 이름이 달랐던 부분 수정 * refactor: 백엔드 응답 및 리팩토링된 리뷰 쓰기 형식에 맞게 리뷰 쓰기 모킹 데이터 수정 * chore: 불필요한 console.log 삭제 * [Fe] 절대 경로 사용 시 발생하는 eslint 오류를 수정했습니다. (#81) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * [FE] ci: Jest, RTL 테스트 환경 구축 (#84) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * ci: jest 및 RTL 설치 * ci: jest 타입 설정 추가 * ci: CI를 위한 yml 파일 테스트 * fix: 잘못 올라간 파일 삭제 --------- Co-authored-by: badahertz52 * [BE] refactor: 발생하는 모든 예외의 형식 통일 (#69) * refactor: 커스텀 예외를 발생시키도록 수정 * refactor: 모든 예외를 잡을 수 있도록 수정 * refactor: ExceptionHandler가 반환하는 예외 응답 형식 변경 - ProblemDetail로 변경 * refactor: ResponseEntityExceptionHandler를 상속하지 않도록 변경 * feat: ResponseEntityExceptionHandler에서 처리하는 예외를 직접 핸들링 * feat: CORS 설정 추가 (#88) * fix: ci PR 브랜치 설정 (#92) * refactor: 예외 핸들러 인자 변경 (#87) * [BE] 도메인 연관관계 재설정 (#97) * feat: 깃허브 아이디 원시값 포장 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 회원 `GithubId` 사용 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * refactor: 키워드 내 컬럼 수정 (content) Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * refactor: 일급 컬렉션 이름 변경 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 질문 엔티티 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * refactor: 답변 구조 변경, 길이 검증 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * refactor: 키워드 연관관계 없이 참조하도록 설정 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * test: 답변 길이 테스트 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 리뷰 생성 시 키워드 받아서 생성 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * test: 리뷰어와 리뷰이 같은 경우의 예외 테스트 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * chore: 리뷰-키워드 매핑 테이블 삭제 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * fix: Fixture 사용해 컴파일 에러 해결 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * refactor: 일대다 편의 메소드 작성, Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 깃허브 아이디 그룹, 리뷰어 연관관계 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 리뷰그룹 - 리뷰 일대다 단방향 연관관계 적용 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 리뷰그룹 - 리뷰 일대다 양방향 연관관계 적용 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 리뷰어 그룹 깃허브 아이디 검증 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 셀프리뷰 검증 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * chore: 사용하지 않는 파일 삭제 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 리뷰 추가 검증 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * fix: 순환 참조 NPE 해결 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * chore: 사용하지 않는 import 제거 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * chore: Github ID 패키지 이동 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * feat: 리뷰 - 리뷰 답변 연관관계 설정 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * chore: 사용하지 않는 코드 제거 * refactor: 내가 받은 리뷰 상세 보기 구현 --------- Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * [FE] feat: 리뷰 작성 페이지 기능 구현 (#89) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * chore: 현재 사용되지 않는 저장 버튼 숨김 처리 * design: 기본 폰트 사이즈 변경 - 15px -> 16px로 수정 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * design: 리뷰 작성 페이지의 수정된 디자인 적용 - 색상 코드 수정 - 버튼 호버 시 스타일링 수정 - 리뷰 마감일 날짜 형식 변경 * design: 리뷰 질문에 안내 문구 및 placeholder 추가 - 각 질문에 ' (20자 이상)' 문구 하드코딩 - 최대 1000자 입력 가능하다는 문구를 textarea의 placeholder로 추가 * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * chore: api명 및 endpoint명 수정 * chore: 리뷰 질문 목록을 서버에서 받아오기 위해 상수 제거 * chore: api 문서 형식에 맞게 데이터 타입 수정 * fix: 키워드 버튼을 눌렀을 때 폼이 제출되는 문제 해결 * feat: 작성한 리뷰 내용을 부모 컴포넌트의 상태와 동기화하도록 설정 * design: textarea의 크기를 고정시키고, 넘어갈 경우 스크롤 되도록 변경 * design: 공통 버튼 컴포넌트에 조건부 스타일링 구현 * design: disabled 관련 색상 변수 추가 * feat: 모든 폼을 작성했을 때 제출 버튼이 활성화되도록 구현, 키워드 선택 로직 구현 * feat: 제출 버튼 클릭 시 confirm 발생, 한 번 더 확인 시 제출되도록 구현 * feat: 키워드 버튼을 5개 초과해서 선택할 수 없도록 구현 * feat: textarea에 1000자를 초과해서 입력할 수 없도록 구현 및 현재 글자수 표시 * fix: 이미 선택된 키워드를 해제하려 해도 개수 제한되는 오류 수정 * refactor: 폼 유효성 검사 조건들을 변수로 분리 * design: 키워드 버튼들의 크기를 fit-content로 변경 및 가로로 나열하도록 수정 * chore: api 연결 이전, 임시로 폼 정보를 콘솔에 출력하도록 설정 * chore: 리뷰 작성 완료 페이지 라우터 추가 * feat: 리뷰 작성 완료 페이지 및 작성 완료 시 페이지 이동 기능 구현 * refactor: 리뷰 아이템 컴포넌트 매직넘버 상수화 * chore: 시계 아이콘 스타일 컴포넌트화 * refactor: 리뷰 관련 메시지 상수 분리 * design: 리뷰 작성란 placeholder 및 본문 font weight 변경 * refactor: 리뷰 문항 번호 동적으로 부여하도록 변경 및 상수화 적용 * refactor: on-, handle- prefix 관련 컨벤션 적용 및 키워드 버튼 인터페이스 확장 * chore: 리뷰 답변의 길이가 유효한지 체크하는 변수의 이름을 의미를 잘 드러내도록 수정 * refactor: 공통 버튼의 타입별 스타일을 함수로 분리 * style: api 호출 함수명 컨벤션에 맞게 수정 * chore: 불필요한 PropsWithChildren import 제거 --------- Co-authored-by: badahertz52 * [FE] MSW를 mock server로 셋팅 (#95) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * style: eslint 적용에 따른 리뷰 상세페이지 import 순서 정리 * refactor: formatDate를 utils/date 파일로 이동 * design: theme에 colors,, fontSize 변경 및 borderRadius 추가 * feat: MultilineTextViewer 컴포넌트 생성 - 개행이 포함된 string에 개행을 적용해서 보여주는 컴포넌트 * feat: 깃허브 저장소 이미지 컴포넌트 생성 * feat: 리뷰와 관련된 날짜 UI 컴포넌트 생성 * featr: LockButton 삭제 LockToggle 추가 * refactor: 피그마 디자인 변경에 따른 ReviewDescription 변경 * feat: ReviewComment 컴포넌트 생성 * refactor: ReviewViewSection -> ReviewSection 으로 변경 및 리팩토링 - 불필요한 컴포넌트 삭제 : RevieAnswer , ReviewQuestion * refactor: DetailedReviewPage 리팩토링 - 목데이터 변경 - 추가 및 변경된 컴포넌트를 사용해 리뷰 상세페이지 컴포넌트(DetailedReviewPage) 리팩토링 - DetailedReviewPage 폴더의 styles.ts 삭제 * refactor: review에 대한 타입 변경 * design : ReviewDate의 클론 스타일 적용 * feat: KeywordSection 컴포넌트 생성 - 리뷰 상세 페이지 키워드 부분 컴포넌트 생성 * feat: ReviewSectionHeader 컴포넌트 생성 및 적용 - 리뷰 상세보기에서 반복되는 질문,키워드 헤더부분을 컴포넌트로 분리 * design : 리뷰 상세페이지에 width 변경 * refactor: DetailedReview의 목데이터 변경 및 리팩토링 - 타입 변경에 따른 목 데이터 변경 - KeywordSection 적용 * design : formWidth를 theme에 추가 및 리뷰 작성/리뷰 상세 페이지에 적용 * fix: Layout에서 가로 스크롤 생기는 오류 수정 - 100vw는 스크롤을 포함한 뷰포트 너비라서 100%으로 수정 * feat: 리뷰 상페이지 router에 라우터 파라미터 적용 및 관련 설정 변경 - 데모데이를 위해 현재 데이터베이스에 있는 리뷰 상세페이지 id를 sidebar의 리뷰 상세페이지 메뉴 link에 적용 - 리뷰 상세페이지(DetailedReviewPage)의 api 핸들러 수정 * docs: 변수명 변경 (isLock -> isPublic) * refactor: 깃헙 저장소 로고 주소 변수명 변경 - projectImgSrc -> thumbnailUrl * ci: msw 관련 패키지 설치 * ci: msw 관련 설정파일 추가 - 브라우저 환경, node 환경에서 msw로 목서버 사용할 수 있도록 관련 파일 추가 * feat: mock 핸들러 추가 및 상세 리뷰 페이지 목 데이터 추가 * feat: root에서 목서버 사용할 수 있도록함 * refactor: endpoint 수정 - env 에서 서버 주소 끝에 슬래시 넣는 것으로 통일 * feat: 상세 리뷰 페이지(detailedReviewPage)에 목서버 연결 및 관련 코드 수정 - 상태명 변경: detailReview -> detailedReview - detailedReview 타입에 null 추가 및 그에 따른 오류 핸들링 추가 - deadline에 string 타입으로 response로 전달되어서 new Date로 감싸서 props로 전달 * docs: indexhtml의 title 변경 * style: apis/review.ts 의 import 관련 eslint rule 적용에 따른 수정 * fix: ts에서 process 읽지 못하는 오류 수정 * fix: webpack dev server script 복원 * [FE] 상세 리뷰 페이지 : url router 연동, 변경된 디자인 반영 및 서버에서 api를 연동 (#91) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * style: eslint 적용에 따른 리뷰 상세페이지 import 순서 정리 * refactor: formatDate를 utils/date 파일로 이동 * design: theme에 colors,, fontSize 변경 및 borderRadius 추가 * feat: MultilineTextViewer 컴포넌트 생성 - 개행이 포함된 string에 개행을 적용해서 보여주는 컴포넌트 * feat: 깃허브 저장소 이미지 컴포넌트 생성 * feat: 리뷰와 관련된 날짜 UI 컴포넌트 생성 * featr: LockButton 삭제 LockToggle 추가 * refactor: 피그마 디자인 변경에 따른 ReviewDescription 변경 * feat: ReviewComment 컴포넌트 생성 * refactor: ReviewViewSection -> ReviewSection 으로 변경 및 리팩토링 - 불필요한 컴포넌트 삭제 : RevieAnswer , ReviewQuestion * refactor: DetailedReviewPage 리팩토링 - 목데이터 변경 - 추가 및 변경된 컴포넌트를 사용해 리뷰 상세페이지 컴포넌트(DetailedReviewPage) 리팩토링 - DetailedReviewPage 폴더의 styles.ts 삭제 * refactor: review에 대한 타입 변경 * design : ReviewDate의 클론 스타일 적용 * feat: KeywordSection 컴포넌트 생성 - 리뷰 상세 페이지 키워드 부분 컴포넌트 생성 * feat: ReviewSectionHeader 컴포넌트 생성 및 적용 - 리뷰 상세보기에서 반복되는 질문,키워드 헤더부분을 컴포넌트로 분리 * design : 리뷰 상세페이지에 width 변경 * refactor: DetailedReview의 목데이터 변경 및 리팩토링 - 타입 변경에 따른 목 데이터 변경 - KeywordSection 적용 * design : formWidth를 theme에 추가 및 리뷰 작성/리뷰 상세 페이지에 적용 * fix: Layout에서 가로 스크롤 생기는 오류 수정 - 100vw는 스크롤을 포함한 뷰포트 너비라서 100%으로 수정 * feat: 리뷰 상페이지 router에 라우터 파라미터 적용 및 관련 설정 변경 - 데모데이를 위해 현재 데이터베이스에 있는 리뷰 상세페이지 id를 sidebar의 리뷰 상세페이지 메뉴 link에 적용 - 리뷰 상세페이지(DetailedReviewPage)의 api 핸들러 수정 * docs: 변수명 변경 (isLock -> isPublic) * refactor: 깃헙 저장소 로고 주소 변수명 변경 - projectImgSrc -> thumbnailUrl * refactor: 리뷰 상세 페이지 api 변경에 따른 수정 - 리뷰 상세 페이지 keyword 타입 변경 - api endpoint 변경 , router 변경, sidebar 상세보기 경로 변경 - useState에 빈 값에 대한 타입 추론 사용 * [BE] test: 도메인 연관관계 재설정 후 테스트 작성 (#101) * test: 리뷰어 그룹 테스트 작성 * refactor: 리뷰 작성 테스트를 `ReviewTest`로 이동 * test: 리뷰어 중복 추가 테스트 * refactor: Test Fixture 사용하도록 수정 * refactor: 예외 클래스명 통일 * style: 테스트 개행 * refactor: 테스트명 명확하게 수정 * refactor: 회원 도메인에서의 비교를 GithubId로 진행하도록 수정 * refactor: createdAt 사용하지 않고, deadline으로 수정 * refactor: 필드명 reviewerGithubIds로 통일 * test: 리뷰어 중복 생성 검증 * refactor: reviewer/reviewee 통일 * refactor: 리뷰어-리뷰이 github id를 명시 * refactor: 테스트에 하나의 검증만 진행되도록 수정 * [FE] feat: 리뷰 목록 페이지 기능 구현 (#90) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * feat: DropDown 컴포넌트 구현 - 사용자 선택을 위한 `DropDown` 컴포넌트를 구현 - `onChange` 이벤트 핸들러와 `options` 배열을 사용하여 옵션을 동적으로 표시 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: svg 아이콘 크기 수정 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * design: Topbar UI 수정 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * design: 버튼, 검색창 UI 수정 후 export Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: ReviewPreviewCard 컴포넌트 구현 - 리뷰 미리보기 카드 컴포넌트 구현 - 리뷰어 그룹 이름, 생성일, 리뷰 내용 미리보기, 키워드, 공개 여부를 표시 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: ReviewPreview 인터페이스 추가 - 리뷰 미리보기 데이터 구조를 정의하는 `ReviewPreview` 인터페이스를 추가했습니다. Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: SearchSection 컴포넌트 구현 - 검색 입력창, 검색 버튼, 드롭다운을 포함하여 검색 기능을 구현 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: ReviewPreviewListPage 구현 및 모의 데이터 추가 - `ReviewPreviewListPage` 컴포넌트 구현 - `SearchSection`을 포함하여 검색 기능을 구현 - `ReviewPreviewCard`를 사용하여 리뷰 목록을 표시 - 모의 데이터(`mockReviewPreviews`) 추가 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: import 중복 및 순서 정리, ReviewPreviewListPage 라우트 추가 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: 여러 개의 요소를 감싸기 때문에 Container로 변경 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: 리뷰 목록 데이터를 서버에서 가져오기 위해 getReviewListApi 함수 추가 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * refactor: 상세 리뷰 불러오기 api 엔드포인트 수정 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * refactor: 상세 리뷰 페이지 API 연동 부분 수정 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> --------- Co-authored-by: badahertz52 Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> * [BE] feat: 리뷰 작성 API 구현 (#102) * refactor: 리뷰 요청 dto 수정 * refactor: 리뷰 콘텐츠 생성 시, 리뷰에 자신을 추가하도록 변경 * feat: QuestionRepository 추가 * feat: 리뷰 작성 기능 추가 * test: 리뷰 작성 테스트 추가 * refactor: ReviewGroup 생성 시, GithubIdReviewerGroup도 같이 저장되도록 변경 * refactor: GithubId equals 및 hashcode 재정의 * refactor: review 생성 시, reviewGroup이 null이 아니도록 변경 * refactor: EqualsAndHashCode에 id 명시 * refactor: reviewee를 reviewerGroup 통해서 받아오도록 변경 * refactor: 파라미터 long 타입으로 변경 * test: 사용하지 않는 변수 제거 * test: 파라미터별로 개행하도록 변경 * refactor: ReviewerGroupGithubIds의 reviewerGithubIds를 CascadeType.PERSIST로 변경 * chore: 다른 작업에서 진행될 사항으로 사용하지 않는 테스트 삭제 * [FE] Github Actions로 CI 구축 (#96) * ci: CI를 위한 yml 파일 작성 * ci: yarn.lock 경로 추가 * ci: working-directory 추가 * cI: 동작 테스트를 위해 push 브랜치 경로 조건에서 frontend 임시 제거 * ci: 테스트를 위해 현재 브랜치도 조건에 추가 * ci: package에 test 명령어 추가 * ci: yarn.lock 파일 업데이트 * ci: 테스트 파일 형식 수정 * chore: 필요없는 파일 삭제 * chore: svg 파일을 대문자로 import한 것들 소문자로 수정 * ci: 웹팩 env 설정 업데이트 * ci: process.env 사용을 위한 타입 설정 * ci: env 웹팩 설정 경로를 절대 경로로 변경 * ci: env 웹팩 설정 경로 수정 * ci: env 절대경로 수정 * ci: 절대경로 수정 2 * ci: 읽기 권한을 명시적으로 주고 테스트 * ci: dist 파일 테스트 * ci: CI 환경에서도 env 파일 생성 * ci: 배포 정상 동작 확인을 위한 github pages 임시 배포 설정 * ci: 임시 배포 코드 삭제 * ci: develop 브랜치로 frontend 이하 경로에서 PR이 올라올 때 CI가 실행되도록 수정 * ci: node 설치 에러 해결 * [BE] feat: 리뷰에 필요한 정보 조회 기능 추가 (#103) * test: QuestionTestFixture 생성 * feat: 모든 리뷰 문항을 조회하는 기능 구현 * feat: 모든 키워드를 조회하는 기능 구현 * test: ReviewerGroupFixture 생성 * feat: 리뷰 생성 시 필요한 리뷰어 그룹 정보를 조회하는 기능 구현 * feat: 리뷰 생성 시 필요한 정보를 조회하는 기능 구현 * refactor: @ServiceTest 적용 * refactor: swagger 적용 * refactor: 필드명 변경 * style: 개행 추가 * refactor: 날짜 형식 변경 * test: import문 제거 * refactor: ReviewCreationResponse 패키지 변경 * refactor: readOnly 트랜잭션 적용 * fix: 리뷰어 중복 검증 임시 제거 --------- Co-authored-by: donghoony * [FE] 리뷰 작성 페이지에서 MSW를 사용하여 서버 mocking (#111) * chore: 중복된 인터페이스 제거 * feat: MSW 핸들러 및 mock data 추가 * chore: 리뷰 쓰기 페이지 데이터 endpoint 수정 * feat: 리뷰 작성 페이지에 msw를 적용하여 서버 mocking * merge * [FE] 공통 컴포넌트인 모달을 만든다. (#110) * fix: stylelint를 통한 css 속성 정렬 기능 오류 수정 - stylelint 버전16과 충돌되는 플러그인 삭제 : stylelint-config-prettier, stylelint-prettier - css 정렬에 필요하지 않은 플러그인 삭제 : stylelint-config-standard, stylelint-config-styled-componented, stylelint-webpack-plugin - 추가로 설치한 플러그인: postcss-syntax, @stylelint/postcss-css-in-js - stylelint 적용 script 추가 - .stylelintrc.json 수정 : css 관련 rule 설정 * refactor: stylelint 적용에 따른 css 속성 정렬 * fix : 절대 경로 사용 시 오류 수정 오류 : eslintimport/no-unresolved * chore: eslintrc.cjs 에서 불필요한 코드 삭제 node 환경 setting 삭제 * style: eslint 적용에 따른 리뷰 상세페이지 import 순서 정리 * refactor: formatDate를 utils/date 파일로 이동 * design: theme에 colors,, fontSize 변경 및 borderRadius 추가 * feat: MultilineTextViewer 컴포넌트 생성 - 개행이 포함된 string에 개행을 적용해서 보여주는 컴포넌트 * feat: 깃허브 저장소 이미지 컴포넌트 생성 * feat: 리뷰와 관련된 날짜 UI 컴포넌트 생성 * featr: LockButton 삭제 LockToggle 추가 * refactor: 피그마 디자인 변경에 따른 ReviewDescription 변경 * feat: ReviewComment 컴포넌트 생성 * refactor: ReviewViewSection -> ReviewSection 으로 변경 및 리팩토링 - 불필요한 컴포넌트 삭제 : RevieAnswer , ReviewQuestion * refactor: DetailedReviewPage 리팩토링 - 목데이터 변경 - 추가 및 변경된 컴포넌트를 사용해 리뷰 상세페이지 컴포넌트(DetailedReviewPage) 리팩토링 - DetailedReviewPage 폴더의 styles.ts 삭제 * refactor: review에 대한 타입 변경 * design : ReviewDate의 클론 스타일 적용 * feat: KeywordSection 컴포넌트 생성 - 리뷰 상세 페이지 키워드 부분 컴포넌트 생성 * feat: ReviewSectionHeader 컴포넌트 생성 및 적용 - 리뷰 상세보기에서 반복되는 질문,키워드 헤더부분을 컴포넌트로 분리 * design : 리뷰 상세페이지에 width 변경 * refactor: DetailedReview의 목데이터 변경 및 리팩토링 - 타입 변경에 따른 목 데이터 변경 - KeywordSection 적용 * design : formWidth를 theme에 추가 및 리뷰 작성/리뷰 상세 페이지에 적용 * fix: Layout에서 가로 스크롤 생기는 오류 수정 - 100vw는 스크롤을 포함한 뷰포트 너비라서 100%으로 수정 * feat: 리뷰 상페이지 router에 라우터 파라미터 적용 및 관련 설정 변경 - 데모데이를 위해 현재 데이터베이스에 있는 리뷰 상세페이지 id를 sidebar의 리뷰 상세페이지 메뉴 link에 적용 - 리뷰 상세페이지(DetailedReviewPage)의 api 핸들러 수정 * docs: 변수명 변경 (isLock -> isPublic) * refactor: 깃헙 저장소 로고 주소 변수명 변경 - projectImgSrc -> thumbnailUrl * ci: msw 관련 패키지 설치 * ci: msw 관련 설정파일 추가 - 브라우저 환경, node 환경에서 msw로 목서버 사용할 수 있도록 관련 파일 추가 * feat: mock 핸들러 추가 및 상세 리뷰 페이지 목 데이터 추가 * feat: root에서 목서버 사용할 수 있도록함 * refactor: endpoint 수정 - env 에서 서버 주소 끝에 슬래시 넣는 것으로 통일 * feat: 상세 리뷰 페이지(detailedReviewPage)에 목서버 연결 및 관련 코드 수정 - 상태명 변경: detailReview -> detailedReview - detailedReview 타입에 null 추가 및 그에 따른 오류 핸들링 추가 - deadline에 string 타입으로 response로 전달되어서 new Date로 감싸서 props로 전달 * docs: indexhtml의 title 변경 * style: apis/review.ts 의 import 관련 eslint rule 적용에 따른 수정 * fix: ts에서 process 읽지 못하는 오류 수정 * fix: webpack dev server script 복원 * feat: ModalPortal 셍성 * feat: SideModal 컴포넌트, useSide 셍성 및 Sidebar에 적용 * feat: ModalBackground 컴포넌트 생성 및 적용 * fix: 모달 열릴 때 스크롤바 막는 기능 오류 수정 * design : ModalPortal 사이즈 단위 변경 (% -> vw, vh) * feat: Button 컴포넌트가 button 속성을 props로 받을 수 있도록 수정 * feat: ConfirmModal 생성 * refactor: index.tsx에서 모달을 꺼낼 수 있도록 리팩토링 * refactor: PropsWithChildren 수정 - PropsWithChildren를 import 하지 않고 React에서 바로 쓸 수 있도록 React.PropsWithChildren로 수정 * [FE] msw관련 env 오류 해결 (#114) * chore: fe/feat/105-review-writing-msw 의 endpoint 관련 변경사항 반영 * fix: NODE_ENV 관련 오류 수정 - 오류 : env 파일에 NODE_ENV가 없음에도 이를 인식하는 오류 발생 - 원인: 프레임워크에서 NODE_ENV를 기본적으로 가지고 있는 경우도 있다고 함 - 해결: NODE_ENV를 삭제하고 MSW를 사용 * [BE] feat: 내가 받은 리뷰 보기 기능 구현 (#109) * refactor: contains 작동을 위한 EqualsAndHashcode 추가 * fix: lazyInitialization 해결 * feat: 질문 레포지토리 생성 * feat: 내가 받은 리뷰 응답 생성 * refactor: 리뷰 항목과 질문의 연관관계 변경 및 답변 최대 글자수 DB에 반영 * refactor: 리뷰에 리뷰그룹 초기화 부분 추가 * feat: 내가 받은 리뷰 조회 기능 구현 * feat: 받은 리뷰가 없을 때의 응답 추가 * refactor: dto 설명 추가 * refactor: dto 설명 수정 * refactor: 인자 형식 수정, 개행 수정 * refactor: transactional 어노테이션 추가 * refactor: 내가 받은 리뷰 조회할 때Page객체 말고 List로 받아오도록 수정 * refactor: 미리보기 만드는 기능 도메인 안으로 이동 * test: 테스트 코드 개선 - 변수명 수정, save 여러개 대신 saveAll 사용 등 * refactor: 마지막으로 본 리뷰ID가 없는 로직에 대해 수정 - lastViewedReviewId를 입력하지 않으면 999같이 이상하게 큰 수를 넣어주는게 아니라, 가장 큰 값을 넣어주도록 수정 * docs: 스웨거 데코레이션 적용 * refactor: lastReviewId가 null 이어도 가장 최신 리뷰를 찾을 수 있도록 수정 * refactor: eqaulsAndHashCode 재정의 * refactor: eqaulsAndHashCode 재재정의 * refactor: API Docs 반영 --------- Co-authored-by: donghoony * fix: 잘못 정의된 endpoint 수정 (#118) * [FE] 머지로 인해 사라진 리뷰 상세 페이지 변경 사항 복구 및 데이터 타입 변경 (#121) * refactor: 리뷰 상세 페이지 데이터 타입 변경 및 Keyword 타입 복구 * fix: 리뷰 상세 페이지의 path 복구 * fix: 사이드바의 리뷰 상세 페이지 경로 복구 * refactor: 리뷰 상세 페이지 데이터 타입 변경에 따른 목 데이터 변경 * fix: DetailedReviewPage 복구 및 데이터 타입 변경에 따른 수정 * [BE] 더미 데이터 추가, local 프로파일 관리 (#123) * refactor: Unauthorized 예외 처리 * chore: 더미 데이터 추가 및 Profile 처리 * chore: 테스트 yml 생성 * fix: Long notblank -> notnull (#126) * [FE] 리뷰 목록 페이지에서 msw를 사용하여 모킹 (#124) * feat: 리뷰 목록 페이지에서 msw를 사용하여 모킹 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: 엔드포인트 baseurl에 '/' 추가 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> --------- Co-authored-by: badahertz52 Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> * fix: 리뷰 목록 DTO 필드 수정 (#129) * [FE] msw 모킹을 위한 리뷰 데이터 하드코딩 (#130) * refactor: msw 모킹을 위한 리뷰 데이터 하드코딩 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> * refactor: 2차 데모데이를 위한 멤버 아이디 상수화 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: badahertz52 Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: 불필요한 콘솔 로그 제거 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: badahertz52 Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> --------- Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: badahertz52 Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> * [FE] endPoint 오류와 리뷰 그룹 아이디 수정 (#132) * fix: endpoint 오류 수정 * fix: 리뷰 생성을 위한 데이터 받을 때의 리뷰 그룹 아이디를 1로 수정 * [BE] fix: 리뷰 미리 보기 생성 기능 수정 (#134) * fix: src/index.tsx에서 enableMocking 제거 (#136) * [FE] Children 속성이 필수인 타입 제작 (#147) * feat: children 속성이 필수인 EssentialPropsWithChildren 타입 생성 * chore: types 폴더의 index 파일에 누락됐던 export들 추가 및 그에 따른 types import문 경로 수정 * [FE] 사이드바 및 모달 사용성 개선 (#139) * refactor: 사이드바를 닫는 애니메이션 제거 * refactor: 사이드바가 더 빨리 열리도록 수정 * feat: 모달의 배경 클릭 및 esc 키를 눌렀을 때 모달이 닫히는 기능 추가 * chore: hooks index에 useModalClose 추가 * feat: 사이드바 리스트 아이템을 클릭했을 때 사이드바가 닫히는 기능 추가 * refactor: useModalClose 훅을 모든 모달의 공통 최상위 컴포넌트인 ModalBackground에서 처리하도록 수정 * refactor: ModalBackground를 클릭했을 때 모달이 닫히게 하는 이벤트 리스너를 document 대신 Background에 추가 * fix: esc를 사용해 모달을 닫았을 때 햄버거 버튼에 포커스가 생기는 문제 해결 및 변수명 대소문자 수정 * refactor: useEffect 내부 함수들을 훅 외부로 이동 및 주석 추가 * [FE] 존재하지 않는 element에 대한 에러를 출력하는 유틸리티 함수 제작 (#150) * feat: 존재하지 않는 element에 대한 에러를 출력하는 훅 제작 * chore: hooks 폴더 index에 useExistentElement 추가 * refactor: useExistentElement 훅을 더욱 범용적으로 사용할 수 있도록 수정 * refactor: 존재하지 않는 요소를 판별하는 동작을 커스텀 훅 대신 유틸리티 함수로 제작 * chore: 기존의 존재하지 않는 요소를 탐지하는 커스텀 훅 삭제 * [FE] HTTP 요청 오류 시 status code별 오류 상황을 대응하는 함수 생성 (#151) * feat: api 에러 시 표시한 메세지 생성 함수 추가 * refactor: api/review.ts에 api 오류 시 메세지를 반환하는 createApiErrorMessage 함수 적용 * docs: http 요청 실패 메세지 수정 * fix: 오타 수정 * [FE] 페이지 상단 이동 버튼 구현 (#153) * feat: top button 로직 훅으로 구현 * design: top button UI 구현 * chore: 각 페이지마다 top button이 보여지도록 설정 * chore: 컴포넌트 default로 export하도록 설정 * refactor: handler를 useEffect 외부로 분리 * chore: svg import 컨벤션에 맞추어 이름 수정 * [FE] feat: 에러, 로딩 페이지 구현 및 적용 (#155) * feat: 버튼 컴포넌트에 아이콘을 선택적으로 추가할 수 있도록 변경 - `icon` prop을 통해 버튼에 아이콘을 추가할 수 있도록 변경 * feat: 에러 페이지를 위한 ErrorSection 컴포넌트 구현 - 에러 로고, 메시지, 새로고침 및 홈 이동 버튼 제공 * feat: 라우터 설정에서 `errorElement`에 `ErrorPage` 컴포넌트를 추가 * feat: 버튼 클릭 시 새로고침 및 홈 이동 기능 추가 * feat: LoadingBar 컴포넌트 구현 * feat: 로딩 페이지 추가 및 적용 - 로딩 메시지 추가 - 리뷰 작성 페이지, 리뷰 목록 페이지, 리뷰 상세 페이지에 로딩 페이지 적용 * refactor: buttons 배열을 통해 버튼 생성 * refactor: 코드컨벤션에 맞게 코드 수정 및 imageDescription을 버튼 props에 추가 * refactor: boolean 타입추론 제거 * design: 에러 컴포넌트 화면 가운데 배치 * design: 로딩 컴포넌트 화면 가운데 배치 * [BE] refactor: 도메인 연관 관계 재정의 (#156) * refactor: 도메인 연관관계 재정의 및 사용하지 않는 도메인 삭제 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * test: 사용하지 않는 테스트 삭제 Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> Co-authored-by: hyeonjilee * fix: 예약어 사용하지 않도록 수정 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * refactor: Review 도메인에서 reviewee 필드 제거 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * refactor: 리뷰 저장 기능 구현 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * refactor: 키워드 저장 기능 구현 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * refactor: 리뷰 생성 요청 객체 구현 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * test: 리뷰, 리뷰 내용 도메인 테스트 추가 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * refactor: 리뷰 작성 시 질문 검증 Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * fix: Review의 reviewGroupId 컬럼명 수정 * refactor: 리뷰 생성 시, 키워드 검증을 객체 분리 * refactor: 리뷰 생성 시, 질문 검증을 객체 분리 * style: 코드 재정렬 * test: 리뷰 생성 검증 테스트 추가 * refactor: 사용하지 않는 클래스 삭제 * refactor: keyword 관련 사용하지 않는 클래스 삭제 * refactor: review 관련 사용하지 않는 클래스 삭제 * refactor: 데이터 초기화 객체 수정 * test: 사용하지 않는 테스트 삭제 * refactor: 예외 메세지 말투 통일 * refactor: 사용하지 않는 예외 제거 * refactor: 예외 클래스 패키지 변경 * refactor: NOT NULL 제약조건 추가 * refactor: 테스트에서만 사용되는 함수 제거 * refactor: 테스트 실행 시 발생하는 LazyInitializationException 해결 * refactor: 키워드 검증 함수 순서 변경 * chore: 테스트 코드 컨벤션 통일 * refactor: reviewRequestCode 검증 로직 이동 * refactor: 필드인 questionRepository를 사용하도록 변경 * refactor: 리뷰이, 프로젝트 이름 길이 검증 함수 분리 * test: 리뷰 저장 시 reviewKeywor 저장 검증 추가 --------- Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+kimprodp@users.noreply.github.com> * [All] infra: 디스코드 웹훅 사용해 PR 생성/코멘트 시 멘션 (#169) * feat: PR Request 생성 / Comment 시 디스코드 멘션 * fix: Case-sensitive ID로 해결, ALL webhook 추가 * fix: remove whitespace * chore: rename workflow * fix: fix shell script * fix: step statement * fix: remove trailing whitespace after equals sign * [BE] feat: 리뷰 그룹 생성 API 구현 (#163) * feat: 랜덤한 문자열 생성기 구현 Co-authored-by: donghoony * feat: 리뷰 그룹 생성 요청, 응답 형식 Co-authored-by: donghoony * feat: 리뷰 그룹 생성 Co-authored-by: hyeonjilee Co-authored-by: nayonsoso * feat: 리뷰 작성 컨트롤러 Co-authored-by: hyeonjilee Co-authored-by: nayonsoso --------- Co-authored-by: nayonsoso Co-authored-by: hyeonjilee * [BE] refactor: 리뷰 작성을 위한 정보 요청 기능 리팩터링 (#162) * refactor: 불필요한 update 방지 * feat: controller에 리뷰 생성 요청에 대한 응답 기능 구현 * feat: controller에 리뷰 작성을 위해 필요한 정보를 응답 기능 구현 * feat: service에 리뷰 작성을 위해 필요한 정보 조회 기능 구현 * chore: 사용하지 않는 dto 삭제 * test: 리뷰 리뷰 작성을 위해 필요한 정보 조회 기능 테스트 작성 * refactor: swagger 설정 일부 수정 및 에러 응답코드 노출되도록 변경 * refactor: service에서 원시타입 long을 반환하도록 변경 * docs: api 문서 dto 항목설명 변경 * test: 사용하지 않는 변수 선언하지 않도록 변경 * docs: api 문서 검증 내용에 대한 어체 변경 * refactor: 트랜잭션 조회 명시 추가 * refactor: dto 이름 변경 * [FE] 리뷰 상세 페이지에 react-query 추가 및 리팩토링 (#161) * fix: src/index.tsx에서 enableMocking 제거 * feat: 리뷰 그룹 생성 시 코멘트 컴포넌트명 변경 및 기본값 설정 - 컴포넌트명 변경: RevewComment -> ReveweeComments - reviewGroup의 description이 빈문자열이면 기본값을 보여주는 것으로 변경 * refactor: 서버 DB에 있는 리뷰 데이터를 사용하기 위한 상수화 - 현재 DB에 있는 리뷰 데이터를 목 서버에서도 사용하고, 사이드바 페이지 이동 시에도 활용할 수 있도록 관련 value들을 상수화 함 * feat : QueryClient, QueryClientProvider 적용 - src/index.tsx에 QueryClient, QueryClientProvider 적용 * fix: dependencies에 있는 테스트 패키지들을 devDependencies로 옮김 * feat: 리뷰 상세페이지에 react-query 적용 * feat : msw에 리뷰 상세페이지 404 오류 추가 * ci: react-error-boundary 설치 * feat: Outlet에 QueryErrorResetBoundary,ErrorBoundary, Suspense 적용 * feat: 리뷰 상세페이지에 useSuspenseQuery 적용 * refactor: 리뷰 상세페이지 resource, queryString key 상수화 * refactor: 리뷰 상세페이지 react-query key 상수화 * refactor: 리뷰 상세 페이지 컴포넌트 속에서만 사용하는 상수들 상수화 * refactor: DetailedReviewPage/components에 index.tsx를 추가해 import 경로 간결하게 수정 * feat: error 전파를 위한 QueryClient 옵션 추가 - react-query의 query, mutation에서 error가 전파되도록 QueryClient 옵션 설정 * fix: ErrorPage의 SideModal에 closeModal props로 줌 * refactor: ErrorSection 위치 변경(src/pages/ErrorPage -> src/components/error) * feat: ErrorFallback 컴포넌트 생성 * feat: ErrorSuspenseContainer 생성 및 App.tsx에 적용 * chore: constants/index.ts export 경로 변경 - 중복되는 apiErrorMessage 삭제 - queryKeys 추가 * chore: 3차-1주차 핵심 기능 시현 때 필요 없는 코드 주석 처리 * docs: ErrorPage의 ERROR_MESSAGE 수정 * design: formWidth 변경 및 fontSize에 1.4rem 추가 * feat: 리뷰 상세 페이지에 리뷰이 이름 추가 - 리뷰 상세 페이지 목데이터, 데이터 타입에 리뷰이 이름 추가 - 리뷰 상세 페이지 컴포넌트에 리뷰이 이름 추가 및 관련 스타일 추가 * refactor: 불필요한 export 삭제 * chore: type명 수정 (RevieweeCommentProps =>RevieweeCommentsProps) * refactor: ErrorSection으l Button 수정 * refactor: 리뷰 상세 페이지 데이터 타입 변경에 따른 수정 * refactor: ErrorSuspenseContainer 적용 위치 변경 - App가 아닌 router의 element에서 적용하는 것으로 변경 * refactor: 리뷰 상세 페이지 데이터 타입 강제 방법 변경 * chore: 불필요한 주석 삭제 * refactor: ErrorSection의 buttons 네이밍 변경 및 요소에 key 추가 - buttons -> buttonList * chore: 스타일 주석에 NOTE 추가 * [BE] feat: 리뷰 목록 조회 (#179) * feat: 리뷰 미리보기 Co-authored-by: nayonsoso * feat: 내가 받은 리뷰 목록 응답 생성 Co-authored-by: donghoony * feat: 리뷰 목록 조회 Co-authored-by: nayonsoso * refactor: Cascade 적용으로 불필요한 save 제거 Co-authored-by: nayonsoso * refactor: 리뷰 미리보기 생성 객체 도출 Co-authored-by: nayonsoso --------- Co-authored-by: nayonsoso * fix: 브라우저 타이틀 오타 수정 (#167) * [BE] feat: 리뷰 상세 조회 기능 구현 (#182) * feat: 컨트롤러 리뷰 상세 조회 api 응답 기능 구현 * feat: 리뷰 상세 조회 api 응답 객체 구현 * refactor: 리뷰 상세 조회 api 응답 객체명 변경 * feat: 서비스 리뷰 상세 조회 기능 구현 * test: 리뷰 상세 조회 기능 테스트 추가 * style: 오타 및 개행 수정 * refactor: 날짜 응답 시, 일자까지만 응답하도록 변경 * refactor: 리뷰 조회 권한 검증 로직을 repository 사용하도록 변경 * refactor: 검증메세지 변경 * refactor: 리뷰 서비스에서 리뷰 그룹을 찾을 수 없는 경우에 대한 예외 클래스 변경 * refactor: 리뷰 상세 조회 메서드명 변경 * refactor: 헤더명 상수화 * [FE] recoil을 통한 groupAccessCode 전역 상태 관리 (#180) * ci: recoil 설치 * feat: src/index.ts에 RecoilRoot 적용 * feat: groupAccessCode 에 대한 atom 상태 추가 * feat: useGroupAccessCode 훅 추가 - useGroupAccessCode : groupAccessCode의 atom 상태를 관리하는 훅 * [BE] 내가 받은 리뷰 목록을 최신순으로 조회한다. (#189) * fix: 날짜 순 정렬 * refactor: 사용하지 않는 변수 제거 --------- Co-authored-by: donghoony * [BE] 초기 데이터 설정 (#187) * feat: 초기 데이터 설정 * build: CD 시 local profile 설정 --------- Co-authored-by: donghoony * [FE] feat: Alert Modal과 Error Alert Modal 컴포넌트 구현 및 Button 컴포넌트 확장 (#165) * refactor: 공통 버튼 컴포넌트가 children과 style을 받도록 수정 * chore: error alert modal에서 사용할 primary 색상의 경고 삼각형 추가 * feat: ErrorAlertModal 컴포넌트 구현 * feat: AlertModal 컴포넌트 구현 * chore: 변경된 Button 컴포넌트의 구조에 맞게 코드 수정 * fix: AlertModal이 Esc 또는 background의 클릭으로 닫히지 않도록 수정 * chore: AlertModal과 ErrorAlertModal export * refactor: Button 컴포넌트가 type을 별도로 받도록 수정 및 기존의 buttonType을 styleType으로 변경 * refactor: background 클릭 또는 Esc 키를 통해 모달을 닫는 것을 분리 * refactor: AlertModal의 모달 닫는 정책을 props로 선택 가능하도록 수정 * refactor: ErrorAlertModal을 AlertModal을 사용해 구현 * refactor: 닫기 버튼 이외의 방법으로 모달을 닫을 수 있는지 여부와 handler를 props로 전달받도록 변경 * refactor: 비어있는 스타일 컴포넌트 제거 * [FE] 랜딩 페이지 퍼블리싱 및 디바운싱 함수 추가 (#181) * feat: Input 공통 컴포넌트 제작 * feat: LandingPage 제작 * fix: Input의 type을 text로 수정 * feat: debounce 함수 작성 * docs: debounce 함수에 TODO 주석 및 TSdoc 추가 외 간단한 if문 리팩토링 * chore: 잘못된 파일 구조 및 디렉토리명 수정 * chore: px을 rem으로 수정 * refactor: 동적 스타일링에 css 대신 styled 스타일 적용 * refactor: Input 스타일을 더욱 유연하게 받을 수 있도록 수정, 스타일 리터럴 제거 * refactor: Input에 적용된 커스텀 스타일링을 $style로 수정 * fix: undefined 리턴을 null 리턴으로 수정 * refactor: flex-direction에 별도의 커스텀 타입을 사용하는 대신 React 내장 타입을사용하도록 수정 * refactor: Input 컴포넌트에서 rest props 제거 * chore: 불필요한 import 제거 * [FE] feat: 리뷰 목록 페이지에 변경된 API 연동 후, 리액트 쿼리 적용 및 무한 스크롤 구현 (#192) * design: 리뷰 미리보기 카드 배경색 변경 및 전체 감싸는 div에 아래쪽 여백 추가 * refactor: 리뷰 목록 인터페이스 추가 및 수정 - ReviewPreviewList 인터페이스 추가 - size, lastReviewId, reviews 필드 추가 - ReviewPreview 인터페이스 수정 - keywords 필드를 Keyword[] 타입으로 변경 * chore: 닫힌 자물쇠로 아이콘 변경 * feat: 리뷰 목록 페이지에서 리액트 쿼리 적용 * refactor: 웹 접근성을 위한 alt, aria-label 추가 및 코드 컨벤션에 맞춰 코드 수정 * feat: 리뷰 미리보기 리스트 핸들러에 페이지네이션 로직 추가 - 기존의 단순 반환 핸들러에서 페이지네이션 기능을 포함한 핸들러로 수정 * feat: `SideModal` 컴포넌트에 `closeModal` prop 추가 * feat: 리뷰 목록 페이지에 무한 스크롤 기능 추가 * refactor: useReviewPreviewList 훅 생성하여 데이터 호출 로직 분리 * refactor: 불필요한 ButtonContainer 제거 * design: px을 rem으로 변경 * refactor: Button 컴포넌트에 key 추가 * feat: 리뷰이, 프로젝트명을 보여주는 DescriptionSection 컴포넌트 구현 * refactor: 리뷰 목록을 불러오는 api 변경 이슈로 인해 엔드포인트, fetch 함수 수정 * chore: fragment 제거 * refactor: api 변경에 따라 ReviewPreviewList, ReviewPreview 인터페이스 수정 * refactor: 변경된 api에 따라 props 수정 * refactor: reviewerGroup, isPublic 제거 * refactor: endPoint.gettingReviewList 호출 인자 변경 * refactor: DescriptionSection에서 ReviewInfoSection으로 컴포넌트명 변경 * refactor: useSuspenseQuery 적용 후, 무한스크롤 관련 코드 주석 처리 --------- Co-authored-by: badahertz52 * [FE] feat: 리뷰 작성 페이지와 서버 및 모달 연동 (#191) * chore: 변경된 UI 적용 * design: 키워드 문항 안내 문구 수정 * feat: Textarea 공통 컴포넌트 뼈대 구현 * chore: 수정된 api 엔드포인트 및 queryparams 적용 * design: reset.ts 추가 설정 * chore: 변경된 api 문서에 맞게 타입 수정 * feat: 리뷰 작성 페이지와 모달 및 서버 연동 * chore: 사이드바 메뉴 클릭 시 이동할 path 수정 * fix: ConfirmModal에서 background 클릭 시 모달이 닫히지 않는 현상 수정 * design: AlertModal 및 ErrorAlertModal 디자인 수정 * chore: 리뷰 작성 및 작성 완료 페이지 라우터 추가 * chore: 불필요한 주석 제거 및 사이드바에서 리뷰 작성 메뉴 숨김 처리 * chore: 리뷰 제출 페이지 버튼 타입 명시 * chore: svg import 컨벤션에 맞게 수정 * [FE] 리뷰 목록 페이지, 리뷰 상세 페이지에서 로그인을 연동 (#193) * design: 리뷰 미리보기 카드 배경색 변경 및 전체 감싸는 div에 아래쪽 여백 추가 * refactor: 리뷰 목록 인터페이스 추가 및 수정 - ReviewPreviewList 인터페이스 추가 - size, lastReviewId, reviews 필드 추가 - ReviewPreview 인터페이스 수정 - keywords 필드를 Keyword[] 타입으로 변경 * chore: 닫힌 자물쇠로 아이콘 변경 * feat: 리뷰 목록 페이지에서 리액트 쿼리 적용 * refactor: 웹 접근성을 위한 alt, aria-label 추가 및 코드 컨벤션에 맞춰 코드 수정 * feat: 리뷰 미리보기 리스트 핸들러에 페이지네이션 로직 추가 - 기존의 단순 반환 핸들러에서 페이지네이션 기능을 포함한 핸들러로 수정 * feat: `SideModal` 컴포넌트에 `closeModal` prop 추가 * feat: 리뷰 목록 페이지에 무한 스크롤 기능 추가 * refactor: useReviewPreviewList 훅 생성하여 데이터 호출 로직 분리 * refactor: 불필요한 ButtonContainer 제거 * design: px을 rem으로 변경 * refactor: Button 컴포넌트에 key 추가 * feat: 리뷰 목록,리뷰 상세 페이지 api 핸들러에서 groupAccessCode를 header에 추가 * feat: DetailedReviewPage 에서 groupAccessCode 상태 적용 * feat: LoginRedirectModal 생성 * refactor: ErrorSection 리팩토링 * feat: 리뷰이, 프로젝트명을 보여주는 DescriptionSection 컴포넌트 구현 * refactor: 리뷰 목록을 불러오는 api 변경 이슈로 인해 엔드포인트, fetch 함수 수정 * feat: 리뷰 상세 페이지에 groupAccessCode 상태를 적용 - 리뷰 상세 페이지에 groupAccessCode 상태값이 있으면 api요청을 하고 없으면 LoginRedirectModal을 띄어서 로그인 유도하는 기능 추가 - DetailedPage/index.tsx에 있던 내용을 DetailedReivewPageContents로 이동 - DetailedPage/index.tsx에서 ErrorSuspenseContainer 실행하는 것으로 수정 * chore: fragment 제거 * refactor: api 변경에 따라 ReviewPreviewList, ReviewPreview 인터페이스 수정 * fix: 리뷰 상세 페이지 keyword 타입 변경에 따른 수정 * refactor: 변경된 api에 따라 props 수정 * refactor: reviewerGroup, isPublic 제거 * feat: ErrorFallback 의 홈 이동 경로 변경 * refactor: endPoint.gettingReviewList 호출 인자 변경 * refactor: DetailedReviewPage에서 groupAccessCode 가져오는 방식 변경 - useRecoilValue가 아닌 useGroupAccessCode에서 가져오는 것으로 변경 * refactor: DescriptionSection에서 ReviewInfoSection으로 컴포넌트명 변경 * refactor: ErrorPage에서 Home 이동 버튼을 유도하는 방식으로 변경 - route오류 메세지와 api오류 메세지를 errorMessage 파일에서 관리하도록 수정 - ErrorPage에서 errorMessage가 ROUTE_ERROR_MESSAGE이면 홈 버튼이 화면상에서 먼저 나오도록 수정 - 색상별 홈,refresh 아이콘 SVG 추가 * refactor: useSuspenseQuery 적용 후, 무한스크롤 관련 코드 주석 처리 * feat: 리뷰 목록 페이지 목 서버 핸들러 및 목데이터 수정 * feat: 리뷰 목록 페이지에 groupAccessCode 전역 상태 적용 * refactor: 불필요한 코드 삭제 * chore: 오타 수정 --------- Co-authored-by: soosoo22 * [FE] feat: 랜딩 페이지 API 연동 (#196) * feat: 그룹 생성을 위한 엔드포인트 생성 및 리뷰 목록 엔드포인트, api 호출 함수 수정 * fix: CSSProperties 대신 커스텀 타입을 사용하던 인터페이스 수정 * feat: ReviewAccessForm에 모달 및 API 연결 * fix: 누락됐던 CopyIcon 파일 업로드 * feat: 확인 코드 입력 API 연동 * feat: 완전한 리뷰 작성 URL을 리턴하는 함수 작성 * [FE] fix: 누락됐던 버튼 리팩토링 및 엔드포인트 복구 (#199) * fix: 충돌 해결 과정에서 누락됐던 엔드포인트 복구 * fix: 버튼 리팩토링 반영 * feat: textarea에 최소 입력 글자 수를 만족하지 못한 경우 에러 메시지 표시 기능 구현 (#200) * feat: 리뷰 작성 완료 페이지에서 홈 버튼 추가 (#201) * docs: 리뷰미 소개글 작성 (#203) * [BE] refactor: 사용하지 않는 메서드 제거, 컨벤션 및 작은 리팩토링 (#206) * refactor: date를 review가 제공하도록 수정 * style: 컨벤션 적용 * refactor: 사용하지 않는 메서드 제거 * refactor: 메서드명 통일 * style: 불필요한 개행 제거 * [FE] jest 사용 시 node환경 속 msw 호환성 문제와 환경 변수 오류 해결 (#215) * ci: dependencies에서 jest 삭제 및 ts-jest 설치 * ci: jest에서 절대 경로 사용할 수 있도록 jest.config.js 추가 * chore: eslint적용 제외 파일에 jest.config.js, tsconfig.json 추가 * ci: jest의 testEnvioronment를 jsdom으로 설정 * fix: jest에서 msw ver2를 목서버로 사용 시 생기는 오류 수정 1. msw/node 를 읽지 못함 - jest.config.js의 testEnvironment 빈문자열 2. ReferentError: TextEnCoder is not defined - 해결 : jest.polyfills.js 추가 및 undici 설치 3. ReferenceError: ReadableStream is not defined - 해결 : undici 다운 그레이드 undici": "^6.19.5", -> "^5.0.0" * ci : jest에서 env 파일 읽을 수 있도록 dotenv 설치 및 jest에 적용 * [FE] 개발환경과 빌드환경에 따라 msw 실행 여부를 결정하도록 msw 실행 조건문 개선 (#221) * ci: 빌드 파일을 실행할 수 있는 http-server 패키지 설치 및 실행 명령어 추가 * fix:개발 환경과 빌드/배포 환경을 구분해 목 서버를 실행하도록 조건문 개선 * fix: 머지 충돌 시 yarn.lock 삭제한 거 복구 * [FE] 리뷰 상세페이지 query 훅 분리 및 HTTP 요청 테스트 진행 (#216) * refactor: DetailedPage/index.tsx 리팩토링 - early return를 사용해 코드의 가독성을 높임 * feat: useGetDetailedReview 훅 생성 및 DetailedReviewPageContents에 적용 * feat: useSearchParamAndQuery 훅 생성 및 DetailedPageContent에 적용 * refactor: 리뷰 상세페이지에서 id라고 사용했던 key값, params의 key를 reviewId로 변경 - DetailedReview의 router param을 id에서 reviewId로 변경 * ci: dependencies에서 jest 삭제 및 ts-jest 설치 * ci: jest에서 절대 경로 사용할 수 있도록 jest.config.js 추가 * chore: eslint적용 제외 파일에 jest.config.js, tsconfig.json 추가 * ci: jest의 testEnvioronment를 jsdom으로 설정 * refactor: useGetDetailedReview에서 query 결과를 모두 반환하는 방식으로 변경 * fix: jest에서 msw ver2를 목서버로 사용 시 생기는 오류 수정 1. msw/node 를 읽지 못함 - jest.config.js의 testEnvironment 빈문자열 2. ReferentError: TextEnCoder is not defined - 해결 : jest.polyfills.js 추가 및 undici 설치 3. ReferenceError: ReadableStream is not defined - 해결 : undici 다운 그레이드 undici": "^6.19.5", -> "^5.0.0" * ci : jest에서 env 파일 읽을 수 있도록 dotenv 설치 및 jest에 적용 * fix: mock 핸들러인 getDetailedReview 에서 중복된 쿼리 매개 변수 사용 수정 - 오류 상황: jest에서 msw 사용 시, get의 url에 파라미터 사용 시 중복된 쿼리 매개 변수 오류가 남 - 오류 메세지 ::Found a redundant usage of query parameters in the request handler - 해결: 리뷰 상세보기 페이지의 reviews까지의 url 상수를 만들고, get에서는 이 상수를 활용한 정규표현식으로 리뷰 상세보기 페이지로 오는 모든 요청을 가로챌 수 있도록 함 * refactor: getWrongDetailedReview 목서버 핸들러 및 관련 상수 삭제 - getDetailedReview에서 request를 분석해 http오류 여부를 결정함 * feat: queryClientWrapper 생성 - queryClientWrapper : msw를 사용한 jest 테스트에 queryWrapper로 사용 * test:리뷰 상세 페이지 api 요청 성공에 대한 테스트 추가 * fix: groupAccessCodeAtom의 기본값 원래대로 복구 * chore:queryClientWrapper 네이밍 표기법을 파스칼 케이스로 변경 * fix: 머지 충돌 방지를 위해 yarn.lock 삭제 * fix: 머지 시 yarn.lock 충돌 해결 * [FE] test: 리뷰 목록 페이지에 API 연동 테스트 추가 및 리팩토링 (#217) * chore: 리뷰 목록 페이지 관련 컴포넌트 및 인터페이스, 파일 이름 변경 * refactor: api 엔드포인트 상수 적용 * refactor: useGetReviewList 훅으로 분리 * test: 리뷰 목록 페이지 api 연동 테스트 추가 * chore: msw 모킹 코드 제거 * chore: 경로 index 제거 * fix: 중복된 import 및 불필요한 핸들러 제거 * chore: 엔드포인트 상수 제거 * [FE] refactor: 리뷰 작성 페이지 리팩토링 (#219) * refactor: 각 모달별로 상태와 동작을 관리하는 훅 분리 * refactor: 리뷰 작성 폼의 로직을 훅으로 분리 * refactor: 리뷰 작성 페이지에 분리한 훅 적용 * refactor: URL에서 reviewRequestCode를 추출하는 로직을 훅으로 분리 * chore: 불필요한 테스트용 코드 제거 * chore: ErrorModal을 닫을 때 errorMessage 상태도 초기값으로 변경하도록 수정 * [BE] feat: 헤더 존재 여부 검증 (#207) * fix: 인터페이스와 구현체 어노테이션 일치 * feat: 헤더 검사 어노테이션 * feat: 헤더 밸리데이터 * feat: 컨트롤러에 헤더 검사 * feat: 헤더 검증 메시지 클라이언트에 전달 * fix: 누락된 `@Valid` 어노테이션 추가 * refactor: 요청이 null인 경우 핸들링 * chore: 테스트 이름 간결하게 변경 * chore: 소문자 컨벤션 * feat: ArgumentResolver를 활용한 헤더 검증 * feat: 공용 패키지로 이동 및 범용적으로 사용할 수 있도록 수정 * chore: 사용하지 않는 import 제거 * feat: ArgumentResolver 적용, Validator 삭제 * chore: 사용하지 않는 커스텀 예외 원복 * refactor: 헤더 존재하지 않는 경우 메시지 수정 * chore: 불필요한 `@Valid` 어노테이션 삭제 * refactor: 변수 추출 Co-authored-by: Yeongseo Na * fix: 컴파일 에러 해결 --------- Co-authored-by: Yeongseo Na * ci: index.html에 구글 애널리틱스 적용 (#226) * [FE] ci: sentry 초기 설정 (#223) * ci: sentry 초기 설정 * chore: gitignore에 env.sentry-build-plugin 추가 * ci: sentry 적용 프로젝트를 woowacoure-review-me로 변경, 로컬 환경 추적하도록 변경 * ci: sentry 적용 환경을 배포 환경으로 다시 변경 * ci: sentry에서 replay 관련 설정 삭제 --------- Co-authored-by: badahertz52 * [FE] refactor: LandingPage에 리액트 쿼리 적용 및 리팩토링 (#218) * chore: LandingPage의 styles 파일 분리 * fix: POST 요청을 하는 함수의 이름을 post~로 수정 * feat: 그룹 데이터 생성 요청에 대한 MSW 핸들러 추가 * refactor: 모킹 데이터 값을 더 직관적으로 수정 * refactor: LandingPage를 ErrorSuspenseContainer가 감싸도록 수정 * refactor: URL을 얻어오는 API에 react-query 적용 및 API 호출 함수 이름 수정 * chore: LandingPage 하위 컴포넌트들의 index 파일 추가 및 적용 * refactor: groupAccessCode 관련 msw 핸들러 추가 및 에러 상태(없는 코드 입력, 서버 에러)에 따른 에러 메세지를 출력하도록 수정 * refactor: groupAccessCode에 알파벳 대소문자와 숫자만 올 수 있도록 수정 * refactor: LandingPage에서 ErrorSuspenseContainer를 제거하고 대신 URLGeneratorForm만을 감싸도록 수정 * refactor: Input 컴포넌트의 onChange 이벤트 타입 수정 * refactor: Input 컴포넌트에 name 속성 추가 * refactor: 수정된 경로 반영 * refactor: usePostDataForUrl 쿼리에서 mutation을 리턴하도록 수정 * refactor: URL을 성공적으로 생성한 이후 Input을 리셋하는 함수 추가 * chore: NOTE 주석 추가 * refactor: getIsValidGroupAccessCodeApi에서 400 외의 에러 처리를 기존의 createApiErrorMessage를 사용하도록 수정 * chore: 누락됐던 -Api suffix 반영 * [BE] Actuator 적용과 logback 설정 (#228) * build: actuator 의존성 추가 * feat: 로그백 설정 * feat: submodule 적용 - actuator 설정을 서브 모듈에 저장 * style: 개행 및 주석 제거 * test: logback 설정 추가 * [FE] CD test (#233) * ci: sentry 초기 설정 * chore: 서비스 타이틀 대문자로 변경 * chore: merge * [BE] 프로파일 분리, CD 스크립트 수정 (#235) * feat: 프로파일 분리 * feat: 프로파일 분리 * cd: 프로파일 분리 cd 스크립트 수정 * fix: CD 스크립트 오타 수정 (#236) * [FE] CD test 2 (#237) * ci: sentry 초기 설정 * chore: 서비스 타이틀 대문자로 변경 * chore: merge * fix: sentry 재설정 * fix: sentry 설정 복구 * fix: cd 스크립트 오타 수정 (#238) * [FE] CD TEST 3 (#239) * ci: sentry 초기 설정 * chore: 서비스 타이틀 대문자로 변경 * chore: merge * fix: sentry 재설정 * fix: sentry 설정 복구 * chore: 서비스 타이틀 변경 * [BE] build: Micrometer 의존성 추가 (#244) * build: 마이크로미터 의존성 추가 Co-authored-by: hyeonjilee Co-authored-by: nayonsoso * chore: 서브모듈 업데이트 Co-authored-by: hyeonjilee Co-authored-by: nayonsoso --------- Co-authored-by: hyeonjilee Co-authored-by: nayonsoso * [FE] feat: 공통 컴포넌트인 체크박스와 체크박스 아이템 제작 (#240) * feat: 공통 체크박스 컴포넌트 작성 * feat: 체크박스에 레이블을 달 수 있는 CheckboxItem 컴포넌트 작성 * fix: style을 잘못 전달하던 문제 해결 * [BE] fix: CD 스크립트 수정 (#246) * fix: CD 스크립트 수정 * chore: 저장 데이터 dev에서 활용할 수 있도록 수정 * [FE] refactor: LandingPage 경로 변경 (#249) * refactor: LandingPage의 경로 변경 * refactor: App 에서 사이드바 주석 처리 * [BE] Swagger API 문서 업데이트 (#254) * docs: 리뷰 api 문서를 위한 swagger 어노테이션 추가 * refactor: 사용하지 않는 dto 삭제 * docs: api 문서에 최소 및 최대 설정 안내 추가 * docs: 리뷰 그룹 api를 위한 swagger 어노테이션 추가 * refactor: Spring에서 제공하는 APPLICATION_JSON_VALUE 사용 * [BE] feat: 예외에 대한 로그 작성 (#255) * feat: Controller advice 에 로깅 추가 * refactor: 스택 트레이스 로깅 추가 * feat: 리뷰그룹 생성시 길이 검증 로깅 추가 * feat: 답변 길이 검증 로깅 추가 * feat: 리뷰 그룹 코드 검증 검증 로깅 추가 * feat: 리뷰 조회 검증 검증 로깅 추가 * feat: 선택된 키워드 존재하지 않는 검증 로깅 추가 * feat: 중복 선택된 키워드 검증 로깅 추가 * feat: 키워드 조회 검증 로깅 추가 * feat: 선택 키워드 갯수 검증 로깅 추가 * feat: 선택된 질문 중복 검증 로깅 추가 * feat: 질문 조회 검증 로깅 추가 * feat: 중복 질문 검증 로깅 추가 * feat: 스프링 발생 예외 로깅에 메세지 추가 * feat: 인코딩 설정 * style: 개행 수정 Co-authored-by: Donghoon Lee * style: 개행 및 공백 수정 * refactor: 불필요한 검증 제거 - 선택된 키워드와 질문이 DB에 있는지를 validator 에서 검증한 후에도, repository.getById 를 할 때 한번 더 검증이 들어간다. 따라서 'DB에 있는지'에 대한 검증을 validator 에서 할 필요는 없다는 판단 하에 해당 로직을 삭제한다. --------- Co-authored-by: Donghoon Lee * [FE] refactor: Textarea를 사용하는 공통 장문형 답변 입력 컴포넌트 분리 (#252) * feat: 공통 textarea 컴포넌트 작성 * refactor: longReviewItem의 로직을 커스텀 훅으로 분리 * refactor: longReviewItem으로 이름 변경 및 컴포넌트 구현 * chore: 기존의 ReviewItem 제거 및 리뷰 작성 페이지에 LongReviewItem 적용 * [FE] refactor: 공용 모달 훅 추가 및 LongReviewItem 리팩토링 (#258) * feat: 모달 상태 관리 훅 추가 * refactor: LongReviewItem 컴포넌트가 외부의 event를 props로 받을 수 있도록 변경 * [FE] test: LandingPage에서 사용하는 API에 대한 테스트 작성 (#251) * chore: 불필요한 Fragment 제거 * test: LandingPage에서 사용하는 api 테스트 추가 * [FE] refactor: 리뷰 작성 페이지에 react query를 적용하고 API 연동 테스트 작성 (#256) * refactor: 리뷰 작성을 위한 데이터를 불러오는 로직에 react query 적용 * refactor: 리뷰 작성 후 게시 요청 로직에 useMutation 적용 * refactor: query key 상수화 및 리뷰 작성을 위한 데이터를 불러오는 로직을 useReviewForm에서 제거 * chore: 리뷰 작성 페이지에 ErrorSuspenseContainer 적용 * chore: styled component명 수정 * chore: 리뷰 작성을 위한 mock data를 변경된 api 형식에 맞게 수정 * chore: useGetDataToWrite가 더 많은 반환값을 제공하도록 수정 * test: useGetDataToWrite의 API 요청 테스트 작성 * chore: dataToWrite가 없는 경우 상태 기본값 지정 * chore: msw를 사용하여 리뷰 생성 post 요청 mocking * chore: 리뷰 정보 mocking data 생성 * chore: useMutationReview가 UseMutationResult 타입 객체를 반환하도록 설정 * test: useMutateReview의 API 요청 테스트 작성 * chore: useGetDataToWrite의 반환값을 구조 분해 할당으로 사용할 수 있도록 수정 * chore: LongReviewItem 컴포넌트 적용 * [FE] fix: 질문 번호가 잘못 출력되는 오류 및 리뷰 작성 완료 시 잘못된 경로로 이동하는 오류 수정 (#265) * fix: 질문 번호가 잘못 출력되는 오류 수정 * fix: 리뷰 작성 완료 시 잘못된 경로로 이동되는 문제 수정 * [BE] feat: 질문 리뷰이 이름으로 치환 (#262) * chore: cd 테스트 확인 코드 작성 (#272) * fix: 상세 내용 표시할 때 리뷰이 이름 치환 (#270) * [FE] feat: 클립보드 복사 컴포넌트 구현 (#261) * feat: 클립보드 복사 컴포넌트 구현 Co-authored-by: skylar1220 * fix: 부모 요소의 너비에 따라 CopyTextButton이 작아지는 문제 해결 * refactor: ReviewGroupDataModal 스타일 조정 - 전체 크기 지정 및 gap 조정 * chore: 불필요한 padding 제거 * chore: index 파일에 CopyTextButton 추가 --------- Co-authored-by: skylar1220 * [FE] chore: front_deploy를 develop에 반영 (#276) * [FE] feat: 질문 작성 페이지 QuestionCard, ReviewWritingCard 공통 컴포넌트를 만들고, 슬라이더 방식의 기능 추가 (#247) * chore: .gitignore에서 주석 제거 * feat: QuestionCard 컴포넌트 구현 * feat: ReviewWritingCard 컴포넌트 구현 * style: CSS 속성 순서 정렬 * refactor: 제목과 내용을 각각 Header와 Main 섹션으로 분리 * feat: 슬라이더 방식으로 질문 카드가 자연스럽게 전환되는 기능 추가 * design: pretendard 폰트 적용 * chore: 헤더 review me 대문자로 변경 * refactor: QuestionCard, ReviewWritingCard 폴더 위치 변경 - ReviewWritingFormPage의 components로 이동 * [FE] 질문 작정 페이지에 필요한 질문 목 데이터 및 카드의 상태 처리 (#263) * [BE] fix: CD 스크립트 수정 (#246) * fix: CD 스크립트 수정 * chore: 저장 데이터 dev에서 활용할 수 있도록 수정 * [FE] refactor: LandingPage 경로 변경 (#249) * refactor: LandingPage의 경로 변경 * refactor: App 에서 사이드바 주석 처리 * refactor: QuestionCard, ReviewWritingCard 폴더 위치 변경 - ReviewWritingFormPage의 components로 이동 * refactor: CheckboxItem props 타입 변경 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * refactor: ReviewWritingFormPage 페이지 경로 변경 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: 리뷰 카드 질문 상수 생성 및 객관식 체크 박스 선택 해제 기능 추가 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: 객관식 최대 최소 선택 개수 기능 추가 - 최대 개수 선택 후 추가 선택을 시도하면 최대 개수 안내 문구 출력 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: 이전 버튼 비활성화 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * feat: 리뷰 카드의 답변이 유효할 때 다음 버튼 활성화 기능가 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * [BE] Swagger API 문서 업데이트 (#254) * docs: 리뷰 api 문서를 위한 swagger 어노테이션 추가 * refactor: 사용하지 않는 dto 삭제 * docs: api 문서에 최소 및 최대 설정 안내 추가 * docs: 리뷰 그룹 api를 위한 swagger 어노테이션 추가 * refactor: Spring에서 제공하는 APPLICATION_JSON_VALUE 사용 * [BE] feat: 예외에 대한 로그 작성 (#255) * feat: Controller advice 에 로깅 추가 * refactor: 스택 트레이스 로깅 추가 * feat: 리뷰그룹 생성시 길이 검증 로깅 추가 * feat: 답변 길이 검증 로깅 추가 * feat: 리뷰 그룹 코드 검증 검증 로깅 추가 * feat: 리뷰 조회 검증 검증 로깅 추가 * feat: 선택된 키워드 존재하지 않는 검증 로깅 추가 * feat: 중복 선택된 키워드 검증 로깅 추가 * feat: 키워드 조회 검증 로깅 추가 * feat: 선택 키워드 갯수 검증 로깅 추가 * feat: 선택된 질문 중복 검증 로깅 추가 * feat: 질문 조회 검증 로깅 추가 * feat: 중복 질문 검증 로깅 추가 * feat: 스프링 발생 예외 로깅에 메세지 추가 * feat: 인코딩 설정 * style: 개행 수정 Co-authored-by: Donghoon Lee * style: 개행 및 공백 수정 * refactor: 불필요한 검증 제거 - 선택된 키워드와 질문이 DB에 있는지를 validator 에서 검증한 후에도, repository.getById 를 할 때 한번 더 검증이 들어간다. 따라서 'DB에 있는지'에 대한 검증을 validator 에서 할 필요는 없다는 판단 하에 해당 로직을 삭제한다. --------- Co-authored-by: Donghoon Lee * feat: openning 질문에서 선택한 꼬리 질문 카테고리 질문 카드로 넘어가는 기능 추가 * design: 리뷰 작성 페이지 카드 스타일 변경 * [FE] refactor: Textarea를 사용하는 공통 장문형 답변 입력 컴포넌트 분리 (#252) * feat: 공통 textarea 컴포넌트 작성 * refactor: longReviewItem의 로직을 커스텀 훅으로 분리 * refactor: longReviewItem으로 이름 변경 및 컴포넌트 구현 * chore: 기존의 ReviewItem 제거 및 리뷰 작성 페이지에 LongReviewItem 적용 * [FE] refactor: 공용 모달 훅 추가 및 LongReviewItem 리팩토링 (#258) * feat: 모달 상태 관리 훅 추가 * refactor: LongReviewItem 컴포넌트가 외부의 event를 props로 받을 수 있도록 변경 * docs: ESSAY 의 가이드 라인에 리뷰이 추가 * design: QuestionCard 폰트 사이즈 변경 * feat: 리뷰 작성 카드에서 꼬리 질문 객관식 및 서술형 기능 추가 * feat : 서술형 질문에 대한 답변 기능 및 다음 단계 진행 기능 추가 * fix: isValidatedAnswer 에서 currentQuestions 없는 경우에 대한 오류 처리 * fix: 다음,제출 버튼의 styledType 수정 * fix: 리뷰 작성 카드 currentIndex=0 일 때 다음 클릭 시 다음 카드로 넘어가지 않는 오류 수정 * refactor: 스타일 props 접두사에 달러 표시 추가 * chore: 오타 수정 --------- Co-authored-by: Donghoon Lee Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> Co-authored-by: Kimprodp <145949635+Kimprodp@users.noreply.github.com> Co-authored-by: Yeongseo Na --------- Co-authored-by: soosoo22 Co-authored-by: Donghoon Lee Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> Co-authored-by: Kimprodp <145949635+Kimprodp@users.noreply.github.com> Co-authored-by: Yeongseo Na * feat: 내용 확인 체크박스에 체크해야 모달을 닫을 수 있도록 수정 및 모달 스타일 조정 (#274) * [BE] feat: 초기 엔티티 구성 (#284) * feat: 초기 엔티티 구성 * fix: SQL 예약어 수정 * [All] fix (actions): PR 내용을 저장하지 않도록 수정 (#290) * [BE] feat: 리뷰 목록 재구현 (#293) * refactor: 앤티티에 생성자 추가 * refactor: 리뷰 엔티티에 생성시간 추가 * refactor: 리뷰 목록에 보일 응답 생성 * refactor: 리뷰 목록 조회 로직 구현 * test: 테스트를 위한 레포지토리 생성 * test: 리뷰 목록 조회 테스트 추가 * style: 개행 수정 * refator: 리뷰에 카테고리 옵션 없는 경우 예외 추가 * refator: 레포지토리명 변경 * refator: 사용하지 않는 컬럼 제거 * refator: 사용하지 않는 기존 미리보기 로직 제거 후 대체 * style: 개행 수정 * refactor: 버전 관리를 위해 기존 버전, v2 분리 * style: 개행 수정 * [FE] chore: topbar에서 사용되지 않는 검색창 및 프로필 사진 숨김 처리 (#281) * chore: topbar에서 사용되지 않는 검색창 및 프로필 사진 숨김 처리 * chore: topbar에서 캐릭터 이미지가 보이지 않도록 수정 * [FE] refactor: 리뷰 작성 페이지에 useModals 훅 적용 (#282) * chore: 코드가 중복되는 각 모달 훅 제거 * refactor: 리뷰 작성 페이지에 useModals 적용 * [FE] refactor: 리뷰 목록 페이지에서 변경된 API 연동 (#288) * refactor: 새로운 API 구조에 맞춰 ReviewCard 컴포넌트 및 타입 수정 * refactor: 새로운 API 구조에 맞춰 ReviewList Mock Data 수정 * refactor: revieweeName 뒤에 붙는 메시지 상수 처리 * design: ReviewListPage CSS 수정 * chore: ReviewList Mock Data 프로젝트명 변경 * refactor: 불필요한 코드 제거 * refactor: useGetReviewList의 데이터 변수명을 ReviewListData로 변경 * chore: useGetReviewList를 review 폴더로 이동 * chore: 검색창 주석 처리 * [FE] feature: 제출할 리뷰를 한 번에 볼 수 있는 preview 모달 제작 (#302) * refactor: Checkbox 컴포넌트를 readonly로 사용할 수 있도록 isReadonly 속성 추가 * refactor: Checkbox 컴포넌트에 rest props 적용 * chore: 프리뷰 모달을 표시하기 위한 컴포넌트 복사 (로컬 시연용 코드 복사) * feat: ContentModal 제작 * chore: 누락된 컴포넌트 스타일 업로드 * chore: 프리뷰 모달 출력을 위한 모킹 데이터 및 타입 추가 * feat: 작성한 모든 항목 프리뷰 모달 작성 * chore: 프리뷰 모달 확인용 임시 페이지 작성 * chore: 인터페이스의 중복 속성 제거 * refactor: Content 모달 스크롤바 부활 * refactor: 스타일 및 그에 따른 레이아웃 수정 * [FE] refactor: 리뷰 상세페이지에서 변경된 API를 연동 (#297) * refactor: 새로운 API 구조에 맞춰 ReviewCard 컴포넌트 및 타입 수정 * refactor: 새로운 API 구조에 맞춰 ReviewList Mock Data 수정 * refactor: revieweeName 뒤에 붙는 메시지 상수 처리 * design: ReviewListPage CSS 수정 * chore: ReviewList Mock Data 프로젝트명 변경 * refactor: 불필요한 코드 제거 * refactor: useGetReviewList의 데이터 변수명을 ReviewListData로 변경 * chore: useGetReviewList를 review 폴더로 이동 * refactor: 상세페이지에서 변경된 API에 맞게 인터페이스 및 코드 변경 * refactor: 변경된 API에 맞게 목 데이터 수정 및 createdAt 추가 * design: 상세페이지 전반적인 디자인 수정 * refactor: new Date() 추가 * [BE] feat: 리뷰 단건 조회 API 업데이트 (#294) * feat: 응답 객체 추가 * feat: 객체 생성자, EqualsAndHashCode, 중간 테이블 설정 수정 * feat: 선택 조건에 따른 조회 가시성 기능 * feat: 템플릿, 섹션 저장소, 섹션 순서대로 불러오기 * feat: 질문 저장소에서 순서대로 질문 불러오기 * feat: 옵션 저장소, 선택 항목을 리뷰/질문으로 필터링 * style: apply code convention * feat: 서술형 질문 일급 컬렉션 * feat: `CollectionTable` 삭제, `OneToMany` + `JoinColumn` 사용 * feat: 리뷰 단건 조회 * refactor: 클래스명 명확하게 수정 * feat: API 연동, V2로 적용 * feat: 만들어진 시각 추가 * style: apply code convention * refactor: 예외 수정 * refactor: 쿼리 multiline string으로 수정 * refactor: 예외명 명확하게 수정 * refactor: 클래스명 통일 * refactor: CollectionTable를 삭제하고 다대일 연관관계 수정을 적용 --------- Co-authored-by: hyeonjilee * [FE] 새로운 리뷰 작성 정보 API를 반영하기 위한 리뷰 작성 페이지의 카드 상태 처리 변경 (#299) * docs: 리뷰 작성 카드 폼의 데이터 타입과 목 데이터 생성 * refactor: pages 폴더 경로 간결하게 리팩토링 * chore: 3차 데모데이 시연 중 추가한 cd 관련 코드 사항 삭제 * feat: QnABox의 객관식 문답 기능 추가 * feat: 리뷰 작성 답변 타입 추가 및 QnABox에 유효한 답변 시 답변을 콘솔에 찍는 기능 추가 - 추후에 답변 상태를 업데이트하는 방향으로 변경할 예정 * feat: ReviewWritingCardFormPage , ReviewWritingCardFormPage/ReviewWritingCard 생성 - 답변들이 유효할 경우 다음 버튼 활성화 - 이전, 다음 버튼 클릭 시 캐러셀 작동 - review-wrting-form 경로의 element를 ReviewWritingCardFormPage 으로 변경 * refactor: currentCardIndex 훅으로 분리 * refactor: slideWidth 훅으로 분리 * refactor: answerMap 관련 코드 훅으로 분리 * refactor: hooks/review 에 index.ts 추가해서 import 코드 리팩토링 * refactor: CardSliderController 컴포넌트 생성 - 카드 넘기기, 제출, 미리보기등의 버튼을 담는 CardSliderController 합성 컴포넌트 생성 및 적용 * refactor: components/common/index.tsx에 export 추가 * feat: useMultipleChoice, useTextAnswer 훅 분리 - QnABox의 객관식 문항 관련 코드는 useMultipleChoice, 서술형 관련 코드는 useTextAnswer 훅으로 분리 - upatedAnswerMap -> upatedAnswerMap 네이밍 변경 * feat: 카테고리 질문 선택에 따라 questionList를 바꾸는 useQuestionList 훅 생성 * fix: 서술형에서 currentCardIndex가 변했을때에도 isAlbeNextStep 판단하도록 수정 * feat : 객관식, 서술형의 답변의 유효성에 따라 answerMap의 값을 변경하도록 수 - 객관식, 서술형에서 유효한 답일 경우에난 answerMap (서버에 제출되는 답변)에 유효한 값이 업데이트 되고 그렇지 않으면 (객관식:selectedOptionIds , 서술형:text) null로 변경 - 다음 버튼 활성화 여부는 answerMap 에서 현재 보여지는 card에 속하는 해당 answer의 유효한 답변이 있는지 여부만 검사 * refactor: useQuestionList 리팩토링 * fix: 카드 추가 시 slideContainer높이가 늘어나는 오류 수정 - useSlideWidth -> useSlideWidthAndHeight로 변경 - 현재 보여주는 slide의 높이에 따라 slideContainer의 높이가 결정되게 함 * style: QnABox, ReviewWritingCard 스타일 적용 * feat: 필수 질문 마크 추가 * fix; REVIEW_WRITING_FORM_CARD_DATA 의 questionId 중복 수정 * refactor: ReviewWritingCardProps 리팩토링 -. CardSliderControllerProps를 확장해서 중복되는 타입 선언을 피할 수 있도록 함 * feat: ReviewWritingCardFromPag에 제출, 미리보기 버튼의 클릭 이벤트 핸들러 추가 * refactor: ReviewWritingFormPage 삭제 * refactor:ReviewWritingCardFormPage에 ErrorSuspenseContainer 적용 - 기존의 ReviewWritingCardFormPage의 내용을 CardForm으로 이동 * feat: 프로젝트, 리뷰이 관련 내용을 CardForm에 추가 * chore: 제출 버튼 타입 변경 * feat: 객관식 문항 선택 개수에 대한 안내 문구 추가 * design: 객관식 문항 최대 개수 넘을 때 안내 문구 스타일 변경 * fix: SliderContainer 의 border를 Slide의 border로 변경 * refactor: hooks/review/iondex.ts에서 useGetReviewList export * [BE] feat: 변경된 도메인 구조가 적용된 리뷰 작성 API 구현 (#296) * feat: 리뷰 작성 요청 dto 생성 * feat: 리뷰 레포지토리 생성 * feat: 템플릿 레포지토리 생성 및 기본 함수 등록 * feat: 섹션 레포지토리 생성 및 기본 함수 등록 * feat: 질문 레포지토리 생성 및 기본 함수 등록 * feat: 옵션 아이템 레포지토리 생성 및 기본 함수 등록 * feat: 옵션 그룹 레포지토리 생성 및 기본 함수 등록 * feat: 엔티티에 생성자 추가 * feat: templateId 를 필요한 곳에 추가 * refactor: 레포지토리 이름 변경 - 명시적이게 Question2 로 변경 * feat: 리뷰 저장 검증 실패시 발생시킬 예외 생성 * feat: 리뷰 저장 검증 시 필요한 레포지토리 함수 생성 * feat: 선택형 대답 검증기 생성 * feat: 서술형 대답 검증기 생성 * feat: 리뷰 저장 기능 구현 * test: 서술형 응답 검증 테스트 * feat: 필수 질문 검증 기능 구현 * test: 텍스트 필수 질문 검증 테스트 추가 * feat: OptionGroup에 생성자 추가 * feat: 선택지 갯수 검증 기능 추가 * test: 선택 문항 검증 테스트 추가 * refactor: 컨트롤러에서 호출하는 서비스 변경 * refactor: 리뷰 저장 함수에 Transactional 추가 * feat: 테스트 코드 작성을 위해 필요한 생성자, 레포지토리 추가 * test: 리뷰 작성 서비스 테스트 * style: 개행 및 코드 재정렬 * refactor: 없는 선택지 제출 시 발생하는 예외 로그 보강 * refactor: validator 어노테이션 수정 - Service -> Component * refactor: 예외 클래스 이름 수정 * refactor: 예외 클래스 이름 수정 * refactor: 예외 로그 보강 * style: 개행 통일 * refactor: api 버전 구분 * style: 논리적 개행 * refactor: 예외 클래스 이름 변경 * refactor: 예외 메세지 수정 * refactor: DB 존재 검증 로직 위치 변경 - private 함수를 호출하지 사용하지 않고, repository 함수를 바로 호출하도록 변경 * refactor: OptionItem, OptionGroup 레포지토리 통합 - question 과 template 패키지에 각각 존재하던 것을 question 패키지로 통합 * style: 주석 삭제 * fix: 컴파일 에러 해결 - merge 후 발생하는 컨플릭을 해결한 뒤 발생한 컴파일 에러를 해결한다. * [BE] feat: 리뷰 폼 응답 재구현 (#295) * feat: TemplateController 추가 및 리뷰 템플릿 응답 기능 구현 * feat: 리뷰 템플릿 응답을 위한 dto 생성 * refactor: 에러 응답 메세지 변경 * refactor: onSelectedOptionId 타입 변경 * refactor: 도메인 id 제외 생성자 추가 * feat: TemplateService 추가 및 기본 템플릿 응답 기능 추가 * feat: TemplateMapper 구현 * refactor: mapping url 중복으로 인한 context 로딩 에러 해결을 위해 임시적으로 url 경로 변경 * refactor: 로깅 레벨 warn으로 변경 * refactor: 질문에 옵션 그룹이 없을 경우 null을 사용하도록 변경 * refactor: id 리스트를 즉시 로딩할 수 있도록 변경 * refactor: 메서드명 변경 * test: 테스트 추가 * test: DatabaseCleaner에서 CollectionTable로 생성된 테이블 초기화 하도록 변경 * refactor: Transactional readOnly 설정 * refactor: 컨트롤러 요청 경로 변경 * refactor: response에서 null 가능한 필드 명시 * style: 오타 수정 * refactor: Question2 hasGuideline 메서드에서 guideline이 null 인 경우도 추가 * refactor: 기본 사용 템플릿을 찾을 수 없는 경우에 대한 예외명과 로그 메세지 수정 * refactor: 옵션 그룹에 옵션 아이템이 없는 경우에 대한 예외명 수정 * refactor: SectionNotFoundException의 로그 레벨 수정 (info -> warn) * refactor: 사용하지 않는 메서드 삭제 * test: 가이드라인이 제공되지 않는 경우 테스트 추가 * test: 옵션 그룹에 없는 질문이 없는 경우 테스트 추가 * test: 섹션의 선택된 옵션이 필요없는 경우 테스트 추가 * refactor: long 타입 변경 * [BE] feat: 초기 데이터 세팅 (#311) * feat: 초기 데이터 세팅 * feat: 오타 수정 및 최대 갯수 반영 * feat: 리뷰어, 가이드라인 및 헤더에서 텍스트 대치 (임시) * refactor: 상수 접근 제어자 수정 --------- Co-authored-by: donghoony * feat: v2 엔드포인트 추가 (#316) * [BE] refactor: 리뷰 상세보기에서 가이드라인 관련 필드 삭제 (#310) * [FE] 리뷰 작성 페이지에 변경된 리뷰 작성 페이지의 API 반영 (#317) * feat: 리뷰 작성 카드 폼에서 리뷰 작성을 위한 데이터 받아오는 api 훅, 목 핸들러 생성 - ReviewWritingCardFormPage 의 path 경로 변경 - 리뷰 작성 카드 폼에서 리뷰 작성을 위한 데이터 받아오는 api 훅, 목 핸들러 생성 - gettingDataToWritingReview 의 endpoint 변경 * feat: 리뷰 작성 카드 폼에 제출 모달, 오류 모달, 미리보기 모달 적용 및 AnswerListPrevieModal 수정 * refactor: 불필요한 파일 삭제 및 mockData 관련 경로 정리 * feat: 첫 번째 카드에서 이전 버튼 감추기 * refactor: 미리보기를 '제출 전 확인'으로 수정 - AnswerListPreviewModal -> AnswerListRecheckModal - PreviewButton -> RecheckButton * chore: api base url 변경에 따른 endpoint, handler 수정 * chore: 오타 수정 * [BE] docs: Swagger API 문서 업데이트 (#313) * refactor: dto 생성 로직의 에러 수정 * docs: 리뷰 폼 응답 dto에 Swagger 어노테이션 추가 * docs: 리뷰 폼 응답 컨트롤러에 Swagger 어노테이션 추가 * docs: swagger examples 제외 * docs: 리뷰 쓰기 요청 dto에 Swagger 어노테이션 추가 * docs: 리뷰 쓰기 요청 컨트롤러에 Swagger 어노테이션 추가 * docs: api 문서 버전 표시 * docs: TemplateResponse dto api description 명칭 통일 * docs: 받은 리뷰 목록 조회 응답 dto에 Swagger 어노테이션 추가 * docs: 받은 리뷰 목록 조회 응답 컨트롤러 Swagger 어노테이션 추가 * docs: 리뷰 상세 조회 조회 응답 dto Swagger 어노테이션 추가 * docs: 리뷰 상세 조회 조회 응답 컨트롤러 Swagger 어노테이션 추가 * [FE] feature: 전역 스타일에 커스텀 스크롤바 스타일 적용 (#318) * refactor: ContentModal에서 Content 컴포넌트에 적용됐던 스타일 제거 * feat: 전역 스타일에 커스텀 스크롤바 적용 * chore: 불필요한 전역 overflow-y 제거 * [BE] refactor: 리뷰 작성 시 네이티브 쿼리를 사용 (#308) * refactor: 답변 질문 목록이 템플릿에 포함되는지 여부를 검사할 때 네이티브 쿼리 사용 * refactor: QuestionRepository2로 통일 * style: apply code convention * refactor: 파라미터 `long`으로 수정 * [FE] refactor: 리뷰 상세 페이지 url path 관련 코드 수정 및 리팩터링 (#321) * merge * chore: 검색창 관련 코드 주석 처리 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * refactor: memberId 코드 제거 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * refactor: key 값을 questionId로 수정 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: 불필요한 코드 제거 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> * chore: 상수 대문자로 변경 Co-Authored-By: badahertz52 Co-Authored-By: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-Authored-By: Fe <64690761+chysis@users.noreply.github.com> --------- Co-authored-by: badahertz52 Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> * [FE] Fix: 리뷰 작성 페이지 API 관련 오류를 수정한다. (#324) * fix: 리뷰 작성 페이지에서 api 가 아닌 목 데이터를 받아오고 있는 것 수정 * fix: 리뷰 작성 페이지에서 제출 실패 시에도 navigate 작동하는 오류 수정 * Update index.tsx * chore: ReviewWriting 폴더 삭제 * [BE] 리뷰 단건 조회 시, 리뷰이 이름으로 치환되지 않는 오류 수정 (#327) * refactor: 리뷰 상세 조회 시, 리뷰이 이름 치환 적용 * style: 파라미터 순서 변경 * fix: URL 생성 모달에서 체크박스가 선택되지 않은 경우 닫기 버튼을 disabled로 수정 (#329) * [FE] fix : 리뷰 작성 페이지에서 유효하지 않은 답변 오류 수정 (#333) * fix: 리뷰 작성 페이지에서 api 가 아닌 목 데이터를 받아오고 있는 것 수정 * fix: 리뷰 작성 페이지에서 제출 실패 시에도 navigate 작동하는 오류 수정 * Update index.tsx * chore: ReviewWriting 폴더 삭제 * fix: 유효하지 않은 답변들을 빈문자열, 빈배열로 수정 * [FE] refactor: URLGeneratorForm에 useModals 훅 적용 (#331) * refactor: URLGeneratorForm에 useModals 훅 적용 * fix: Checkbox change 이벤트 핸들러의 이름을 규칙에 맞게 handleChange로 수정 * [FE] fix : 필수가 아닌 객관식, 서술형에서 답변 작성이 들어갈 경우에도 유효성 검사를 진행 (#339) * fix: 리뷰 작성 페이지에서 api 가 아닌 목 데이터를 받아오고 있는 것 수정 * fix: 리뷰 작성 페이지에서 제출 실패 시에도 navigate 작동하는 오류 수정 * Update index.tsx * chore: ReviewWriting 폴더 삭제 * feat: 제출 버튼에 활성화 기능 추가 * feat: 답변들에 대한 유효성여부를 담는 상태, 이를 핸들링 하는 함수 추가 - answerValidationMap, updateAnswerValidationMap 생성 - answerValidationMap을 업데이트할떼, useMultiple, useTextAnswer에서 필수가 아닌 답변이면서 서술형의 경우 빈문자열, 객관식의 경우 빈배열일때 유효성을 true로 설정 - useReviewAnswer에서 isValidateAnswerList에서 answerValidation을 사용 * [FE] fix : 필수가 아닌 답변을 작성하려 하지 않았을 경우에 대한 오류 수정 (#342) * fix : answerMap, answerValidationMap 의 기본값 설정 * fix: 필수 아닌 답변에서 문자 개수 유효하지 않게 될 때 버튼 비활성화 오류 수정 * [FE] refactor: 리뷰 상세 페이지 QuestionData 인터페이스에서 hasGuideline, guideline 속성 제거 (#340) * refactor:QuestionData 인터페이스에서 hasGuideline, guideline 속성 제거 * chore: 불필요한 주석 코드 제거 * [FE] refactor: 사용자 테스트에서 피드백 받은 사소한 버그 수정 (#345) * design: 버튼에서 드래그 불가능하도록 reset css 수정 * chore: 전체 선택 가능한 객관식 질문의 안내 문구를 '최소 O개 이상'의 형태로 수정 * fix: 필수가 아닌 서술형 문항에서 아무것도 쓰지 않았을 때 focus를 잃어도 에러 UI가 보여지지 않도록 수정 * chore: 서술형 문항의 최대/최소 글자수를 각각 placeholder와 안내 문구로 명시 * chore: 바뀐 property명에 맞게 수정 * fix: property명 수정 이후 체크박스 선택이 안 되는 문제 해결(롤백) * chore: 사용하지 않는 햄버거 아이콘 숨김 처리 * chore: 로고 클릭 시 랜딩 페이지로 이동하도록 수정 * chore: MultilineText에 text wrap 속성 적용 * chore: placeholder에 최소 글자수 명시 * [FE] refactor: LandingPage에서 사용하는 Input 유효성 검사 함수 분리 및 테스트 작성 (#349) * feat: Input 유효성 검증을 위한 유틸 함수 추가 * test: Input 유효성 검증 유틸리티 함수에 대한 테스트 작성 * test: 잘못된 반환값을 체크하던 테스트 수정 * refactor: sourcemap 관련 보안 설정 추가 (#347) * [BE] fix: 쿼리 문법 수정 (#358) * feat: 이름, 프로젝트명에 대한 유효성 검사 및 에러 메세지 안내 추가 (#351) * [FE] fix: Checkbox의 onChange 이벤트 핸들러가 전달되지 않는 버그 수정 (#367) * fix: onChange 이벤트 핸들러가 전달되고 있지 않던 버그 수정 * fix: 누락됐던 isDisabled 속성 전달 * [BE] refactor: v1 API를 삭제, 엔티티 통합 및 사용하지 않는 클래스 제거 (#343) * refactor: v1 endpoint 제거 * refactor: 키워드 제거, 리뷰 관련 테스트 정리 * refactor: v1 질문 클래스 제거 * refactor: 키워드 제거, 사용하지 않는 dto 정리 * refactor: Question2, Review2에서 `2` 제거 * refactor: QuestionRepository question 패키지로 이동 * refactor: ReviewRepository 통합 * refactor: Dto 패키지 Service 하위로 이동 * style: sql 개행 적용 * refactor: 사용하지 않는 변수 제거 * refactor: v2 interface 수정 * fix: Pretendard 폰트가 정상적으로 적용되도록 나눔고딕 제거 (#365) * [FE] refactor,fix : 리뷰 작성 페이지 디자인 버그 수정 및 상태 관리 리팩토링 (#369) * chore: 깃허브 로고 주석 처리 * fix: Checkbox 의 onChange 오류 수정 - 오류 : handleChange로 props명이 변경되면서 구조분해할당으로 input에 onChange 이벤트가 들어가지 않게 됨 - 오류 수정; onChange에 handleChange를 직접 넣어주는 방식으로 수정 * chore: 서술형 질문에 글자 수 안내 문구 삭제, 선택 문항에 '(선택)' 문구 추 * refactor: multipleGuidline을 useMultipleChoice로 이동 * refactor: useMutateReview의 onSuccess에서 리뷰 생성 후 기능 실행하도록 수정 - mutate 성공 시, 실행할 executeAfterMutateSuccess를 useMutateReview의 props로 추가 - 리뷰 제출 성공 시, 모달 닫고 페이지 이동하는 코드를 onSuccess 에서 실행하도록 수정 * refactor: useMutateReview의 onSuccess에서 리뷰 생성 후 기능 실행하도록 수정 - mutate 성공 시, 실행할 executeAfterMutateSuccess를 useMutateReview의 props로 추가 - 리뷰 제출 성공 시, 모달 닫고 페이지 이동하는 코드를 onSuccess 에서 실행하도록 수정 * fix: Checkbox props명 오류 수정 - isDisabled -> disabled * refactor : 리뷰 작성 질문들을 recoil로 관리하도록 리팩토링 - 컴포넌트간 props drilling을 줄이기 위해 recoil 상태관리를 사용 - 카테고리 선택 결과에 따라 리뷰 작성 질문지(questionList)가 동적으로 변할 수 있도록 atom, selector를 사용 * refactor: answerMap, answerValidationMap을 atom으로 변경 * refactor: useReviewAnswer를 3개의 훅으로 분리 - 분리되어 생성된 훅 :useUpdateReviewerAnswer, useUpdateDefaultAnswers, useCheckStepAvailability - QnABox: props 변경 및 useUpdateReviewerAnswer 사용 - RevieWritingCard : props 변경 및 useCheckNextStepAvailability 사용 * feat: 리뷰 작성 폼 관련 recoil 상태 초기화하는 훅 생성 및 적용 * fix: 리뷰 작성 페이지 목 데이터에서 optionId 중복 수정 * chore: 코드 설명에 대한 주석 추가 * refactor: useUpdateDefaultAnswers 에서 객관식/서술형 기본값 상수 처리 * fix: 서술형에서 길에 한문장으로 작성 시 맥에서 가로 스크롤이 생기는 오류 수정 * fix : ReviewWritingFrom의 formId 타입 변경 (string-> number) * chore: REVIEW_WRITING_FORM_CARD_DATA 변경 * refactor: 훅 , selector 네이밍 변경 - questionList -> cardSectionList * refactor: useMultipleChoice 리팩토링 - useAboveSelectionLimit , useUpdateMultipleChoiceAnswer로 분리 * feat :기존에 답변한 카테고리를 카테고리 질문에서 해제할 때에 대한 대응 추가 - useCancelAnsweredCategory 추가 - 카테고리 질문에서 이미 답변을 작성한 카테고리 선택을 해제하는 지 판단 - 해제하려는 경우, answerMap, answerValidationMap의 상태 변경을 하지 않고 모달을 띄워줌 - 모달에서 확인 버튼 클릭하면, 해제되고 취소되면 기존의 선택이 유지 * refactor: ReviewWritingCardForm하위 컴포넌트 파일들을 components 폴더로 이동 * feat: QnABox에 답변한 카테고리 해제 시, 관련 기능 적용 - 리뷰 작성 페이지의 다른 모달들과 같은 컴포넌트에 위치하면 좋겠으나, 확인 버튼 클릭 시 사용자가 해제하려했던 카테고리 선택이 해제되어야해서 컴포넌트 구조상 QnABox에 관련 모달이 있어야한다고 판단함 * refactor: QnABox에서 MultipleChoiceQuestion 분리 * fix: 개수 제한 가이드 라인 표시 이전에 선택 오류 수정 개수 제한 가이드 라인 표시 이전에 최대 개수를 넘는 선택 시, 체크박스 선택되는 오류 수정 * chore: 필요없는 코드 삭제 * fix: 답변이 없는 카테고리 선택 취소 시, 모달 띄워지는 오류 수정 * fix: handleModalOpen 파라미터 오류 수정 * fix: 답변이 없는 카테고리 선택 취소 시 답변 기본값에 의한 오류 수정 - 답변의 기본값이 빈문자열 이나 빈 배열이여서 답변을 하지 않았음에도 모달이 띄워지는 오류가 있었음 * refactor: useTextAnswer에서 useUpdateReviewerAnswer사용 * design: 프로젝트 이름, 리뷰이의 이름이 길어지는 경우를 대비한 디자인 수정 * feat: 작성 내용 확인 버튼에 비활성화 기능 추가 - 모든 답변이 제출 가능한 상태여야 리뷰 작성 확인 버튼도 비활성화 됨 - 버튼의 text를 작성 내용 확인 버튼으로 수정 * fix: Checkbox 의 onChange 오류 수정 - 오류 : handleChange로 props명이 변경되면서 구조분해할당으로 input에 onChange 이벤트가 들어가지 않게 됨 - 오류 수정; onChange에 handleChange를 직접 넣어주는 방식으로 수정 * chore: 서술형 질문에 글자 수 안내 문구 삭제, 선택 문항에 '(선택)' 문구 추 * fix: Checkbox 의 onChange 오류 수정 - 오류 : handleChange로 props명이 변경되면서 구조분해할당으로 input에 onChange 이벤트가 들어가지 않게 됨 - 오류 수정; onChange에 handleChange를 직접 넣어주는 방식으로 수정 * fix: 서술형에서 길에 한문장으로 작성 시 맥에서 가로 스크롤이 생기는 오류 수정 * design: 프로젝트 이름, 리뷰이의 이름이 길어지는 경우를 대비한 디자인 수정 * chore: Checkbox 변경으로 인한 코드 수정 * [BE] infra: cd 스크립트 dev와 prod 분리 (#352) * docs: cd 스크립트 dev와 prod 분리 * docs: prod cd 스크립트에서 prod용 설정 파일 적용 * docs: cd 스크립트 name 변경 * [FE] refactor: 리뷰 작성 페이지에 form 태그 도입 및 submit 버튼 변경 (#374) * chore: 깃허브 로고 주석 처리 * fix: Checkbox 의 onChange 오류 수정 - 오류 : handleChange로 props명이 변경되면서 구조분해할당으로 input에 onChange 이벤트가 들어가지 않게 됨 - 오류 수정; onChange에 handleChange를 직접 넣어주는 방식으로 수정 * chore: 서술형 질문에 글자 수 안내 문구 삭제, 선택 문항에 '(선택)' 문구 추 * refactor: multipleGuidline을 useMultipleChoice로 이동 * refactor: useMutateReview의 onSuccess에서 리뷰 생성 후 기능 실행하도록 수정 - mutate 성공 시, 실행할 executeAfterMutateSuccess를 useMutateReview의 props로 추가 - 리뷰 제출 성공 시, 모달 닫고 페이지 이동하는 코드를 onSuccess 에서 실행하도록 수정 * refactor: useMutateReview의 onSuccess에서 리뷰 생성 후 기능 실행하도록 수정 - mutate 성공 시, 실행할 executeAfterMutateSuccess를 useMutateReview의 props로 추가 - 리뷰 제출 성공 시, 모달 닫고 페이지 이동하는 코드를 onSuccess 에서 실행하도록 수정 * fix: Checkbox props명 오류 수정 - isDisabled -> disabled * refactor : 리뷰 작성 질문들을 recoil로 관리하도록 리팩토링 - 컴포넌트간 props drilling을 줄이기 위해 recoil 상태관리를 사용 - 카테고리 선택 결과에 따라 리뷰 작성 질문지(questionList)가 동적으로 변할 수 있도록 atom, selector를 사용 * refactor: answerMap, answerValidationMap을 atom으로 변경 * refactor: useReviewAnswer를 3개의 훅으로 분리 - 분리되어 생성된 훅 :useUpdateReviewerAnswer, useUpdateDefaultAnswers, useCheckStepAvailability - QnABox: props 변경 및 useUpdateReviewerAnswer 사용 - RevieWritingCard : props 변경 및 useCheckNextStepAvailability 사용 * feat: 리뷰 작성 폼 관련 recoil 상태 초기화하는 훅 생성 및 적용 * fix: 리뷰 작성 페이지 목 데이터에서 optionId 중복 수정 * chore: 코드 설명에 대한 주석 추가 * refactor: useUpdateDefaultAnswers 에서 객관식/서술형 기본값 상수 처리 * fix: 서술형에서 길에 한문장으로 작성 시 맥에서 가로 스크롤이 생기는 오류 수정 * fix : ReviewWritingFrom의 formId 타입 변경 (string-> number) * chore: REVIEW_WRITING_FORM_CARD_DATA 변경 * refactor: 훅 , selector 네이밍 변경 - questionList -> cardSectionList * refactor: useMultipleChoice 리팩토링 - useAboveSelectionLimit , useUpdateMultipleChoiceAnswer로 분리 * feat :기존에 답변한 카테고리를 카테고리 질문에서 해제할 때에 대한 대응 추가 - useCancelAnsweredCategory 추가 - 카테고리 질문에서 이미 답변을 작성한 카테고리 선택을 해제하는 지 판단 - 해제하려는 경우, answerMap, answerValidationMap의 상태 변경을 하지 않고 모달을 띄워줌 - 모달에서 확인 버튼 클릭하면, 해제되고 취소되면 기존의 선택이 유지 * refactor: ReviewWritingCardForm하위 컴포넌트 파일들을 components 폴더로 이동 * feat: QnABox에 답변한 카테고리 해제 시, 관련 기능 적용 - 리뷰 작성 페이지의 다른 모달들과 같은 컴포넌트에 위치하면 좋겠으나, 확인 버튼 클릭 시 사용자가 해제하려했던 카테고리 선택이 해제되어야해서 컴포넌트 구조상 QnABox에 관련 모달이 있어야한다고 판단함 * refactor: QnABox에서 MultipleChoiceQuestion 분리 * fix: 개수 제한 가이드 라인 표시 이전에 선택 오류 수정 개수 제한 가이드 라인 표시 이전에 최대 개수를 넘는 선택 시, 체크박스 선택되는 오류 수정 * chore: 필요없는 코드 삭제 * fix: 답변이 없는 카테고리 선택 취소 시, 모달 띄워지는 오류 수정 * fix: handleModalOpen 파라미터 오류 수정 * fix: 답변이 없는 카테고리 선택 취소 시 답변 기본값에 의한 오류 수정 - 답변의 기본값이 빈문자열 이나 빈 배열이여서 답변을 하지 않았음에도 모달이 띄워지는 오류가 있었음 * refactor: useTextAnswer에서 useUpdateReviewerAnswer사용 * design: 프로젝트 이름, 리뷰이의 이름이 길어지는 경우를 대비한 디자인 수정 * feat: 작성 내용 확인 버튼에 비활성화 기능 추가 - 모든 답변이 제출 가능한 상태여야 리뷰 작성 확인 버튼도 비활성화 됨 - 버튼의 text를 작성 내용 확인 버튼으로 수정 * fix: Checkbox 의 onChange 오류 수정 - 오류 : handleChange로 props명이 변경되면서 구조분해할당으로 input에 onChange 이벤트가 들어가지 않게 됨 - 오류 수정; onChange에 handleChange를 직접 넣어주는 방식으로 수정 * chore: 서술형 질문에 글자 수 안내 문구 삭제, 선택 문항에 '(선택)' 문구 추 * fix: Checkbox 의 onChange 오류 수정 - 오류 : handleChange로 props명이 변경되면서 구조분해할당으로 input에 onChange 이벤트가 들어가지 않게 됨 - 오류 수정; onChange에 handleChange를 직접 넣어주는 방식으로 수정 * fix: 서술형에서 길에 한문장으로 작성 시 맥에서 가로 스크롤이 생기는 오류 수정 * design: 프로젝트 이름, 리뷰이의 이름이 길어지는 경우를 대비한 디자인 수정 * chore: Checkbox 변경으로 인한 코드 수정 * refactor: form 태그 ,submit 변경 및 ConfirmModal의 props 변경 * refactor: ConfirmModal의 type 의 기본값 삭제 * docs: cd 스크립트 name 변경 (#388) * [BE] refactor: `Random`을 `ThreadLocalRandom`으로 변경 (#387) * [FE] feat: 좌측 상단에 경로를 표시하는 Breadcrumb 컴포넌트 구현 (#372) * fix: Pretendard 폰트가 정상적으로 적용되도록 나눔고딕 제거 * chore: 오른쪽 화살표 이미지 추가 * feat: 경로 표시를 위한 Breadcrumb 컴포넌트 구현 * refactor: rightArrow 이미지를 '/'로 변경 * feat: 리뷰 목록 페이지에 Breadcrumb 컴포넌트 추가 * style: css 속성 순서 변경 * refactor: 경로 반환 로직을 useBreadcrumbPaths 훅으로 분리 * refactor: Breadcrumb 컴포넌트 렌더링을 App.tsx로 이동 * feat: 작성 완료 페이지 경로 추가 * refactor: path가 number, string일 경우를 명확히 구분하여 처리 * chore: 코드 컨벤션에 맞춰 배열 이름을 List로 변경 * refactor: 각 페이지별 경로 상수 처리 * refactor: 객체 키를 모두 소문자로 변경 * refactor: 경로 상수를 src/index.tsx에도 적용 * refactor: 카멜 케이스로 변경 후 적용 * fix: Pretendard font-family 수정 (#386) * [FE] refactor: 랜딩 페이지 리팩토링 및 서비스 소개 section 제작 (#371) * chore: LandingPage를 HomePage로 변경 * refactor: 새 HomePage의 Form 부분 퍼블리싱 * refactor: 문자열 길이 검증 함수에서 최소 길이도 검증할 수 있도록 변경 * test: 변경된 문자열 길이 검증 함수에 대한 테스트 추가 * refactor: 컴포넌트에 비밀번호 유효성 검사 추가 * refactor: ReviewGroupDataModal 스타일 조정 * refactor: URL을 안내하는 모달 이름 수정 * feat: 리뷰미 소개 페이지 1차 퍼블리싱 * chore: 불필요한 페이지 삭제 * refactor: 스타일 수정 * refactor: 폼 멘트 수정 * feat: 입력한 비밀번호를 확인할 수 있는 EyeButton 컴포넌트 작성 * refactor: URLGeneratorForm에 EyeButton 적용 * refactor: EyeButton 위치 수정 * chore: 불필요한 import 제거 * fix: EyeButton 위치 고정 * refactor: 소개 페이지의 화살표 위치 조정 * fix: EyeButton 위치 에러 수정 Co-authored-by: badahertz52 * refactor: EyeButton 크기 조정 * chore: 사용하지 않는 파일 삭제 * fix: h3 삭제 * chore: 사용하지 않는 파일을 index에서 제거 * refactor: EyeButton 사이즈 상수화 * refactor: OverviewItem의 배경색을 theme의 배경색으로 변경 * fix: React.FC 제거 * refactor: 불필요한 리턴 제거 * refactor: 불필요한 css 삭제 * refactor: 불필요한 스타일 컴포넌트 삭제 * fix: 상위 태그가 없는 h태그 삭제 --------- Co-authored-by: badahertz52 * [BE] fix: CD 스크립트 수정 (#404) * [FE] feature: 리뷰 연결 페이지 제작 (#370) * feat: ReviewDashboardPage 퍼블리싱 및 기본 기능 구현 * feat: PasswordModal 퍼블리싱 및 기본 기능 구현 * chore: ReviewDashboardPage를 pages의 index에 추가 * refactor: PasswordModal에 height 지정 * refactor: 피그마 스타일 반영 * feat: 라우터에 ReviewDashboardPage 임시 경로 추가 * feat: PasswordModal에 새로운 비밀번호 컴포넌트 임시 적용 * feat: 리뷰 작성 페이지 이동 기능 추가 * refactor: ReviewDashboardPage 스타일 조정 * fix: ReviewDashboardPage에 ErrorSuspenseContainer 적용 (라우터에서 감싸기) * refactor: ReviewDashboard를 보여주기 위한 API 도입(예정)에 따른 revieweeName, projectName props 제거 * fix: 잘못된 경로 수정 * [FE] feat: 리뷰 목록 페이지에서 받은 리뷰가 없을 경우, 그에 맞는 문구를 보여주는 ReviewEmptySection 컴포넌트 구현 및 스타일 수정 (#398) * fix: Pretendard 폰트가 정상적으로 적용되도록 나눔고딕 제거 * chore: 오른쪽 화살표 이미지 추가 * feat: 경로 표시를 위한 Breadcrumb 컴포넌트 구현 * refactor: rightArrow 이미지를 '/'로 변경 * feat: 리뷰 목록 페이지에 Breadcrumb 컴포넌트 추가 * style: css 속성 순서 변경 * refactor: 경로 반환 로직을 useBreadcrumbPaths 훅으로 분리 * refactor: Breadcrumb 컴포넌트 렌더링을 App.tsx로 이동 * feat: 리뷰 목록 페이지에서 받은 리뷰가 없을 경우, 그에 맞는 문구를 보여주는 ReviewEmptySection 컴포넌트 구현 * chore: 추후에 깃허브 로고 대신 다른 이미지로 대체될 수 있어서 일단 주석 처리 * design: 사소한 CSS 수정 * chore: 리뷰 목록 페이지 문구 수정 * chore: 불필요한 코드 제거 * refactor: 기존 빈 박스 이미지를 없애고 문구만 보여주는 것으로 수정 * design: NullText 스타일 수정 * [FE] design: 리뷰 상세, 작성 완료 페이지 디자인 수정 (#402) * chore: ProjectImg를 깃허브 로고 대신 다른 이미지로 대체될 수 있어서 일단 주석 처리 * design: 선택된 객관식들을 리스트 형태로 점으로 표시되게끔 css 수정 * design: 리뷰 작성 완료 문구가 가운데에 올 수 있게 css 수정 * design: 카드 헤더 부분 css 수정 * [FE] fix: Checkbox 컴포넌트의 잘못된 css 수정 (#401) * fix: Checkbox 스타일 버그 수정 - 사파리, 파이어폭스 브라우저 환경에서 비정상적으로 보이던 문제 및 존재하지 않는 속성 폐기 * refactor: deprecated인 clip 속성 제거 * fix: firefox, safari를 위한 체크박스 clip 속성 추가 * [FE] feat: 리뷰 작성 페이지 Progress Bar 구현 (#406) * chore: theme에 palePurple 색상 추가 * chore: 바뀐 API 문서에 맞추어 타입 수정 * feat: 프로그레스 바 컴포넌트 작성 * chore: 방문한 card sectionId를 recoil 상태로 관리 * chore: 수정된 WritingReviewData 타입에 맞추어 mock data 수정 * feat: 답변이 작성된 카테고리를 해제하는 경우 visitedCardList배열에서 해당 sectionId를 제거 * refactor: currentCardIndex를 숫자 타입 인자로 바로 변경할 수 있도록 수정 * chore: useMultipleChoice가 updateVisitedCardList를 그대로 return하도록 수정 * feat: cardSectionList를 기반으로 stepList를 만들어서 프로그레스 바를 보여주는 기능 구현 * chore: visitedCardList를 초기화하는 코드 추가 * fix: merge 과정에서 중복으로 작성된 코드 제거 * [FE] feat: 기존에 답변한 카테고리를 선택 해제하는 경우 confirm modal로 재확인하는 기능 구현 (#408) * chore: theme에 palePurple 색상 추가 * chore: 바뀐 API 문서에 맞추어 타입 수정 * feat: 프로그레스 바 컴포넌트 작성 * chore: 방문한 card sectionId를 recoil 상태로 관리 * chore: 수정된 WritingReviewData 타입에 맞추어 mock data 수정 * feat: 답변이 작성된 카테고리를 해제하는 경우 visitedCardList배열에서 해당 sectionId를 제거 * refactor: currentCardIndex를 숫자 타입 인자로 바로 변경할 수 있도록 수정 * chore: useMultipleChoice가 updateVisitedCardList를 그대로 return하도록 수정 * feat: cardSectionList를 기반으로 stepList를 만들어서 프로그레스 바를 보여주는 기능 구현 * chore: visitedCardList를 초기화하는 코드 추가 * fix: merge 과정에서 중복으로 작성된 코드 제거 * fix: 프로그레스 바 버튼 클릭 시 페이지 새로고침 되는 문제 해결 * design: confirmModal의 max-width 속성 제거 * feat: 이미 답변한 카테고리를 선택 해제하는 경우 confirmModal로 재확인하는 기능 구현 * feat: 이미 답변한 카테고리를 선택 해제하는 경우 confirmModal로 재확인하는 기능 구현 * [FE] feat : 리뷰 그룹 정보(리뷰이,프로젝트 이름)을 서버에서 받는 로직 생성 (#415) * refactor: ROUTER_PARAM 상수 추가 * feat: 리뷰 그룹 정보 타입과 이를 가져오는 api, endpoint 생성 * feat: 리뷰 그룹 정보를 가져오는 훅 생성 * feat: 리뷰 그룹 정보를 가져오는 목 핸들러 및 목 데이터 생성 * feat: 리뷰 그룹 정보를 가져오는 지 테스트하는 페이지 생성 (추후 삭제 예정) * chore: 리뷰 목록 api 테스트에서 삭제된 상수 속성 제거 * test: 리뷰 그룹 데이터 api 테스트 코드 추가 * fix: 리뷰 작성을 위한 정보를 가져오는 api 테스트에서 warning 나는 부분 수정 - 리뷰 작성 페이지에서 쿼리 파라미터가 들어있는데 msw에서 쿼리 파라미터가 들어있는 것을 직접 매칭하면 경고문구가 뜸 - 경고 문구 : Please match against a path instead and access query parameters using "new URL(request.url).searchParams" instead. Learn more: https://mswjs.io/docs/recipes/query-parameters - 정규 표현식으로 리뷰 작성 페이지의 api 요청 url을 잡는 방식으로 수정 * fix: groups가 들어가는 다른 api 요청과 구별하기 위해서 getReviewGroupData 목 핸들러 수정 * chore: 오타 수정 * [BE] refactor: 초기 데이터 설정시 중복 삽입 방지, 선택지 최대 개수 적용 (#405) * refactor: 초기 데이터 설정 시 중복 삽입 방지 * refactor: 선택지 최대 갯수 적용 및 꼬리질문 서술형 질문 내용 수정 * refactor: 템플릿이 존재하는지 확인하는 로직 변경 * test: 실패하는 테스트 수정 * [FE] feat: 비밀번호 조회 api 요청 관련 프로덕션 코드 및 테스트 코드 생성 (#417) * refactor: ROUTER_PARAM 상수 추가 * feat: 리뷰 그룹 정보 타입과 이를 가져오는 api, endpoint 생성 * feat: 리뷰 그룹 정보를 가져오는 훅 생성 * feat: 리뷰 그룹 정보를 가져오는 목 핸들러 및 목 데이터 생성 * feat: 리뷰 그룹 정보를 가져오는 지 테스트하는 페이지 생성 (추후 삭제 예정) * chore: 리뷰 목록 api 테스트에서 삭제된 상수 속성 제거 * test: 리뷰 그룹 데이터 api 테스트 코드 추가 * fix: 리뷰 작성을 위한 정보를 가져오는 api 테스트에서 warning 나는 부분 수정 - 리뷰 작성 페이지에서 쿼리 파라미터가 들어있는데 msw에서 쿼리 파라미터가 들어있는 것을 직접 매칭하면 경고문구가 뜸 - 경고 문구 : Please match against a path instead and access query parameters using "new URL(request.url).searchParams" instead. Learn more: https://mswjs.io/docs/recipes/query-parameters - 정규 표현식으로 리뷰 작성 페이지의 api 요청 url을 잡는 방식으로 수정 * fix: groups가 들어가는 다른 api 요청과 구별하기 위해서 getReviewGroupData 목 핸들러 수정 * feat: 비밀번호를 조회 관련 api, mock 생성 - getIsValidGroupAccessCodeApi 삭제 * feat: 비밀번호를 조회하고 비밀번호의 유효성 여부에 따른 액션을 핸들링하는 훅 생성 * feat: 비밀번호 조회 기능을 확인하기 위한 페이지 생성 (추후 삭제 예정) * fix: useCheckPasswordValidation 파일 확장자 변경 (tsx-> ts) * fix: 비밀번호 조회 목 핸들러(getPassWordValidation) 반환값 수정 * test : 비밀번호 조회 테스트 파일 추가 * chore: 불필요한 코드 삭제 * chore: 오타 수정 * fix: useCheckPasswordValidation 테스트 오류 수정 * fix: useCheckPasswordValidation 경로 오류 수정 * [BE] refactor: 리뷰 목록 카테고리 조회 시 네이티브 쿼리를 사용하도록 변경 (#391) * refactor: 리뷰 목록에서 카테고리 표시하는 로직을 네이티브 쿼리로 수정 * refactor: left join을 inner join으로 수정 * test: 실패하는 테스트 수정 * [FE] feat: footer 생성 및 리뷰미 팀명과 icon8 저작권 표기 (#419) * design: footerHeight theme 추가 * design: PageLayout 스타일 변경 - Layout의 배경 색 삭제 - Wrapper에 min-height 추가 * feat: Footer 생성 및 적용 * chore: 팀명 변경 * chore: 팀명 수정 * [BE] refactor: dto 에 Nullable, validation 어노테이션 적용 (#410) * refactor: 스프링 validation 어노테이션 추가 * refactor: Nullable 어노테이션 추가 * refactor: NotNull 추가 * refactor: NotNull에 메세지 추가 * [BE] refactor: 리뷰 상세 조회 시, 답변이 있는 리뷰만 보여주도록 변경 (#395) * refactor: TextAnswers에 QuestionId로 답변을 찾는 기능 추가 * feat: CheckboxAnswer를 관리하는 checkboxAnswers 클래스 추가 * refactor: 리뷰 상세 조회 시, 답변이 있는 질문으로만 구성하도록 변경 * refactor: 사용되지 않는 필드 제거 * test: TextAnswers의 hasAnswerByQuestionId 메서드 테스트 추가 * test: CheckboxAnswers 테스트 추가 * test: 답변이 있는 리뷰만 보여주는 기능 테스트 추가 * refactor: 리뷰 상세 조회 시, 발생하는 예외의 로그 레벨을 error로 변경 * test: CheckboxAnswersTest 테스트명 변경 * refactor: CheckboxAnswerdp reviewId 필드 추가 * refactor: else 를 사용한 가독성 향상 * test: 답변이 없는 경우 섹션이 아닌 질문이 없는지를 확인하도록 변경 * [BE] feat: 리뷰 그룹 정보 조회 api 구현 (#396) * feat: 리뷰 그룹 응답 dto 생성 * feat: ReviewGroupController 리뷰 그룹 조회 기능 추가 * feat: ReviewGroupFindService 추가 * test: ReviewGroupFindService 테스트 추가 * refactor: 서비스명 변경 (ReviewGroupLookupService) * test: 리뷰 요청 코드에 대한 리뷰 그룹에 존재하지 않는 경우 테스트 추가 * refactor: 메서드명 구체화 * refactor: 서비스 메서드명 변경 * refactor: 서비스 메서드명 변경2 * [FE] refactor: 리뷰 그룹 생성 API 요청 함수 및 MSW 핸들러, 테스트 추가 (#420) * refactor: 새로운 URL 요청 함수 및 엔드포인트 작성 * feat: reviewDashboard에 대한 경로 상수 추가 * refactor: URL 확인 모달 이름 변경 * refactor: 새 URL 생성 API 호출 함수를 리액트 쿼리에 적용 * chore: 리액트 쿼리 코드 변경으로 인해 deprecated된 테스트 코드 주석 처리 * refactor: 새로운 리뷰 URL 생성 API를 위한 모킹 핸들러 및 데이터 추가 * test: usePostDataForReviewRequestCode의 정상 동작 테스트 작성 * chore: 불필요한 주석 삭제 * [FE] feat: 캐러셀 구현한 후, 홈 페이지에 적용 (#421) * chore: LandingPage를 HomePage로 변경 * refactor: 새 HomePage의 Form 부분 퍼블리싱 * refactor: 문자열 길이 검증 함수에서 최소 길이도 검증할 수 있도록 변경 * test: 변경된 문자열 길이 검증 함수에 대한 테스트 추가 * refactor: 컴포넌트에 비밀번호 유효성 검사 추가 * refactor: ReviewGroupDataModal 스타일 조정 * refactor: URL을 안내하는 모달 이름 수정 * feat: 리뷰미 소개 페이지 1차 퍼블리싱 * chore: 불필요한 페이지 삭제 * refactor: 스타일 수정 * refactor: 폼 멘트 수정 * feat: 입력한 비밀번호를 확인할 수 있는 EyeButton 컴포넌트 작성 * refactor: URLGeneratorForm에 EyeButton 적용 * refactor: EyeButton 위치 수정 * chore: 불필요한 import 제거 * fix: EyeButton 위치 고정 * refactor: 소개 페이지의 화살표 위치 조정 * fix: EyeButton 위치 에러 수정 Co-authored-by: badahertz52 * refactor: EyeButton 크기 조정 * chore: 사용하지 않는 파일 삭제 * fix: h3 삭제 * chore: 사용하지 않는 파일을 index에서 제거 * refactor: EyeButton 사이즈 상수화 * refactor: OverviewItem의 배경색을 theme의 배경색으로 변경 * design: theme.ts에 palePurple 색상 추가 * feat: Carousel 컴포넌트 구현 * refactor: 기존 OverviewItem을 Carousel 컴포넌트로 교체 * design: 홈 페이지 높이 조정 * style: css 속성 순서 정렬 --------- Co-authored-by: ImxYJL Co-authored-by: badahertz52 * [BE] refactor: 리뷰 등록시 검증 보완 (#414) * refactor: 서술형 답변 길이 검증 추가 * refactor: 선택 질문인 서술형 응답이 빈값으로 들어오는 경우 TextAnswer 생성 로직을 실행하지 않도록 변경 * refactor: CheckBoxAnswerIncludedTextException 메시지 오류 수정 * refactor: 검증을 위한 question을 가져오는 방식 수정 * refactor: dto에 notNull, notBlank 추가 * test: 다른 테스트에 영향받지 않도록 getId() 적용 * test: 선택 질문인 서술형 응답이 빈값으로 들어오는 경우 테스트 추가 * refactor: 작업 내용 충돌 방지를 위해 제거 * refactor: 텍스트형 응답의 길이 검증 책임을 validator로 이동 * refactor: 텍스트형 응답이 빈문자열인지 확인하는 로직을 DTO로 이동 * refactor: log 메시지 오류 수정 * test: 답변 길이 검증 책임 이동에 따른 테스트 이동 * test: 불필요한 import문 제거 * [BE] refactor: 로그를 레벨에 따라 다르게 분류 (#394) * refactor: 사용하지 않는 클래스 삭제 * refactor: 예외 클래스 이름 변경 - BadRequestException 하위 예외들 대상으로 * refactor: 로그 레벨 변경 - BadRequestException 하위 예외들 대상으로 * refactor: 같은 기능을 하는 예외 하나로 통일 * refactor: 로그 레벨 수정 * refactor: 발생하는 상황을 잘 반영하는 예외로 변경 * refactor: 로그 레벨 변경 * refactor: 예외 이름 변경 * refactor: 사용하지 않는 예외 삭제 * refactor: 데이터 정합성 오류 로그 메세지 보강 * refactor: 클라이언트에 내려주지 않은 데이터를 요청하는 오류 로그 메세지 보강 * refactor: 오류 로그 메세지 보강 * refactor: 데이터 정합성 예외 생성 * refactor: 데이터 정합성 예외 생성 * refactor: 정상적이지 않은 요청에 대한 예외 생성 * refactor: BadRequest 로그 레벨 조정 * refactor: NotFoundException 로그 레벨 조정 * refactor: 예외 이름 변경 * refactor: 상황에 구체적인 예외로 변경 * refactor: 상황에 구체적인 예외로 변경 * refactor: 사용하지 않는 함수 삭제 * refactor: 특정 상황에 대한 예외로 변경 * refactor: 사용하지 않는 함수, 예외 삭제 * refactor: 오타 수정 * refactor: warn, error 로그 레벨 스택 트레이스 출력 * refactor: 예외 이름 변경 * refactor: 스택 트레이스 출력 * refactor: 스택 트레이스 출력 * refactor: 사용하지 않는 예외 삭제 * refactor: 스택 트레이스 출력 * test: 변경한 예외 테스트에도 적용 * style: 코드 재정렬 * [FE] fix: 리뷰 연결 페이지 이름 수정 및 EyeButton이 가려지는 문제 해결 (#428) * fix: 비밀번호 Input의 길이가 길어질 때 Eyebutton을 가리는 문제 수정 * refactor: 리뷰 연결 페이지 이름 수정(ReviewZone) * refactor: 누락됐던 dashboard 표현 삭제 * chore: 리뷰 목록, 상세 페이지에 유효하지 않은 접근 시 띄워주는 모달의 문구 및 버튼 content 수정 (#429) * [BE] feat: GroupAccessCode 와 ReviewRequestCode 를 받아 확인하는 API 구현 (#412) * feat: 코드 확인 결과 응답 객체 생성 * feat: 레포지토리 코드 확인 함수 생성 * feat: 서비스 코드 확인 함수 생성 * test: 서비스 코드 확인 함수 테스트 작성 * feat: 컨트롤러 코드 확인 메서드 생성 * refactor: post 로 변경 * refactor: API 변경 적용 * [BE] feat: RestDocs 도입 (#407) * build: RestDocs, asciidoc 의존성 추가 * test: RestDocs 상위 클래스 생성 * test: 리뷰 그룹 생성 API * test: 리뷰 질문 가져오기 API * test: 리뷰 작성하기 API * test: 리뷰 단건 조회 API * test: 리뷰 목록 조회 API * docs: API 문서 생성 * docs: http-request를 curl-request로 변경 * refactor: Swagger 어노테이션 삭제 * fix: 리뷰어 -> 리뷰이 * fix: 텍스트 답변 -> 서술형 답변 * fix: 생성 날짜 -> 작성 날짜 * chore: 설명 추가 * chore: 설명 추가 * chore: 배열 설명 추가 * feat: 필수 여부 추가 * test: 리뷰 그룹 정보 API * fix: resolve compile error * docs: 리뷰 요청 코드, 확인 코드 일치 여부 API * [FE] feat: 리뷰 연결 페이지에서 리뷰이,프로젝트 이름을 가져오는 api 적용 (#432) * chore: RevieGroupTestPage 삭제 * feat: 리뷰 연결 페이지에서 리뷰이, 프로젝트 이름을 가져오는 api를 연결 * design: ReviewGuide 디자인 수정 - 리뷰이,프로젝트명이 길어질 경우를 대비해 overflow-wrap 추가 * refactor: 줄 바꿈 * [FE] feat : 리뷰 연결 페이지에서 비밀번호 조회 api 적용 (#436) * chore: ReviewGroupTestPage 삭제 * fix: PasswordResponse 타입 수정 * refactor: 비밀번호 일치 조회 api 변경에 따른 코드 수정 * chore: PasswordTestPage 삭제 * chore: PasswordModal의 InputContainer 태그 변경 (div-> form) ,button 타입을 submit으로 수정 * style: PasswordModal 스타일 코드 stylelint 적용 * feat : 리뷰 연결 페이지에서 비밀번호 조회 api 적용 * fix:useCheckPasswordValidation의 staleTime 복구 * chore: 비밀번호 조회 endpont 명 변경 * fix: 복수형 상수 네이밍 단수로 수정 - 파일명도 같이 수정 * [BE] refactor: GroupAccessCode를 발급하지 않고 사용자가 직접 입력하면서 변경되는 사항을 반영 (#441) * feat: GroupAccessCode 클래스 및 비밀번호 검증 로직 추가 * refactor: 그룹 생성 요청 dto에 groupAccessCode 추가 * refactor: 리뷰 상세 조회 로직 변경 * refactor: 리뷰 그룹 생성 로직에 groupAccessCode 검증 * refactor: 리뷰 목록 조회 로직 수정중 * test: 변경된 사항 테스트 반영 * refactor: 로그 메세지 보강 * refactor: 리뷰 목록 조회 로직 수정완 * test: 깨지는 테스트 해결 * docs: 변경된 내용 api 문서에 반영 * refactor: 그룹 액세스 코드의 필드명 변경 * refactor: 네이티브 쿼리를 기본 쿼리 메서드로 변경 * refactor: 사용하지 않는 상수 제거 * refactor: 인증정보 불일치 예외 메시지 구체적으로 변경 * refactor: 사용하지 않는 필드와 클래스 제거 * test: reviewRequestCode와 groupAccessCode 서로 다르게 변경 --------- Co-authored-by: KIMGYUTAE Co-authored-by: nayonsoso * [FE] fix: 리뷰 제출 전 확인 모달 스타일 조정 (#440) * refactor: 질문과 답변 사이의 gap 조정 * fix: 객관식 질문과 서술형 질문이 붙어있던 문제 해결 * [FE] fix: 비밀번호 검증 훅 분리 및 비밀번호 길이 유효성 검사 문구 추가 (#434) * refactor: Input 컴포넌트에 onBlur 이벤트 추가 * refactor: 비밀번호 Input에 onBlur 이벤트를 추가해 길이 유효성 검증 에러 문구 출력 * fix: 잘못된 useEffect 의존성 배열 값 수정 * refactor: 비밀번호 검증 로직 훅 분리 * refactor: early return으로 리팩토링 * refactor: boolean형 변수 이름을 보다 명확하게 변경 * fix: 안내 문구를 -요 체로 수정 * chore: 불필요한 주석 제거 * fix: 안내 문구를 -요 체로 수정 * refactor: 비밀번호 유효성 검사 여부를 기존의 유틸 함수를 이용해 검증하도록 수정 * refactor: URL 생성 폼의 타이틀 수정 * [FE] chore: 리뷰 목록 및 상세 페이지에서 reviewRequestCode를 recoil 상태로 관리 (#442) * chore: 리뷰 목록 및 상세 API 형식 수정 * chore: reviewRequestCode를 recoil 상태로 관리 * chore: 리뷰 공간에 처음 들어갔을 때 reviewRequestCode를 recoil에 저장 * chore: 리뷰 목록 페이지에서 reviewRequestCode가 존재하지 않는 경우 홈으로 돌아가도록 수정 * chore: 상세 페이지를 요청할 때 reviewRequestCode를 함께 전달하도록 prop 수정 * chore: 리뷰 목록을 요청할 때 reviewRequestCode를 함께 전달하도록 props 수정 * chore: 비밀번호 확인 post handler 추가 및 getReviewList prop 수정 * fix: endpoint 수정 * chore: console 삭제 * refactor: api 요청 함수의 props를 interface로 분리 * refactor: endpoint source, queryString 상수화 적용 * style: 코드의 안정감과 무게중심을 맞추는 작업 * chore: 중복되는 handler 삭제 * chore: reviewRequestCodeAtom의 경로 수정 * [FE]: 온점 삭제 및 ~다 체를 ~요 체로 변경 (#444) * fix: 온점 삭제 및 ~다 체를 ~요 체로 변경 * chore: 오타 수정 * fix: RevieZone navigate 경로 오류 수정 * ci: concurrency의 cancel-in-progress 설정을 false로 수정 (#448) * [BE] refactor: 섹션에 섹션명 추가 (#457) * refactor: Section에 sectionName 추가 * refactor: Section 응답 시, sectionName을 포함하도록 변경 * refactor: DatabaseInitializer에 sectionName 추가 * test: Section 변경 사항 테스트 코드에 적용 * refactor: 초기 데이터 섹션명 변경 * [FE] feat: 홈 페이지 캐러셀에 자동으로 넘어가는 기능 추가 및 디자인 수정 (#459) * chore: 홈 페이지에 보여질 슬라이드 이미지 추가 * feat: 캐러셀에 자동으로 넘어가는 기능 추가 및 한 화면에 하나의 슬라이드만 보이게 수정 * chore: ReviewMeOverview에서 캐러셀 적용 * refactor: setInterval 말고 setTimeout으로 변경 * [FE] refactor: 리뷰 작성 페이지 컴포넌트들의 gap 조정, footer 스타일 조정, 로고 가운데 정렬 (#458) * refactor: Breadcrumb의 폰트 크기 조정 (조금 더 작게) * refactor: 리뷰 작성 카드와 footer간의 길이 조정 * fix: footer를 절대 위치에서 상대 위치로 수정 (footer가 본문을 가리는 문제 해결) * refactor: ProgressBar와 리뷰 작성 카드 사이의 gap 수정 * fix: Topbar의 리뷰미 로고 세로 가운데 정렬 * refactor: ReviewZonePage에 명시적인 height 추가 * refactor: Footer의 position을 다시 absolute로 수정 Co-authored-by: badahertz52 * refactor: Footer position 조정에 따른 스타일 조정 * fix: ReviewZonePage에서 height 제거 --------- Co-authored-by: badahertz52 * fix: 비밀번호 입력 시 onBlur 이벤트가 일어나지 않으면 Form 전체에 대한 유효성 검증을 하지 않던 문제 해결 (#463) * [FE] fix: breadcrumb에서 연결 페이지를 눌렀을 때 홈페이지로 이동되는 문제 해결 (#464) * fix: breadcrumb에서 연결 페이지를 눌렀을 때 홈페이지로 이동되는 문제 해결 * chore: 완료된 task 주석 제거 * [FE] design: 캐러셀을 정가운데로 위치시킨 뒤, 자동 슬라이드 전환 시간을 6초로 설정한다. 그리고 전반적인 디자인을 수정한다. (#465) * design: 캐러셀 정가운데로 위치 변경 * refactor: 자동 슬라이드 전환 시간을 6초로 변경 * refactor: 홈 페이지 입력 폼에서 placeholder 모두 제거 * design: ReviewZonePage 화면 정가운데로 위치 변경 * style: css 속성 순서 정렬 * refactor: Input id값을 useId 훅을 이용하게 변경 * [FE] feat : 리뷰 작성 내용 확인 에서 쓰지 않은 서술형에서 답변을 작성하지 않았다는 문구 띄어주기 (#466) * refactor : AnswerListRecheckModal 폴더 이동 및 경로 리팩토링 * feat:제출 적 확인 모달에서 작성하지 않은 단변에 대한 문구 추가 * desing: LongReviewItem의 font-weight 삭제 * fix: 아루가 제안한 버그 수정 (#468) * refactor: 비밀번호 조회 input에서 placeholder 삭제 (#470) * [BE] 답변을 길게 작성하는 경우 발생하는 서버 에러 해결 (#472) * fix: 한글 byte를 고려한 서술형 응답 length 속성 추가 * test: 글자수 속성 변경에 따른 테스트 추가 * [FE] design: confirm modal의 버튼 관련 디자인 수정 (#474) * design: 확인버튼, 취소버튼 위치 변경 * design: confirm modal 버튼 너비,gap 변경 * [FE] design : 홈페이지에서 footer가 스크롤 없이 한 화면에 보이도록 수정 (#476) * style: theme의 타입명 컨벤션에 맞게 수정 * design: componentHeight theme 생성 및 적용 * design: 홈페이지에서 footer가 한 화면에 보이지 않는 디자인 오류 수정 * feat: breadcrumbPathList에 따라 Main의 min-height를 동적으로 변경하는 기능 추가 * refactor: MainProps,MainContainerProps의 isBreadCrumb 옵셔널로 변경 * [FE] rafactor: isSuccess 상태에 따라 리뷰 목록 페이지 전체 레이아웃 렌더링 및 리뷰 목록 간격 수정 (#485) * design: 리뷰 목록 gap 수정 * refactor: isSuccess 상태에 따라 전체 레이아웃 렌더링 * [FE] fix: 객관식 내용과 주관식 질문 간의 gap 띄우기 (#478) * fix: 객관식 내용과 주관식 질문 간의 gap 띄우기 Co-authored-by: soosoo22 * chore: 불필요한 div 삭제 * chore: 빈 div 태그를 fragment로 변경 --------- Co-authored-by: soosoo22 * [FE] refactor: 프로그레스 바 관련 코드 리팩토링 (#481) * refactor: stepList 타입 분리 및 네이밍 camelCase로 수정 * refactor: Direction의 타입 분리 * refactor: if-else문 대신 early return 적용 * refactor: visitedCardListAtom의 default값 상수화 * chore: sectionName이 없는 경우 사용했던 mock data 제거 * design: 프로그레스 바의 stepButton 폰트 사이즈를 줄이고 좌우 패딩 추가 * style: 컨벤션에 맞추어 중괄호 제거 * [FE] design: 링크 생성 모달, 연결 페이지 버튼 내 텍스트, 리뷰 확인 모달 띄어쓰기 관련 문제를 해결 (#483) * design: 리뷰 연결 페이지에서 버튼 내 작은 텍스트의 font size 증가 * chore: 리뷰 확인 모달에서의 띄어쓰기 오타 수정 * design: checkbox 크기를 조절해서 align이 맞지 않는 문제 수정 * [FE] refactor: react-query에 retry 설정 추가 (#487) * refactor: react-query에 retry 설정 추가 - 500단 에러에서는 api 요청을 재시도하고, 그렇지 않은 경우에는 api 요청 재시도를 하지 않도록 설정 * refactor: if 중괄호 삭제 * [FE] feat: 캐러셀 하단에 슬라이드 위치 표시용 도트를 추가 및 피그마 컴포넌트를 사용하여 기존 슬라이드 이미지 교체 (#484) * feat: 캐러셀 하단에 슬라이드 위치 표시용 도트 추가 * chore: 피그마 컴포넌트를 활용한 이미지로 교체 * design: 캐러셀 하단 Indicator bottom 위치 조정 * chore: 슬라이드 이미지 수정 * chore: 불필요한 rem 제거 * design: 캐러셀 height 수정 * chore: 슬라이드에 들어갈 이미지 텍스트 정렬 후 적용 * [BE] feat: 그룹 접근 코드 해싱 적용 (#489) * refactor: v2 잔재 제거 * feat: 해싱 인코더 * feat: 해싱 코드 생성 및 검증 * feat: 그룹 접근 코드 해싱 적용 및 검증 * [FE] design: 홈페이지 디자인 오류 수정 (#492) * design: ReviewMeOverview 섹션의 height 속성 제거 * design: Formlayout에서 title이 wrap되지 않도록 수정 * [FE] chore: 리뷰미 저작권 표시 및 Icons8 저작권 표기를 공식 문서대로 변경 (#494) * chore: 리뷰미 저작권 표시 및 Icons8 저작권 표기를 공식 문서대로 변경 * chore: By 대소문자 변경 * chore: 리뷰미 표기 방식 수정 * [FE] feat :리뷰 작성 중 리뷰 작성 페이지를 떠날 때, 확인 모달을 띄우는 기능 추가 (#497) * ci : history 설치 * chore: 폴더명 오타 수정 * fix: 리뷰 작성 완료 페이지 경로 오류 수정 * refactor: 제출 모달에 대한 key, 핸들러 함수 네이밍 수정 * refactor: 스타일 컴포넌트 네이밍 변경 - SubmitErrorMessage -> ConfrimModalMessage * feat: useNavigateBlocker 훅 생성 - 작성한 답변이 있는 상태에서 작성한 페이지에서 다른 페이지로 이동하려할때 이동을 막거나, 이동을 진행하는 blocker를 반환하는 훅 * feat: 리뷰 작성 페이지에 useNavigateBlocker 적용 및 페이지 이동에 대한 ConfirmModal 추가 * refactor: 리뷰 작성 페이지에서 SubmitCheckModal, NavigatonBlockerModal 분리 * design: 파비콘 추가 및 적용 (#498) * design : title에서 편지 이모지 삭제 (#500) * [BE] fix: QA에서 발생한 오류를 수정한다. (#451) * fix: 카테고리 개수 2개로 수정 * fix: 리뷰이 이름 치환 * style: apply code convention * fix: 필요하지 않은 문구 제거 * [BE] 필수 질문 제출 검증 (#502) * refactor: 함수명 변경 * feat: 예외 클래스 생성 * feat: 필수 질문 검증 로직 추가 * test: 필수 질문 검증 로직 테스트 작성 * refactor: Nullable 추가 * refactor: notNull 조건 추가 * style: 개행 및 코드 정렬 * refactor: Set 자료형 변경 * refactor: 예외 이름 변경 * refactor: 예외 메세지 수정 * refactor: 예외 클래스 이름 오타 수정 * refactor: 함수 분리하지 않게 수정 * refactor: 로그 레벨 수정 * refactor: 불필요한 부분 제거 * refactor: 예외 이름 변경 * refactor: 더 적절한 예외로 변경 * refactor: 사용하지 않는 예외 삭제 * style: 코드 정렬 * [FE] fix: 캐러셀에서 마지막 슬라이드가 넘어갈 때 자연스럽게 첫 슬라이드로 넘어가도록 수정 (#508) * refactor: 캐러셀에 들어가는 슬라이드를 담은 배열의 이름을 복수형에서 List로 수정 * fix: 마지막 슬라이드에서 자연스럽게 맨 처음 슬라이드로 이동하도록 수정 및 일부 변수 상수화 --------- Co-authored-by: Fe <64690761+chysis@users.noreply.github.com> Co-authored-by: Donghoon Lee Co-authored-by: Hyeonji <110809927+skylar1220@users.noreply.github.com> Co-authored-by: nayonsoso Co-authored-by: Kimprodp <145949635+Kimprodp@users.noreply.github.com> Co-authored-by: KIMGYUTAE Co-authored-by: ImxYJL <111052302+ImxYJL@users.noreply.github.com> Co-authored-by: chysis Co-authored-by: soosoo22 Co-authored-by: soosoo22 <80167893+soosoo22@users.noreply.github.com> Co-authored-by: soosoo22 Co-authored-by: hyeonjilee Co-authored-by: ImxYJL --- .github/workflows/backend-ci.yml | 50 + .github/workflows/backend-dev-cd.yml | 98 + .github/workflows/backend-prod-cd.yml | 98 + .../discord-pull-request-comment.yml | 106 + .github/workflows/discord-pull-request.yml | 85 + .github/workflows/frontend-ci.yml | 48 + .gitmodules | 3 + README.md | 18 + backend/.gitignore | 37 + backend/build.gradle | 85 + backend/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + backend/gradlew | 249 + backend/gradlew.bat | 92 + backend/settings.gradle | 1 + backend/src/docs/asciidoc/create-review.adoc | 7 + .../src/docs/asciidoc/get-review-form.adoc | 7 + backend/src/docs/asciidoc/index.adoc | 31 + backend/src/docs/asciidoc/review-detail.adoc | 7 + backend/src/docs/asciidoc/review-list.adoc | 7 + backend/src/docs/asciidoc/reviewgroup.adoc | 11 + .../java/reviewme/DatabaseInitializer.java | 148 + .../java/reviewme/ReviewMeApplication.java | 12 + .../main/java/reviewme/config/CorsConfig.java | 16 + .../java/reviewme/config/SwaggerConfig.java | 22 + .../main/java/reviewme/config/WebConfig.java | 16 + .../config/properties/SwaggerProperties.java | 19 + .../global/GlobalExceptionHandler.java | 128 + .../java/reviewme/global/HeaderProperty.java | 18 + .../HeaderPropertyArgumentResolver.java | 32 + .../global/exception/BadRequestException.java | 8 + .../exception/DataInconsistencyException.java | 12 + .../global/exception/FieldErrorResponse.java | 8 + .../MissingHeaderPropertyException.java | 12 + .../global/exception/NotFoundException.java | 8 + .../global/exception/ReviewMeException.java | 12 + .../exception/UnauthorizedException.java | 8 + .../exception/UnexpectedRequestException.java | 12 + .../reviewme/question/domain/OptionGroup.java | 39 + .../reviewme/question/domain/OptionItem.java | 46 + .../reviewme/question/domain/OptionType.java | 6 + .../reviewme/question/domain/Question.java | 69 + .../question/domain/QuestionType.java | 8 + ...singOptionItemsInOptionGroupException.java | 13 + .../exception/QuestionNotFoundException.java | 13 + .../repository/OptionGroupRepository.java | 12 + .../repository/OptionItemRepository.java | 48 + .../repository/QuestionRepository.java | 29 + .../review/controller/ReviewController.java | 57 + .../domain/CheckBoxAnswerSelectedOption.java | 32 + .../review/domain/CheckboxAnswer.java | 46 + .../review/domain/CheckboxAnswers.java | 28 + .../java/reviewme/review/domain/Review.java | 61 + .../reviewme/review/domain/TextAnswer.java | 35 + .../reviewme/review/domain/TextAnswers.java | 30 + ...tegoryOptionByReviewNotFoundException.java | 13 + .../InvalidProjectNameLengthException.java | 14 + ...lidReviewAccessByReviewGroupException.java | 13 + .../InvalidRevieweeNameLengthException.java | 14 + .../InvalidTextAnswerLengthException.java | 14 + ...ingCheckboxAnswerForQuestionException.java | 13 + ...MissingTextAnswerForQuestionException.java | 13 + ...oupNotFoundByGroupAccessCodeException.java | 13 + ...pNotFoundByReviewRequestCodeException.java | 13 + .../repository/CheckboxAnswerRepository.java | 9 + .../review/repository/ReviewRepository.java | 17 + .../repository/TextAnswerRepository.java | 9 + .../CreateCheckBoxAnswerRequestValidator.java | 77 + .../review/service/CreateReviewService.java | 144 + .../CreateTextAnswerRequestValidator.java | 48 + .../service/ReviewDetailLookupService.java | 155 + .../service/ReviewPreviewGenerator.java | 20 + .../review/service/ReviewService.java | 63 + .../request/CreateReviewAnswerRequest.java | 21 + .../dto/request/CreateReviewRequest.java | 15 + .../detail/OptionGroupAnswerResponse.java | 11 + .../detail/OptionItemAnswerResponse.java | 8 + .../detail/QuestionAnswerResponse.java | 14 + .../detail/SectionAnswerResponse.java | 10 + .../detail/TemplateAnswerResponse.java | 13 + .../list/ReceivedReviewCategoryResponse.java | 7 + .../response/list/ReceivedReviewResponse.java | 12 + .../list/ReceivedReviewsResponse.java | 10 + ...ncludedNotProvidedOptionItemException.java | 17 + .../CheckBoxAnswerIncludedTextException.java | 13 + .../MissingRequiredQuestionException.java | 15 + .../OptionItemNotFoundBySelectedOptionId.java | 13 + .../RequiredQuestionNotAnsweredException.java | 13 + .../ReviewGroupNotFoundByCodesException.java | 14 + .../ReviewGroupUnauthorizedException.java | 13 + .../ReviewNotFoundByIdAndGroupException.java | 13 + .../exception/ReviewNotFoundException.java | 13 + ...tedOptionItemCountOutOfRangeException.java | 18 + ...nAndProvidedQuestionMismatchException.java | 18 + .../SubmittedQuestionNotFoundException.java | 13 + ...TextAnswerIncludedOptionItemException.java | 13 + .../UnnecessaryQuestionIncludedException.java | 14 + .../controller/ReviewGroupController.java | 47 + .../reviewgroup/domain/GroupAccessCode.java | 38 + .../reviewgroup/domain/ReviewGroup.java | 76 + ...InvalidGroupAccessCodeFormatException.java | 13 + .../repository/ReviewGroupRepository.java | 18 + .../service/RandomCodeGenerator.java | 23 + .../service/ReviewGroupLookupService.java | 22 + .../service/ReviewGroupService.java | 46 + .../service/dto/CheckValidAccessRequest.java | 13 + .../service/dto/CheckValidAccessResponse.java | 6 + .../dto/ReviewGroupCreationRequest.java | 16 + .../dto/ReviewGroupCreationResponse.java | 6 + .../service/dto/ReviewGroupResponse.java | 8 + .../controller/TemplateController.java | 22 + .../reviewme/template/domain/Section.java | 72 + .../template/domain/SectionQuestion.java | 32 + .../reviewme/template/domain/Template.java | 38 + .../template/domain/TemplateSection.java | 32 + .../reviewme/template/domain/VisibleType.java | 8 + ...ionGroupNotFoundByQuestionIdException.java | 13 + .../SectionInTemplateNotFoundException.java | 13 + ...emplateNotFoundByReviewGroupException.java | 14 + .../repository/SectionRepository.java | 20 + .../repository/TemplateRepository.java | 9 + .../template/service/TemplateMapper.java | 111 + .../template/service/TemplateService.java | 34 + .../dto/response/OptionGroupResponse.java | 11 + .../dto/response/OptionItemResponse.java | 7 + .../dto/response/QuestionResponse.java | 14 + .../service/dto/response/SectionResponse.java | 14 + .../dto/response/TemplateResponse.java | 11 + .../QuestionInSectionNotFoundException.java | 13 + .../src/main/java/reviewme/util/Encoder.java | 32 + ...coderAlgorithmInitializationException.java | 13 + backend/src/main/resources/api-docs.yml | 11 + backend/src/main/resources/application.yml | 21 + backend/src/main/resources/logback-spring.xml | 33 + backend/src/main/resources/logback.yml | 14 + backend/src/main/resources/secret | 1 + .../reviewme/ReviewMeApplicationTests.java | 12 + .../src/test/java/reviewme/api/ApiTest.java | 94 + .../test/java/reviewme/api/ReviewApiTest.java | 261 + .../java/reviewme/api/ReviewGroupApiTest.java | 125 + .../java/reviewme/api/TemplateApiTest.java | 93 + .../java/reviewme/api/TemplateFixture.java | 109 + .../test/java/reviewme/config/TestConfig.java | 14 + .../HeaderPropertyArgumentResolverTest.java | 56 + .../repository/OptionItemRepositoryTest.java | 84 + .../review/domain/CheckboxAnswersTest.java | 52 + .../review/domain/TextAnswersTest.java | 50 + .../repository/QuestionRepositoryTest.java | 71 + ...ateCheckBoxAnswerRequestValidatorTest.java | 160 + .../service/CreateReviewServiceTest.java | 264 + .../CreateTextAnswerRequestValidatorTest.java | 77 + .../ReviewDetailLookupServiceTest.java | 214 + .../service/ReviewPreviewGeneratorTest.java | 41 + .../review/service/ReviewServiceTest.java | 109 + .../reviewme/reviewgroup/ReviewGroupTest.java | 63 + .../domain/GroupAccessCodeTest.java | 35 + .../service/RandomCodeGeneratorTest.java | 21 + .../service/ReviewGroupLookupServiceTest.java | 52 + .../service/ReviewGroupServiceTest.java | 74 + .../reviewme/support/DatabaseCleaner.java | 42 + .../support/DatabaseCleanerExtension.java | 15 + .../java/reviewme/support/ServiceTest.java | 17 + .../reviewme/template/domain/SectionTest.java | 45 + .../repository/SectionRepositoryTest.java | 38 + .../template/service/TemplateMapperTest.java | 210 + .../template/service/TemplateServiceTest.java | 43 + backend/src/test/resources/application.yml | 34 + .../restdocs/templates/request-fields.snippet | 11 + .../templates/request-headers.snippet | 10 + frontend/.eslintrc.cjs | 67 + frontend/.gitignore | 6 + frontend/.prettierrc | 12 + frontend/.stylelintrc.json | 13 + frontend/babel.config.json | 8 + frontend/jest.config.js | 13 + frontend/jest.polyfills.js | 21 + frontend/jest.setup.js | 5 + frontend/package.json | 79 + frontend/public/index.html | 32 + frontend/public/mockServiceWorker.js | 281 + frontend/src/App.tsx | 30 + frontend/src/apis/apiErrorMessageCreator.ts | 11 + frontend/src/apis/endpoints.ts | 59 + frontend/src/apis/group.ts | 76 + frontend/src/apis/review.ts | 73 + frontend/src/assets/alertTriangle.svg | 3 + frontend/src/assets/alertTrianglePrimary.svg | 3 + frontend/src/assets/checked.svg | 3 + frontend/src/assets/clock.svg | 3 + frontend/src/assets/close.svg | 3 + frontend/src/assets/copy.svg | 3 + frontend/src/assets/downArrow.svg | 10 + frontend/src/assets/eye.svg | 11 + frontend/src/assets/eyeOff.svg | 10 + frontend/src/assets/githubLogo.svg | 3 + frontend/src/assets/lock.svg | 3 + frontend/src/assets/logo.svg | 25 + frontend/src/assets/menu.svg | 3 + frontend/src/assets/navigateNext.svg | 3 + frontend/src/assets/overviewTitle.svg | 9 + frontend/src/assets/primaryHome.svg | 3 + frontend/src/assets/primaryReload.svg | 3 + frontend/src/assets/reviewZone.svg | 9 + frontend/src/assets/unLock.svg | 3 + frontend/src/assets/unchecked.svg | 3 + frontend/src/assets/upperArrow.svg | 3 + frontend/src/assets/usageCarosel1.svg | 67 + frontend/src/assets/usageCarosel2.svg | 60 + frontend/src/assets/usageCarosel3.svg | 61 + frontend/src/assets/userProfile.svg | 4 + frontend/src/assets/whiteHome.svg | 3 + frontend/src/assets/whiteReload.svg | 3 + frontend/src/assets/x.svg | 3 + frontend/src/components/ReviewCard/index.tsx | 43 + frontend/src/components/ReviewCard/styles.ts | 91 + .../components/common/Breadcrumb/index.tsx | 39 + .../components/common/Breadcrumb/styles.ts | 25 + .../src/components/common/Button/index.tsx | 17 + .../src/components/common/Button/styles.ts | 70 + .../src/components/common/Checkbox/index.tsx | 33 + .../src/components/common/Checkbox/styles.ts | 28 + .../components/common/CheckboxItem/index.tsx | 20 + .../components/common/CheckboxItem/styles.ts | 15 + .../src/components/common/DropDown/index.tsx | 24 + .../src/components/common/DropDown/styles.ts | 11 + .../src/components/common/EyeButton/index.tsx | 19 + .../src/components/common/EyeButton/styles.ts | 16 + .../src/components/common/Input/index.tsx | 31 + .../src/components/common/Input/styles.ts | 21 + .../common/LongReviewItem/index.tsx | 55 + .../common/LongReviewItem/styles.ts | 45 + .../common/MultilineTextViewer/index.tsx | 20 + .../common/MultilineTextViewer/styles.ts | 5 + .../components/common/ProjectImg/index.tsx | 16 + .../components/common/ProjectImg/styles.ts | 11 + .../components/common/ReviewDate/index.tsx | 25 + .../components/common/ReviewDate/styles.ts | 17 + .../common/RevieweeComments/index.tsx | 13 + .../common/RevieweeComments/styles.ts | 13 + .../components/common/SearchInput/index.tsx | 13 + .../components/common/SearchInput/styles.ts | 19 + .../src/components/common/TopButton/index.tsx | 18 + .../src/components/common/TopButton/style.ts | 26 + frontend/src/components/common/index.tsx | 15 + .../common/modals/AlertModal/index.tsx | 45 + .../common/modals/AlertModal/styles.ts | 28 + .../common/modals/ConfirmModal/index.tsx | 52 + .../common/modals/ConfirmModal/styles.ts | 37 + .../common/modals/ContentModal/index.tsx | 30 + .../common/modals/ContentModal/styles.ts | 39 + .../common/modals/ErrorAlertModal/index.tsx | 31 + .../common/modals/ErrorAlertModal/styles.ts | 13 + .../modals/LoginRedirectModal/index.tsx | 25 + .../common/modals/ModalBackground/index.tsx | 18 + .../common/modals/ModalBackground/styles.ts | 7 + .../common/modals/ModalPortal/index.tsx | 30 + .../common/modals/ModalPortal/styles.ts | 13 + .../common/modals/SideModal/index.tsx | 24 + .../common/modals/SideModal/styles.ts | 12 + .../src/components/common/modals/index.tsx | 5 + .../components/error/ErrorFallback/index.tsx | 16 + .../components/error/ErrorSection/index.tsx | 72 + .../components/error/ErrorSection/styles.ts | 46 + .../error/ErrorSuspenseContainer/index.tsx | 22 + frontend/src/components/error/index.tsx | 2 + frontend/src/components/index.tsx | 3 + .../src/components/layouts/Footer/index.tsx | 21 + .../src/components/layouts/Footer/style.ts | 32 + .../src/components/layouts/Main/index.tsx | 17 + .../src/components/layouts/Main/styles.ts | 37 + .../components/layouts/PageLayout/index.tsx | 16 + .../components/layouts/PageLayout/styles.ts | 18 + .../src/components/layouts/Sidebar/index.tsx | 57 + .../src/components/layouts/Sidebar/styles.ts | 73 + .../layouts/Topbar/components/Logo/index.tsx | 25 + .../layouts/Topbar/components/Logo/styles.ts | 33 + .../components/SidebarOpenButton/index.tsx | 17 + .../components/SidebarOpenButton/styles.ts | 11 + .../src/components/layouts/Topbar/index.tsx | 28 + .../src/components/layouts/Topbar/styles.ts | 26 + frontend/src/components/layouts/index.tsx | 5 + frontend/src/constants/errorMessage.ts | 19 + frontend/src/constants/index.ts | 6 + frontend/src/constants/page.ts | 7 + frontend/src/constants/queryKey.ts | 13 + frontend/src/constants/review.ts | 10 + frontend/src/constants/route.ts | 9 + frontend/src/constants/routerParam.ts | 4 + frontend/src/constants/system.ts | 4 + frontend/src/favicons/favicon.ico | Bin 0 -> 1150 bytes frontend/src/hooks/index.ts | 7 + frontend/src/hooks/review/index.ts | 3 + .../review/useGetDetailedReview/index.ts | 33 + .../hooks/review/useGetDetailedReview/test.ts | 25 + .../hooks/review/useGetReviewList/index.ts | 32 + .../hooks/review/useGetReviewList/test.tsx | 23 + .../src/hooks/review/writingCardForm/index.ts | 12 + .../multipleChoice/useAboveSelectionLimit.ts | 42 + .../useCancelAnsweredCategory.ts | 71 + .../multipleChoice/useMultipleChoice.ts | 60 + .../useUpdateMultipleChoiceAnswer.ts | 64 + .../writingCardForm/useCardSectionList.ts | 29 + .../useCheckNextStepAvailability.ts | 38 + .../writingCardForm/useCurrentCardIndex.ts | 26 + .../useGetDataToWrite/index.ts | 26 + .../writingCardForm/useGetDataToWrite/test.ts | 19 + .../writingCardForm/useMutateReview/index.ts | 28 + .../writingCardForm/useMutateReview/test.tsx | 23 + .../writingCardForm/useNavigateBlocker.ts | 42 + .../writingCardForm/useResetFormRecoil.ts | 23 + .../writingCardForm/useSlideWidthAndHeight.ts | 37 + .../review/writingCardForm/useTextAnswer.ts | 47 + .../useUpdateDefaultAnswers.ts | 110 + .../useUpdateReviewerAnswer.ts | 49 + frontend/src/hooks/reviewGroup/index.ts | 2 + .../useCheckPasswordValidation/index.ts | 65 + .../useCheckPasswordValidation/test.ts | 64 + .../useGetReviewGroupData/index.ts | 29 + .../reviewGroup/useGetReviewGroupData/test.ts | 23 + frontend/src/hooks/useBreadcrumbPaths.ts | 38 + frontend/src/hooks/useEyeButton.tsx | 16 + frontend/src/hooks/useGroupAccessCode.ts | 18 + frontend/src/hooks/useLongReviewItem.ts | 45 + frontend/src/hooks/useModalClose.ts | 60 + frontend/src/hooks/useModals.ts | 29 + frontend/src/hooks/usePasswordValidation.ts | 40 + frontend/src/hooks/useReviewForm.ts | 48 + frontend/src/hooks/useSearchParamAndQuery.ts | 22 + frontend/src/hooks/useSidebar.ts | 29 + frontend/src/hooks/useTopButton.ts | 39 + frontend/src/index.tsx | 121 + frontend/src/mocks/browser.ts | 8 + frontend/src/mocks/handlers/group.ts | 77 + frontend/src/mocks/handlers/index.ts | 6 + frontend/src/mocks/handlers/review.ts | 88 + .../mocks/mockData/detailedReviewMockData.ts | 98 + frontend/src/mocks/mockData/group.ts | 18 + frontend/src/mocks/mockData/index.ts | 6 + .../src/mocks/mockData/reviewListMockData.ts | 115 + .../src/mocks/mockData/reviewWritingData.ts | 19 + .../writingCardForm/reviewFormResultData.ts | 14 + .../reviewWritingCardFormData.ts | 304 + frontend/src/mocks/server.ts | 7 + .../DetailedReviewPageContents/index.tsx | 48 + .../DetailedReviewPageContents/styles.ts | 12 + .../components/KeywordSection/index.tsx | 21 + .../components/KeywordSection/styles.ts | 15 + .../components/LockToggle/index.tsx | 47 + .../components/LockToggle/styles.ts | 40 + .../components/ReviewDescription/index.tsx | 48 + .../components/ReviewDescription/styles.ts | 55 + .../components/ReviewSection/index.tsx | 25 + .../components/ReviewSection/styles.ts | 20 + .../components/ReviewSectionHeader/index.tsx | 11 + .../components/ReviewSectionHeader/styles.ts | 7 + .../DetailedReviewPage/components/index.tsx | 4 + .../src/pages/DetailedReviewPage/index.tsx | 18 + frontend/src/pages/ErrorPage/index.tsx | 34 + .../HomePage/components/Carousel/index.tsx | 107 + .../HomePage/components/Carousel/styles.ts | 102 + .../components/CopyTextButton/index.tsx | 27 + .../components/CopyTextButton/styles.ts | 10 + .../HomePage/components/FormBody/index.tsx | 15 + .../HomePage/components/FormBody/styles.ts | 7 + .../HomePage/components/FormLayout/index.tsx | 23 + .../HomePage/components/FormLayout/styles.ts | 13 + .../components/ReviewMeOverview/index.tsx | 41 + .../components/ReviewMeOverview/styles.ts | 42 + .../components/ReviewZoneURLModal/index.tsx | 56 + .../components/ReviewZoneURLModal/styles.ts | 60 + .../components/URLGeneratorForm/index.tsx | 161 + .../components/URLGeneratorForm/styles.ts | 38 + .../src/pages/HomePage/components/index.ts | 7 + frontend/src/pages/HomePage/index.tsx | 17 + .../usePostDataForReviewRequestCode.test.tsx | 30 + .../usePostDataForReviewRequestCode.ts | 27 + frontend/src/pages/HomePage/styles.ts | 7 + .../HomePage/utils/validateInput.test.ts | 60 + .../src/pages/HomePage/utils/validateInput.ts | 30 + .../components/LoadingBar/index.tsx | 7 + .../components/LoadingBar/styles.ts | 41 + frontend/src/pages/LoadingPage/index.tsx | 13 + frontend/src/pages/LoadingPage/styles.ts | 21 + .../components/PageContents/index.tsx | 56 + .../components/PageContents/styles.ts | 14 + .../components/ReviewEmptySection/index.tsx | 12 + .../components/ReviewEmptySection/styles.ts | 18 + .../components/ReviewInfoSection/index.tsx | 22 + .../components/ReviewInfoSection/styles.ts | 19 + .../components/SearchSection/index.tsx | 23 + .../components/SearchSection/styles.ts | 18 + frontend/src/pages/ReviewListPage/index.tsx | 46 + .../components/QuestionCard/index.tsx | 14 + .../components/QuestionCard/styles.ts | 11 + .../components/ReviewCard/index.tsx | 20 + .../components/ReviewCard/styles.ts | 23 + .../AnswerListRecheckModal/index.tsx | 80 + .../AnswerListRecheckModal/styles.ts | 36 + .../components/CardForm/index.tsx | 211 + .../components/CardForm/styles.ts | 71 + .../components/CardSliderController/index.tsx | 93 + .../MultipleChoiceQuestion/index.tsx | 88 + .../MultipleChoiceQuestion/style.ts | 27 + .../components/NavigateBlockerModal/index.tsx | 40 + .../components/NavigateBlockerModal/style.ts | 13 + .../components/ProgressBar/index.tsx | 42 + .../components/ProgressBar/styles.ts | 55 + .../components/QnABox/index.tsx | 60 + .../components/QnABox/style.ts | 27 + .../components/ReviewWritingCard/index.tsx | 63 + .../components/ReviewWritingCard/style.ts | 34 + .../components/SubmitCheckModal/index.tsx | 35 + .../components/SubmitCheckModal/style.ts | 13 + .../components/index.tsx | 9 + .../pages/ReviewWritingCardFromPage/index.tsx | 12 + .../pages/ReviewWritingCompletePage/index.tsx | 28 + .../pages/ReviewWritingCompletePage/styles.ts | 32 + .../components/PasswordModal/index.tsx | 79 + .../components/PasswordModal/styles.ts | 37 + frontend/src/pages/ReviewZonePage/index.tsx | 87 + frontend/src/pages/ReviewZonePage/styles.ts | 55 + frontend/src/pages/index.tsx | 8 + .../src/queryTestSetup/QueryClientWrapper.tsx | 11 + frontend/src/recoil/groupAccessCode/index.ts | 8 + frontend/src/recoil/index.ts | 4 + frontend/src/recoil/keys/index.ts | 17 + .../src/recoil/reviewRequestCode/index.ts | 11 + frontend/src/recoil/reviewWritingForm/atom.ts | 49 + .../src/recoil/reviewWritingForm/index.ts | 2 + .../src/recoil/reviewWritingForm/selector.ts | 26 + frontend/src/styles/globalStyles.ts | 40 + frontend/src/styles/reset.ts | 117 + frontend/src/styles/theme.ts | 77 + frontend/src/types/emotion.ts | 38 + .../src/types/essentialPropsWithChildren.ts | 5 + frontend/src/types/index.ts | 6 + frontend/src/types/review.ts | 137 + frontend/src/types/reviewGroup.ts | 7 + frontend/src/types/styles.ts | 3 + frontend/src/types/theme.ts | 5 + frontend/src/utils/date.ts | 11 + frontend/src/utils/debounce.ts | 34 + frontend/src/utils/index.ts | 2 + frontend/src/utils/isExistentElement.ts | 18 + frontend/tempTest.test.ts | 4 + frontend/tsconfig.json | 23 + frontend/types.d.ts | 4 + frontend/webpack.config.js | 81 + frontend/yarn.lock | 8351 +++++++++++++++++ 449 files changed, 24018 insertions(+) create mode 100644 .github/workflows/backend-ci.yml create mode 100644 .github/workflows/backend-dev-cd.yml create mode 100644 .github/workflows/backend-prod-cd.yml create mode 100644 .github/workflows/discord-pull-request-comment.yml create mode 100644 .github/workflows/discord-pull-request.yml create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 .gitmodules create mode 100644 backend/.gitignore create mode 100644 backend/build.gradle create mode 100644 backend/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/gradlew create mode 100644 backend/gradlew.bat create mode 100644 backend/settings.gradle create mode 100644 backend/src/docs/asciidoc/create-review.adoc create mode 100644 backend/src/docs/asciidoc/get-review-form.adoc create mode 100644 backend/src/docs/asciidoc/index.adoc create mode 100644 backend/src/docs/asciidoc/review-detail.adoc create mode 100644 backend/src/docs/asciidoc/review-list.adoc create mode 100644 backend/src/docs/asciidoc/reviewgroup.adoc create mode 100644 backend/src/main/java/reviewme/DatabaseInitializer.java create mode 100644 backend/src/main/java/reviewme/ReviewMeApplication.java create mode 100644 backend/src/main/java/reviewme/config/CorsConfig.java create mode 100644 backend/src/main/java/reviewme/config/SwaggerConfig.java create mode 100644 backend/src/main/java/reviewme/config/WebConfig.java create mode 100644 backend/src/main/java/reviewme/config/properties/SwaggerProperties.java create mode 100644 backend/src/main/java/reviewme/global/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/reviewme/global/HeaderProperty.java create mode 100644 backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java create mode 100644 backend/src/main/java/reviewme/global/exception/BadRequestException.java create mode 100644 backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java create mode 100644 backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java create mode 100644 backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java create mode 100644 backend/src/main/java/reviewme/global/exception/NotFoundException.java create mode 100644 backend/src/main/java/reviewme/global/exception/ReviewMeException.java create mode 100644 backend/src/main/java/reviewme/global/exception/UnauthorizedException.java create mode 100644 backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java create mode 100644 backend/src/main/java/reviewme/question/domain/OptionGroup.java create mode 100644 backend/src/main/java/reviewme/question/domain/OptionItem.java create mode 100644 backend/src/main/java/reviewme/question/domain/OptionType.java create mode 100644 backend/src/main/java/reviewme/question/domain/Question.java create mode 100644 backend/src/main/java/reviewme/question/domain/QuestionType.java create mode 100644 backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java create mode 100644 backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java create mode 100644 backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java create mode 100644 backend/src/main/java/reviewme/question/repository/OptionItemRepository.java create mode 100644 backend/src/main/java/reviewme/question/repository/QuestionRepository.java create mode 100644 backend/src/main/java/reviewme/review/controller/ReviewController.java create mode 100644 backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java create mode 100644 backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java create mode 100644 backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java create mode 100644 backend/src/main/java/reviewme/review/domain/Review.java create mode 100644 backend/src/main/java/reviewme/review/domain/TextAnswer.java create mode 100644 backend/src/main/java/reviewme/review/domain/TextAnswers.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java create mode 100644 backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java create mode 100644 backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java create mode 100644 backend/src/main/java/reviewme/review/repository/ReviewRepository.java create mode 100644 backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java create mode 100644 backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java create mode 100644 backend/src/main/java/reviewme/review/service/CreateReviewService.java create mode 100644 backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java create mode 100644 backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java create mode 100644 backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java create mode 100644 backend/src/main/java/reviewme/review/service/ReviewService.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java create mode 100644 backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java create mode 100644 backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java create mode 100644 backend/src/main/java/reviewme/template/controller/TemplateController.java create mode 100644 backend/src/main/java/reviewme/template/domain/Section.java create mode 100644 backend/src/main/java/reviewme/template/domain/SectionQuestion.java create mode 100644 backend/src/main/java/reviewme/template/domain/Template.java create mode 100644 backend/src/main/java/reviewme/template/domain/TemplateSection.java create mode 100644 backend/src/main/java/reviewme/template/domain/VisibleType.java create mode 100644 backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java create mode 100644 backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java create mode 100644 backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java create mode 100644 backend/src/main/java/reviewme/template/repository/SectionRepository.java create mode 100644 backend/src/main/java/reviewme/template/repository/TemplateRepository.java create mode 100644 backend/src/main/java/reviewme/template/service/TemplateMapper.java create mode 100644 backend/src/main/java/reviewme/template/service/TemplateService.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java create mode 100644 backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java create mode 100644 backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java create mode 100644 backend/src/main/java/reviewme/util/Encoder.java create mode 100644 backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java create mode 100644 backend/src/main/resources/api-docs.yml create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/logback-spring.xml create mode 100644 backend/src/main/resources/logback.yml create mode 160000 backend/src/main/resources/secret create mode 100644 backend/src/test/java/reviewme/ReviewMeApplicationTests.java create mode 100644 backend/src/test/java/reviewme/api/ApiTest.java create mode 100644 backend/src/test/java/reviewme/api/ReviewApiTest.java create mode 100644 backend/src/test/java/reviewme/api/ReviewGroupApiTest.java create mode 100644 backend/src/test/java/reviewme/api/TemplateApiTest.java create mode 100644 backend/src/test/java/reviewme/api/TemplateFixture.java create mode 100644 backend/src/test/java/reviewme/config/TestConfig.java create mode 100644 backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java create mode 100644 backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java create mode 100644 backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java create mode 100644 backend/src/test/java/reviewme/review/domain/TextAnswersTest.java create mode 100644 backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java create mode 100644 backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java create mode 100644 backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java create mode 100644 backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java create mode 100644 backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java create mode 100644 backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java create mode 100644 backend/src/test/java/reviewme/review/service/ReviewServiceTest.java create mode 100644 backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java create mode 100644 backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java create mode 100644 backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java create mode 100644 backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java create mode 100644 backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java create mode 100644 backend/src/test/java/reviewme/support/DatabaseCleaner.java create mode 100644 backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java create mode 100644 backend/src/test/java/reviewme/support/ServiceTest.java create mode 100644 backend/src/test/java/reviewme/template/domain/SectionTest.java create mode 100644 backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java create mode 100644 backend/src/test/java/reviewme/template/service/TemplateMapperTest.java create mode 100644 backend/src/test/java/reviewme/template/service/TemplateServiceTest.java create mode 100644 backend/src/test/resources/application.yml create mode 100644 backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet create mode 100644 backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.gitignore create mode 100644 frontend/.prettierrc create mode 100644 frontend/.stylelintrc.json create mode 100644 frontend/babel.config.json create mode 100644 frontend/jest.config.js create mode 100644 frontend/jest.polyfills.js create mode 100644 frontend/jest.setup.js create mode 100644 frontend/package.json create mode 100644 frontend/public/index.html create mode 100644 frontend/public/mockServiceWorker.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/apis/apiErrorMessageCreator.ts create mode 100644 frontend/src/apis/endpoints.ts create mode 100644 frontend/src/apis/group.ts create mode 100644 frontend/src/apis/review.ts create mode 100644 frontend/src/assets/alertTriangle.svg create mode 100644 frontend/src/assets/alertTrianglePrimary.svg create mode 100644 frontend/src/assets/checked.svg create mode 100644 frontend/src/assets/clock.svg create mode 100644 frontend/src/assets/close.svg create mode 100644 frontend/src/assets/copy.svg create mode 100644 frontend/src/assets/downArrow.svg create mode 100644 frontend/src/assets/eye.svg create mode 100644 frontend/src/assets/eyeOff.svg create mode 100644 frontend/src/assets/githubLogo.svg create mode 100644 frontend/src/assets/lock.svg create mode 100644 frontend/src/assets/logo.svg create mode 100644 frontend/src/assets/menu.svg create mode 100644 frontend/src/assets/navigateNext.svg create mode 100644 frontend/src/assets/overviewTitle.svg create mode 100644 frontend/src/assets/primaryHome.svg create mode 100644 frontend/src/assets/primaryReload.svg create mode 100644 frontend/src/assets/reviewZone.svg create mode 100644 frontend/src/assets/unLock.svg create mode 100644 frontend/src/assets/unchecked.svg create mode 100644 frontend/src/assets/upperArrow.svg create mode 100644 frontend/src/assets/usageCarosel1.svg create mode 100644 frontend/src/assets/usageCarosel2.svg create mode 100644 frontend/src/assets/usageCarosel3.svg create mode 100644 frontend/src/assets/userProfile.svg create mode 100644 frontend/src/assets/whiteHome.svg create mode 100644 frontend/src/assets/whiteReload.svg create mode 100644 frontend/src/assets/x.svg create mode 100644 frontend/src/components/ReviewCard/index.tsx create mode 100644 frontend/src/components/ReviewCard/styles.ts create mode 100644 frontend/src/components/common/Breadcrumb/index.tsx create mode 100644 frontend/src/components/common/Breadcrumb/styles.ts create mode 100644 frontend/src/components/common/Button/index.tsx create mode 100644 frontend/src/components/common/Button/styles.ts create mode 100644 frontend/src/components/common/Checkbox/index.tsx create mode 100644 frontend/src/components/common/Checkbox/styles.ts create mode 100644 frontend/src/components/common/CheckboxItem/index.tsx create mode 100644 frontend/src/components/common/CheckboxItem/styles.ts create mode 100644 frontend/src/components/common/DropDown/index.tsx create mode 100644 frontend/src/components/common/DropDown/styles.ts create mode 100644 frontend/src/components/common/EyeButton/index.tsx create mode 100644 frontend/src/components/common/EyeButton/styles.ts create mode 100644 frontend/src/components/common/Input/index.tsx create mode 100644 frontend/src/components/common/Input/styles.ts create mode 100644 frontend/src/components/common/LongReviewItem/index.tsx create mode 100644 frontend/src/components/common/LongReviewItem/styles.ts create mode 100644 frontend/src/components/common/MultilineTextViewer/index.tsx create mode 100644 frontend/src/components/common/MultilineTextViewer/styles.ts create mode 100644 frontend/src/components/common/ProjectImg/index.tsx create mode 100644 frontend/src/components/common/ProjectImg/styles.ts create mode 100644 frontend/src/components/common/ReviewDate/index.tsx create mode 100644 frontend/src/components/common/ReviewDate/styles.ts create mode 100644 frontend/src/components/common/RevieweeComments/index.tsx create mode 100644 frontend/src/components/common/RevieweeComments/styles.ts create mode 100644 frontend/src/components/common/SearchInput/index.tsx create mode 100644 frontend/src/components/common/SearchInput/styles.ts create mode 100644 frontend/src/components/common/TopButton/index.tsx create mode 100644 frontend/src/components/common/TopButton/style.ts create mode 100644 frontend/src/components/common/index.tsx create mode 100644 frontend/src/components/common/modals/AlertModal/index.tsx create mode 100644 frontend/src/components/common/modals/AlertModal/styles.ts create mode 100644 frontend/src/components/common/modals/ConfirmModal/index.tsx create mode 100644 frontend/src/components/common/modals/ConfirmModal/styles.ts create mode 100644 frontend/src/components/common/modals/ContentModal/index.tsx create mode 100644 frontend/src/components/common/modals/ContentModal/styles.ts create mode 100644 frontend/src/components/common/modals/ErrorAlertModal/index.tsx create mode 100644 frontend/src/components/common/modals/ErrorAlertModal/styles.ts create mode 100644 frontend/src/components/common/modals/LoginRedirectModal/index.tsx create mode 100644 frontend/src/components/common/modals/ModalBackground/index.tsx create mode 100644 frontend/src/components/common/modals/ModalBackground/styles.ts create mode 100644 frontend/src/components/common/modals/ModalPortal/index.tsx create mode 100644 frontend/src/components/common/modals/ModalPortal/styles.ts create mode 100644 frontend/src/components/common/modals/SideModal/index.tsx create mode 100644 frontend/src/components/common/modals/SideModal/styles.ts create mode 100644 frontend/src/components/common/modals/index.tsx create mode 100644 frontend/src/components/error/ErrorFallback/index.tsx create mode 100644 frontend/src/components/error/ErrorSection/index.tsx create mode 100644 frontend/src/components/error/ErrorSection/styles.ts create mode 100644 frontend/src/components/error/ErrorSuspenseContainer/index.tsx create mode 100644 frontend/src/components/error/index.tsx create mode 100644 frontend/src/components/index.tsx create mode 100644 frontend/src/components/layouts/Footer/index.tsx create mode 100644 frontend/src/components/layouts/Footer/style.ts create mode 100644 frontend/src/components/layouts/Main/index.tsx create mode 100644 frontend/src/components/layouts/Main/styles.ts create mode 100644 frontend/src/components/layouts/PageLayout/index.tsx create mode 100644 frontend/src/components/layouts/PageLayout/styles.ts create mode 100644 frontend/src/components/layouts/Sidebar/index.tsx create mode 100644 frontend/src/components/layouts/Sidebar/styles.ts create mode 100644 frontend/src/components/layouts/Topbar/components/Logo/index.tsx create mode 100644 frontend/src/components/layouts/Topbar/components/Logo/styles.ts create mode 100644 frontend/src/components/layouts/Topbar/components/SidebarOpenButton/index.tsx create mode 100644 frontend/src/components/layouts/Topbar/components/SidebarOpenButton/styles.ts create mode 100644 frontend/src/components/layouts/Topbar/index.tsx create mode 100644 frontend/src/components/layouts/Topbar/styles.ts create mode 100644 frontend/src/components/layouts/index.tsx create mode 100644 frontend/src/constants/errorMessage.ts create mode 100644 frontend/src/constants/index.ts create mode 100644 frontend/src/constants/page.ts create mode 100644 frontend/src/constants/queryKey.ts create mode 100644 frontend/src/constants/review.ts create mode 100644 frontend/src/constants/route.ts create mode 100644 frontend/src/constants/routerParam.ts create mode 100644 frontend/src/constants/system.ts create mode 100644 frontend/src/favicons/favicon.ico create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/hooks/review/index.ts create mode 100644 frontend/src/hooks/review/useGetDetailedReview/index.ts create mode 100644 frontend/src/hooks/review/useGetDetailedReview/test.ts create mode 100644 frontend/src/hooks/review/useGetReviewList/index.ts create mode 100644 frontend/src/hooks/review/useGetReviewList/test.tsx create mode 100644 frontend/src/hooks/review/writingCardForm/index.ts create mode 100644 frontend/src/hooks/review/writingCardForm/multipleChoice/useAboveSelectionLimit.ts create mode 100644 frontend/src/hooks/review/writingCardForm/multipleChoice/useCancelAnsweredCategory.ts create mode 100644 frontend/src/hooks/review/writingCardForm/multipleChoice/useMultipleChoice.ts create mode 100644 frontend/src/hooks/review/writingCardForm/multipleChoice/useUpdateMultipleChoiceAnswer.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useCardSectionList.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useCheckNextStepAvailability.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useCurrentCardIndex.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useGetDataToWrite/index.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useGetDataToWrite/test.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useMutateReview/index.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useMutateReview/test.tsx create mode 100644 frontend/src/hooks/review/writingCardForm/useNavigateBlocker.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useResetFormRecoil.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useSlideWidthAndHeight.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useTextAnswer.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useUpdateDefaultAnswers.ts create mode 100644 frontend/src/hooks/review/writingCardForm/useUpdateReviewerAnswer.ts create mode 100644 frontend/src/hooks/reviewGroup/index.ts create mode 100644 frontend/src/hooks/reviewGroup/useCheckPasswordValidation/index.ts create mode 100644 frontend/src/hooks/reviewGroup/useCheckPasswordValidation/test.ts create mode 100644 frontend/src/hooks/reviewGroup/useGetReviewGroupData/index.ts create mode 100644 frontend/src/hooks/reviewGroup/useGetReviewGroupData/test.ts create mode 100644 frontend/src/hooks/useBreadcrumbPaths.ts create mode 100644 frontend/src/hooks/useEyeButton.tsx create mode 100644 frontend/src/hooks/useGroupAccessCode.ts create mode 100644 frontend/src/hooks/useLongReviewItem.ts create mode 100644 frontend/src/hooks/useModalClose.ts create mode 100644 frontend/src/hooks/useModals.ts create mode 100644 frontend/src/hooks/usePasswordValidation.ts create mode 100644 frontend/src/hooks/useReviewForm.ts create mode 100644 frontend/src/hooks/useSearchParamAndQuery.ts create mode 100644 frontend/src/hooks/useSidebar.ts create mode 100644 frontend/src/hooks/useTopButton.ts create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/mocks/browser.ts create mode 100644 frontend/src/mocks/handlers/group.ts create mode 100644 frontend/src/mocks/handlers/index.ts create mode 100644 frontend/src/mocks/handlers/review.ts create mode 100644 frontend/src/mocks/mockData/detailedReviewMockData.ts create mode 100644 frontend/src/mocks/mockData/group.ts create mode 100644 frontend/src/mocks/mockData/index.ts create mode 100644 frontend/src/mocks/mockData/reviewListMockData.ts create mode 100644 frontend/src/mocks/mockData/reviewWritingData.ts create mode 100644 frontend/src/mocks/mockData/writingCardForm/reviewFormResultData.ts create mode 100644 frontend/src/mocks/mockData/writingCardForm/reviewWritingCardFormData.ts create mode 100644 frontend/src/mocks/server.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/components/DetailedReviewPageContents/styles.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/KeywordSection/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/components/KeywordSection/styles.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/LockToggle/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/components/LockToggle/styles.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/ReviewDescription/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/components/ReviewDescription/styles.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/ReviewSection/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/components/ReviewSection/styles.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/components/ReviewSectionHeader/styles.ts create mode 100644 frontend/src/pages/DetailedReviewPage/components/index.tsx create mode 100644 frontend/src/pages/DetailedReviewPage/index.tsx create mode 100644 frontend/src/pages/ErrorPage/index.tsx create mode 100644 frontend/src/pages/HomePage/components/Carousel/index.tsx create mode 100644 frontend/src/pages/HomePage/components/Carousel/styles.ts create mode 100644 frontend/src/pages/HomePage/components/CopyTextButton/index.tsx create mode 100644 frontend/src/pages/HomePage/components/CopyTextButton/styles.ts create mode 100644 frontend/src/pages/HomePage/components/FormBody/index.tsx create mode 100644 frontend/src/pages/HomePage/components/FormBody/styles.ts create mode 100644 frontend/src/pages/HomePage/components/FormLayout/index.tsx create mode 100644 frontend/src/pages/HomePage/components/FormLayout/styles.ts create mode 100644 frontend/src/pages/HomePage/components/ReviewMeOverview/index.tsx create mode 100644 frontend/src/pages/HomePage/components/ReviewMeOverview/styles.ts create mode 100644 frontend/src/pages/HomePage/components/ReviewZoneURLModal/index.tsx create mode 100644 frontend/src/pages/HomePage/components/ReviewZoneURLModal/styles.ts create mode 100644 frontend/src/pages/HomePage/components/URLGeneratorForm/index.tsx create mode 100644 frontend/src/pages/HomePage/components/URLGeneratorForm/styles.ts create mode 100644 frontend/src/pages/HomePage/components/index.ts create mode 100644 frontend/src/pages/HomePage/index.tsx create mode 100644 frontend/src/pages/HomePage/queries/usePostDataForReviewRequestCode.test.tsx create mode 100644 frontend/src/pages/HomePage/queries/usePostDataForReviewRequestCode.ts create mode 100644 frontend/src/pages/HomePage/styles.ts create mode 100644 frontend/src/pages/HomePage/utils/validateInput.test.ts create mode 100644 frontend/src/pages/HomePage/utils/validateInput.ts create mode 100644 frontend/src/pages/LoadingPage/components/LoadingBar/index.tsx create mode 100644 frontend/src/pages/LoadingPage/components/LoadingBar/styles.ts create mode 100644 frontend/src/pages/LoadingPage/index.tsx create mode 100644 frontend/src/pages/LoadingPage/styles.ts create mode 100644 frontend/src/pages/ReviewListPage/components/PageContents/index.tsx create mode 100644 frontend/src/pages/ReviewListPage/components/PageContents/styles.ts create mode 100644 frontend/src/pages/ReviewListPage/components/ReviewEmptySection/index.tsx create mode 100644 frontend/src/pages/ReviewListPage/components/ReviewEmptySection/styles.ts create mode 100644 frontend/src/pages/ReviewListPage/components/ReviewInfoSection/index.tsx create mode 100644 frontend/src/pages/ReviewListPage/components/ReviewInfoSection/styles.ts create mode 100644 frontend/src/pages/ReviewListPage/components/SearchSection/index.tsx create mode 100644 frontend/src/pages/ReviewListPage/components/SearchSection/styles.ts create mode 100644 frontend/src/pages/ReviewListPage/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/AnswerListRecheckModal/components/QuestionCard/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/AnswerListRecheckModal/components/QuestionCard/styles.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/AnswerListRecheckModal/components/ReviewCard/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/AnswerListRecheckModal/components/ReviewCard/styles.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/AnswerListRecheckModal/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/AnswerListRecheckModal/styles.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/CardForm/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/CardForm/styles.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/CardSliderController/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/MultipleChoiceQuestion/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/MultipleChoiceQuestion/style.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/NavigateBlockerModal/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/NavigateBlockerModal/style.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/ProgressBar/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/ProgressBar/styles.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/QnABox/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/QnABox/style.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/ReviewWritingCard/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/ReviewWritingCard/style.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/SubmitCheckModal/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/SubmitCheckModal/style.ts create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/components/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCardFromPage/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCompletePage/index.tsx create mode 100644 frontend/src/pages/ReviewWritingCompletePage/styles.ts create mode 100644 frontend/src/pages/ReviewZonePage/components/PasswordModal/index.tsx create mode 100644 frontend/src/pages/ReviewZonePage/components/PasswordModal/styles.ts create mode 100644 frontend/src/pages/ReviewZonePage/index.tsx create mode 100644 frontend/src/pages/ReviewZonePage/styles.ts create mode 100644 frontend/src/pages/index.tsx create mode 100644 frontend/src/queryTestSetup/QueryClientWrapper.tsx create mode 100644 frontend/src/recoil/groupAccessCode/index.ts create mode 100644 frontend/src/recoil/index.ts create mode 100644 frontend/src/recoil/keys/index.ts create mode 100644 frontend/src/recoil/reviewRequestCode/index.ts create mode 100644 frontend/src/recoil/reviewWritingForm/atom.ts create mode 100644 frontend/src/recoil/reviewWritingForm/index.ts create mode 100644 frontend/src/recoil/reviewWritingForm/selector.ts create mode 100644 frontend/src/styles/globalStyles.ts create mode 100644 frontend/src/styles/reset.ts create mode 100644 frontend/src/styles/theme.ts create mode 100644 frontend/src/types/emotion.ts create mode 100644 frontend/src/types/essentialPropsWithChildren.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/types/review.ts create mode 100644 frontend/src/types/reviewGroup.ts create mode 100644 frontend/src/types/styles.ts create mode 100644 frontend/src/types/theme.ts create mode 100644 frontend/src/utils/date.ts create mode 100644 frontend/src/utils/debounce.ts create mode 100644 frontend/src/utils/index.ts create mode 100644 frontend/src/utils/isExistentElement.ts create mode 100644 frontend/tempTest.test.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/types.d.ts create mode 100644 frontend/webpack.config.js create mode 100644 frontend/yarn.lock diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 000000000..2a38744c1 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,50 @@ +name: Build test with Gradle + +on: + push: + branches: + - develop + paths: + - 'backend/**' + pull_request: + branches: + - develop + paths: + - 'backend/**' + +jobs: + build: + permissions: + contents: read + issues: read + checks: write + pull-requests: write + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew test + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: | + ./backend/build/test-results/**/*.xml diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml new file mode 100644 index 000000000..c6c91e2d9 --- /dev/null +++ b/.github/workflows/backend-dev-cd.yml @@ -0,0 +1,98 @@ +name: "[DEVELOP] CD using Github self-hosted runner" + +on: + workflow_dispatch: + push: + branches: + - develop + paths: + - 'backend/**' + +env: + ARTIFACT_NAME: review-me-dev + ARTIFACT_DIRECTORY: ./backend/build/libs + APPLICATION_DIRECTORY: ~/review-me-app + +jobs: + build: + name: Build Jar file and upload artifact + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew clean bootJar + + - name: Rename artifact file + run: | + mv ${{ env.ARTIFACT_DIRECTORY }}/*.jar ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + - name: Upload created artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + deploy: + name: Deploy via self-hosted runner + needs: build + runs-on: [self-hosted, dev] + + steps: + - name: Checkout to secret repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPOSITORY_URL }} + token: ${{ secrets.PRIVATE_REPOSITORY_TOKEN }} + + - name: Download uploaded artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + + - name: Copy application related files to other directory + run: | + sudo mv * ${{ env.APPLICATION_DIRECTORY }} + + - name: Find ${{ env.ARTIFACT_NAME }} process + run: | + echo "Checking processes..." + PID=$(pgrep -f ${{ env.ARTIFACT_NAME }}.jar -d " " || true) + if [ -n "$PID" ]; then + echo "Found processes: $PID" + echo "server_running=true" >> "$GITHUB_ENV" + echo "PID=$PID" >> "$GITHUB_ENV" + else + echo "Process not found!" + echo "server_running=false" >> "$GITHUB_ENV" + fi + + - name: Stop server if available (gracefully) + if: env.server_running == 'true' + run: | + echo "Gracefully shutting down process ${{ env.PID }}" + for PID in ${{ env.PID }}; do + sudo kill -15 $PID | true + tail --pid=$PID -f /dev/null | true + done + + - name: Start server + run: | + cd ${{ env.APPLICATION_DIRECTORY }} + sudo nohup java -jar ${{ env.ARTIFACT_NAME }}.jar --server.port=8080 --spring.config.location=application-dev.yml & diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml new file mode 100644 index 000000000..4b3a6bcf6 --- /dev/null +++ b/.github/workflows/backend-prod-cd.yml @@ -0,0 +1,98 @@ +name: "[RELEASE] CD using Github self-hosted runner" + +on: + workflow_dispatch: + push: + branches: + - release + paths: + - 'backend/**' + +env: + ARTIFACT_NAME: review-me-prod + ARTIFACT_DIRECTORY: ./backend/build/libs + APPLICATION_DIRECTORY: ~/review-me-app + +jobs: + build: + name: Build Jar file and upload artifact + runs-on: ubuntu-latest + + steps: + - name: Checkout to current repository + uses: actions/checkout@v4 + + - name: Setup JDK Corretto using cached gradle dependencies + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + with: + gradle-version: 8.8 + + - name: Build and test with gradle + run: | + cd ./backend + ./gradlew clean bootJar + + - name: Rename artifact file + run: | + mv ${{ env.ARTIFACT_DIRECTORY }}/*.jar ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + - name: Upload created artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + path: ${{ env.ARTIFACT_DIRECTORY }}/${{ env.ARTIFACT_NAME }}.jar + + deploy: + name: Deploy via self-hosted runner + needs: build + runs-on: [self-hosted, prod] + + steps: + - name: Checkout to secret repository + uses: actions/checkout@v4 + with: + repository: ${{ secrets.PRIVATE_REPOSITORY_URL }} + token: ${{ secrets.PRIVATE_REPOSITORY_TOKEN }} + + - name: Download uploaded artifact + uses: actions/download-artifact@v4 + with: + name: ${{ env.ARTIFACT_NAME }} + + - name: Copy application related files to other directory + run: | + sudo mv * ${{ env.APPLICATION_DIRECTORY }} + + - name: Find ${{ env.ARTIFACT_NAME }} process + run: | + echo "Checking processes..." + PID=$(pgrep -f ${{ env.ARTIFACT_NAME }}.jar -d " " || true) + if [ -n "$PID" ]; then + echo "Found processes: $PID" + echo "server_running=true" >> "$GITHUB_ENV" + echo "PID=$PID" >> "$GITHUB_ENV" + else + echo "Process not found!" + echo "server_running=false" >> "$GITHUB_ENV" + fi + + - name: Stop server if available (gracefully) + if: env.server_running == 'true' + run: | + echo "Gracefully shutting down process ${{ env.PID }}" + for PID in ${{ env.PID }}; do + sudo kill -15 $PID | true + tail --pid=$PID -f /dev/null | true + done + + - name: Start server + run: | + cd ${{ env.APPLICATION_DIRECTORY }} + sudo nohup java -jar ${{ env.ARTIFACT_NAME }}.jar --server.port=8080 --spring.config.location=application-prod.yml & diff --git a/.github/workflows/discord-pull-request-comment.yml b/.github/workflows/discord-pull-request-comment.yml new file mode 100644 index 000000000..e1a3fc36b --- /dev/null +++ b/.github/workflows/discord-pull-request-comment.yml @@ -0,0 +1,106 @@ +name: Mention Discord on Pull Request Review + +on: + pull_request_review: + types: [ submitted ] + +env: + "31026350": "206298119661420544" + "64690761": "243991296060948491" + "69838872": "1165830186990850110" + "111052302": "859318944195149855" + "145949635": "1164111111193366580" + "76177848": "710749110570975243" + "110809927": "971312723260493834" + "80167893": "1162754699099906169" + "backend": "1263405654534525051" + "frontend": "1263406763382931467" + +jobs: + notify-on-pr: + runs-on: ubuntu-latest + steps: + - name: Find prefix for PR title + run: | + echo "Finding prefix for PR title" + PR_TITLE='${{ github.event.pull_request.title }}' + PR_PREFIX=$(echo $PR_TITLE | cut -d ' ' -f1) + if [ "$PR_PREFIX" = '[BE]' ]; then + echo Backend PR Found! + echo "PR_PREFIX=BE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[FE]' ]; then + echo Frontend PR Found! + echo "PR_PREFIX=FE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[All]' ]; then + echo All PR Found! + echo "PR_PREFIX=All" >> $GITHUB_ENV + fi + echo PR Prefix : $PR_PREFIX + echo PR Prefix on env : ${{ env.PR_PREFIX }} + + + - name: Notify on PR Review + if: github.event.review.state == 'approved' || github.event.review.state == 'changes_requested' + run: | + echo "Notify on Discord" + + PR_URL='${{ github.event.pull_request.html_url }}' + PR_TITLE='${{ github.event.pull_request.title }}' + PR_AUTHOR='${{ github.event.pull_request.user.login }}' + REVIEWER='${{ github.event.review.user.login }}' + + REVIEWER_DISCORD_ID='${{ env[github.event.review.user.id] }}' + AUTHOR_DISCORD_ID='${{ env[github.event.pull_request.user.id] }}' + + if [ "${{ env.PR_PREFIX }}" = 'BE' ]; then + WEBHOOK_URL=${{ secrets.DISCORD_BE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'FE' ]; then + WEBHOOK_URL=${{ secrets.DISCORD_FE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'All' ]; then + WEBHOOK_URL=${{ secrets.DISCORD_ALL_PR_WEBHOOK_URL }} + fi + + if [ "${{ github.event.review.state }}" = 'approved' ]; then + COMMENT="PR Approved 되었습니다 🚀" + COLOR=65305 + elif [ "${{ github.event.review.state }}" = 'changes_requested' ]; then + COMMENT="PR에 수정 요구사항이 있습니다 👀" + COLOR=16736293 + else + echo "Invalid review state" + exit 0 + fi + + JSON_FILE=$(mktemp) + cat > $JSON_FILE < $COMMENT", + "embeds": [ + { + "author": { + "name": "$PR_AUTHOR", + "icon_url": "https://github.com/$PR_AUTHOR.png" + }, + "title": "$PR_TITLE", + "url": "$PR_URL", + "color": $COLOR, + "footer": { + "text": "2024-review-me" + }, + "fields": [ + { + "name": "리뷰어", + "value": "<@$REVIEWER_DISCORD_ID>", + "inline": true + } + ], + "timestamp": "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + } + ] + } + EOF + cat $JSON_FILE + curl -X POST -H 'Content-type: application/json' \ + --data @$JSON_FILE \ + $WEBHOOK_URL + rm $JSON_FILE diff --git a/.github/workflows/discord-pull-request.yml b/.github/workflows/discord-pull-request.yml new file mode 100644 index 000000000..52b446ebb --- /dev/null +++ b/.github/workflows/discord-pull-request.yml @@ -0,0 +1,85 @@ +name: Mention Discord on Pull Request + +on: + pull_request: + types: [ opened, reopened ] # PR이 열렸을 때에만 작동합니다. + +env: + "31026350": "206298119661420544" + "64690761": "243991296060948491" + "69838872": "1165830186990850110" + "111052302": "859318944195149855" + "145949635": "1164111111193366580" + "76177848": "710749110570975243" + "110809927": "971312723260493834" + "80167893": "1162754699099906169" + "backend": "1263405654534525051" + "frontend": "1263406763382931467" + +jobs: + notify-on-pr: + runs-on: ubuntu-latest + steps: + - name: Find prefix for PR title + run: | + echo "Finding prefix for PR title" + PR_TITLE='${{ github.event.pull_request.title }}' + PR_PREFIX=$(echo $PR_TITLE | cut -d ' ' -f1) + if [ "$PR_PREFIX" = '[BE]' ]; then + echo Backend PR Found! + echo "PR_PREFIX=BE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[FE]' ]; then + echo Frontend PR Found! + echo "PR_PREFIX=FE" >> $GITHUB_ENV + elif [ "$PR_PREFIX" = '[All]' ]; then + echo All PR Found! + echo "PR_PREFIX=All" >> $GITHUB_ENV + fi + echo PR Prefix : $PR_PREFIX + + - name: Notify on PR + if: env.PR_PREFIX == 'BE' || env.PR_PREFIX == 'FE' || env.PR_PREFIX == 'All' + run: | + echo "Notify on Discord" + + PR_URL='${{ github.event.pull_request.html_url }}' + PR_TITLE='${{ github.event.pull_request.title }}' + PR_AUTHOR='${{ github.event.sender.login }}' + DISCORD_ID='${{ env[github.event.sender.id] }}' + if [ "${{ env.PR_PREFIX }}" = 'BE' ]; then + NOTIFY_CONTENT="<@&${{ env.backend }}>" + WEBHOOK_URL=${{ secrets.DISCORD_BE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'FE' ]; then + NOTIFY_CONTENT="<@&${{ env.frontend }}>" + WEBHOOK_URL=${{ secrets.DISCORD_FE_PR_WEBHOOK_URL }} + elif [ "${{ env.PR_PREFIX }}" = 'All' ]; then + NOTIFY_CONTENT="<@&${{ env.backend }}> <@&${{ env.frontend }}>" + WEBHOOK_URL=${{ secrets.DISCORD_ALL_PR_WEBHOOK_URL }} + fi + + JSON_FILE=$(mktemp) + cat > $JSON_FILE < ./frontend/.env + + - name: Set environment file permissions + run: chmod 644 ./frontend/.env + + - name: Install dependencies + run: yarn install --frozen-lockfile + working-directory: frontend + + - name: Run tests + run: yarn test + working-directory: frontend + + - name: Build + run: yarn build + env: + API_BASE_URL: ${{ secrets.API_BASE_URL }} + working-directory: frontend diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..f3f31fc6a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "backend/src/main/resources/secret"] + path = backend/src/main/resources/secret + url = https://github.com/woowacourse-teams/2024-review-me-secret.git diff --git a/README.md b/README.md index 40c5f4211..c7e1b40ba 100644 --- a/README.md +++ b/README.md @@ -1 +1,19 @@ # 리뷰미 + +> 🤔 우리 팀원은 나를 어떻게 생각할까? +> 🫂 나와 팀이 함께 성장하려면 어떻게 해야 할까? +> 🤨 팀원에게 하고 싶은 말이 있는데, 대면으로 하기가 민망하네.. +> 🥹 기능 구현 하기에도 바빠서 문화를 챙길 시간도 없고, 팀원들한테 이런거 하자고 하기도 부담스러워... + +저희도 스스로가 팀에서 어떤 존재인지 고민될 때가 있습니다. + +동료의 피드백을 통해 저희는 자신의 강점과 팀에 어떻게 기여할 수 있는지를 알게 되었습니다. +지칠 때 받은 동료의 리뷰가 큰 힘이 되었어요. 팀원 모두가 서로를 응원하니 자연스럽게 팀워크도 향상됐습니다. + +리뷰미는 동료로부터 기술뿐만 아니라 소프트 스킬, 나의 특징 등을 다방면으로 리뷰 받을 수 있는 서비스입니다. +리뷰미를 통해 협업하는 내 모습을 알아갈 수 있고, 나아가 함께 성장하는 방식을 고민할 수 있습니다. +어쩌면 내가 몰랐던 내 모습을 발견할 수도 있겠죠? + +여러분들도 리뷰를 통한 좋은 경험을 해보고 싶으시다면, +리뷰를 통해 누군가에게 응원을 전달하고 싶으시다면, +리뷰미와 함께하세요! diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 000000000..cfab09856 --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,85 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.1' + id 'io.spring.dependency-management' version '1.1.5' + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +group = 'review-me' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + asciidoctorExt +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // RestDocs + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:3.0.1' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:3.0.1' + testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0' + testImplementation 'io.rest-assured:rest-assured:5.4.0' +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + useJUnitPlatform() + outputs.dir snippetsDir +} + +asciidoctor { + configurations 'asciidoctorExt' + + sources { + include("**/index.adoc") + } + baseDirFollowsSourceFile() + + inputs.dir snippetsDir + dependsOn test +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + +tasks.register('copyRestDocs', Copy) { + dependsOn asciidoctor + from file('build/docs/asciidoc') + into file('src/main/resources/static/docs') +} + +build { + dependsOn copyRestDocs +} diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 000000000..25da30dbd --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 000000000..0f5036dcc --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/backend/src/docs/asciidoc/create-review.adoc b/backend/src/docs/asciidoc/create-review.adoc new file mode 100644 index 000000000..7b3464613 --- /dev/null +++ b/backend/src/docs/asciidoc/create-review.adoc @@ -0,0 +1,7 @@ +==== 리뷰 생성 + +operation::create-review[snippets="curl-request,request-fields,http-response"] + +==== 그룹 코드가 올바르지 않은 경우 + +operation::create-review-invalid-review-request-code[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/get-review-form.adoc b/backend/src/docs/asciidoc/get-review-form.adoc new file mode 100644 index 000000000..3d8a9ddee --- /dev/null +++ b/backend/src/docs/asciidoc/get-review-form.adoc @@ -0,0 +1,7 @@ +==== 리뷰 작성을 위한 폼 가져오기 + +operation::get-review-form[snippets="curl-request,http-response,response-fields"] + +==== 그룹 코드가 올바르지 않은 경우 + +operation::get-review-form-not-found[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..d94d361b6 --- /dev/null +++ b/backend/src/docs/asciidoc/index.adoc @@ -0,0 +1,31 @@ += 리뷰미 Rest API Docs +:doctype: book +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectlinks: +:sectnums: 2 + +== 리뷰 그룹 + +include::reviewgroup.adoc[] + +== 리뷰 작성 + +=== 리뷰 작성을 위한 질문지 조회 + +include::get-review-form.adoc[] + +=== 리뷰 작성 + +include::create-review.adoc[] + +== 리뷰 조회 + +=== 리뷰 단건 조회 + +include::review-list.adoc[] + +=== 리뷰 목록 조회 + +include::review-detail.adoc[] diff --git a/backend/src/docs/asciidoc/review-detail.adoc b/backend/src/docs/asciidoc/review-detail.adoc new file mode 100644 index 000000000..26317af36 --- /dev/null +++ b/backend/src/docs/asciidoc/review-detail.adoc @@ -0,0 +1,7 @@ +==== 리뷰 단건 조회 + +operation::review-detail[snippets="curl-request,request-headers,path-parameters,http-response,response-fields"] + +==== 접근 코드가 올바르지 않은 경우 + +operation::review-detail-invalid-group-access-code[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/review-list.adoc b/backend/src/docs/asciidoc/review-list.adoc new file mode 100644 index 000000000..5c2694073 --- /dev/null +++ b/backend/src/docs/asciidoc/review-list.adoc @@ -0,0 +1,7 @@ +==== 자신이 받은 리뷰 목록 조회 + +operation::received-reviews[snippets="curl-request,request-headers,http-response,response-fields"] + +==== 접근 코드가 올바르지 않은 경우 + +operation::received-reviews-invalid-group-access-code[snippets="http-response"] diff --git a/backend/src/docs/asciidoc/reviewgroup.adoc b/backend/src/docs/asciidoc/reviewgroup.adoc new file mode 100644 index 000000000..666d1e862 --- /dev/null +++ b/backend/src/docs/asciidoc/reviewgroup.adoc @@ -0,0 +1,11 @@ +==== 리뷰 그룹 생성 + +operation::review-group-create[snippets="curl-request,request-fields,http-response,response-fields"] + +==== 리뷰 그룹 간단 정보 조회 + +operation::review-group-summary[snippets="curl-request,request-headers,http-response,response-fields"] + +==== 리뷰 요청 코드, 확인 코드 일치 여부 + +operation::review-group-check-access[snippets="curl-request,request-fields,http-response,response-fields"] diff --git a/backend/src/main/java/reviewme/DatabaseInitializer.java b/backend/src/main/java/reviewme/DatabaseInitializer.java new file mode 100644 index 000000000..9789b2d5c --- /dev/null +++ b/backend/src/main/java/reviewme/DatabaseInitializer.java @@ -0,0 +1,148 @@ +package reviewme; + +import jakarta.annotation.PostConstruct; +import jakarta.transaction.Transactional; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@Profile({"local", "dev"}) +@Component +@RequiredArgsConstructor +public class DatabaseInitializer { + + private static final String CATEGORY_HEADER = "이제, 선택한 순간들을 바탕으로 {revieweeName}에 대한 리뷰를 작성해볼게요."; + private static final String CATEGORY_TEXT_QUESTION = "위에서 선택한 사항과 관련된 경험을 구체적으로 적어 주세요."; + private static final int KEYWORD_CHECKBOX_MIN_COUNT = 1; + private static final int KEYWORD_CHECKBOX_MAX_COUNT = 2; + + private final QuestionRepository questionRepository; + private final OptionItemRepository optionItemRepository; + private final OptionGroupRepository optionGroupRepository; + private final SectionRepository sectionRepository; + private final TemplateRepository templateRepository; + + @PostConstruct + @Transactional + void setup() { + // 템플릿이 이미 존재하면 종료 + if (!templateRepository.findAll().isEmpty()) { + return; + } + + // 카테고리 선택 섹션 + long categoryQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "프로젝트 기간 동안, {revieweeName}의 강점이 드러났던 순간을 선택해주세요.", null, 1)).getId(); + long categorySectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(categoryQuestionId), null, "강점 발견", "{revieweeName}와 함께 한 기억을 떠올려볼게요.", 1)).getId(); + long categoryOptionGroupId = optionGroupRepository.save(new OptionGroup(categoryQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + long communicationOptionId = optionItemRepository.save(new OptionItem("🗣️커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)", categoryOptionGroupId, 1, OptionType.CATEGORY)).getId(); + long problemSolvingOptionId = optionItemRepository.save(new OptionItem("💡문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)",categoryOptionGroupId,2, OptionType.CATEGORY )).getId(); + long timeManagingOptionId = optionItemRepository.save(new OptionItem("⏰시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)",categoryOptionGroupId,3, OptionType.CATEGORY )).getId(); + long technicalOptionId = optionItemRepository.save(new OptionItem("💻기술적 역량, 전문 지식 (ex: 요구 사항을 이해하고 이를 구현하는 능력)",categoryOptionGroupId,4, OptionType.CATEGORY )).getId(); + long growthOptionId = optionItemRepository.save(new OptionItem("🌱성장 마인드셋 (ex: 새로운 분야나 잘 모르는 분야에 도전하는 마음, 꾸준한 노력으로 프로젝트 이전보다 성장하는 모습)",categoryOptionGroupId,5, OptionType.CATEGORY )).getId(); + + // 커뮤니케이션 능력 섹션 + long checkBoxCommunicationQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textCommunicationQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. {revieweeName} 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요.", 2)).getId(); + long communicationSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxCommunicationQuestionId, textCommunicationQuestionId), communicationOptionId, "커뮤니케이션 능력", CATEGORY_HEADER, 2)).getId(); + long communicationOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxCommunicationQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.",communicationOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.",communicationOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀의 분위기를 주도해요.",communicationOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("주장을 이야기할 때에는 합당한 근거가 뒤따라요.",communicationOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀에게 필요한 것과 그렇지 않은 것을 잘 구분해요.",communicationOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("팀 내 주어진 요구사항에 우선순위를 잘 매겨요.",communicationOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("서로 다른 분야간의 소통도 중요하게 생각해요.",communicationOptionGroupId,7, OptionType.KEYWORD )); + + // 문제해결 능력 섹션 + long checkBoxProblemSolvingQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "문제해결 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textProblemSolvingQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. 어떤 문제 상황이 발생했고, {revieweeName}이/가 어떻게 해결했는지 그 과정을 떠올려 보세요.", 2)).getId(); + long problemSolvingSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxProblemSolvingQuestionId, textProblemSolvingQuestionId), problemSolvingOptionId, "문제해결 능력", CATEGORY_HEADER, 3)).getId(); + long problemSolvingOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxProblemSolvingQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("큰 문제를 작은 단위로 쪼개서 단계별로 해결해나가요.",problemSolvingOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("낯선 문제를 만나도 당황하지 않고 차분하게 풀어나가요.",problemSolvingOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제 해결을 위해 GPT등의 자원을 적극적으로 활용해요.",problemSolvingOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제를 해결한 뒤에도 재발 방지를 위한 노력을 기울여요. (예: 문서화, 테스트 케이스 추가 등)",problemSolvingOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제의 원인을 적극적으로 탐구하고 해결해요. (예: 디버깅 툴의 적극적 활용 등)",problemSolvingOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("어려운 문제를 만나도 피하지 않고 도전해요.",problemSolvingOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제를 해결하기 위해 타인과 의사소통을 할 수 있어요. (예: 팀원과 이슈 공유, 문제 상황 설명 등)",problemSolvingOptionGroupId,7, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("문제 원인과 해결책에 대한 가설을 세우고 직접 실험해봐요.",problemSolvingOptionGroupId,8, OptionType.KEYWORD )); + + // 시간 관리 능력 섹션 + long checkBoxTimeManagingQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "시간 관리 능력에서 어느 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textTimeManagingQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. {revieweeName} 덕분에 팀이 효율적으로 시간관리를 할 수 있었는지 떠올려 보세요.", 2)).getId(); + long timeManagingSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxTimeManagingQuestionId, textTimeManagingQuestionId), timeManagingOptionId, "시간관리 능력", CATEGORY_HEADER, 4)).getId(); + long timeManagingOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxTimeManagingQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("프로젝트의 일정과 주요 마일스톤을 설정하여 체계적으로 일정을 관리해요.",timeManagingOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("일정에 따라 마감 기한을 잘 지켜요.",timeManagingOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("업무의 중요도와 긴급성을 고려하여 우선 순위를 정하고, 그에 따라 작업을 분배해요.",timeManagingOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("예기치 않은 일정 변경에도 유연하게 대처해요.",timeManagingOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("회의 시간과 같은 약속된 시간을 잘 지켜요.",timeManagingOptionGroupId,5, OptionType.KEYWORD )); + + // 기술 역량 섹션 + long checkBoxTechnicalQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "기술 역량, 전문 지식에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textTechnicalQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. {revieweeName} 덕분에 기술적 역량, 전문 지식적으로 도움을 받은 경험을 떠올려 보세요.", 2)).getId(); + long technicalSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxTechnicalQuestionId, textTechnicalQuestionId), technicalOptionId, "기술 역량", CATEGORY_HEADER, 5)).getId(); + long technicalOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxTechnicalQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("관련 언어 / 라이브러리 / 프레임워크 지식이 풍부해요.",technicalOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("인프라 지식이 풍부해요.",technicalOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("CS 지식이 풍부해요.",technicalOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("코드 리뷰에서 중요한 개선점을 제안했어요.",technicalOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("리팩토링을 통해 전체 코드의 품질을 향상시켰어요.",technicalOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("복잡한 버그를 신속하게 찾고 해결했어요.",technicalOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("꼼꼼하게 테스트를 작성했어요.",technicalOptionGroupId,7, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("처음 보는 기술을 빠르게 습득하여 팀 프로젝트에 적용했어요.",technicalOptionGroupId,8, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("명확하고 자세한 기술 문서를 작성하여 팀의 이해를 도왔어요.",technicalOptionGroupId,9, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("컨벤션을 잘 지키면서 클린 코드를 작성하려고 노력했어요.",technicalOptionGroupId,10, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("성능 최적화에 기여했어요.",technicalOptionGroupId,11, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("지속적인 학습과 공유를 통해 팀의 기술 수준을 높였어요.",technicalOptionGroupId,12, OptionType.KEYWORD )); + + // 성장 마인드셋 섹션 + long checkBoxGrowthQuestionId = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "성장 마인드셋에서 어떤 부분이 인상 깊었는지 선택해주세요.", null, 1)).getId(); + long textGrowthQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, CATEGORY_TEXT_QUESTION, "상황을 자세하게 기록할수록 {revieweeName}에게 도움이 돼요. 인상깊었던 {revieweeName}의 성장 마인드셋을 떠올려 보세요.", 2)).getId(); + long growthSectionId = sectionRepository.save(new Section(VisibleType.CONDITIONAL, List.of(checkBoxGrowthQuestionId, textGrowthQuestionId), growthOptionId, "성장 마인드셋", CATEGORY_HEADER, 6)).getId(); + long growthOptionGroupId = optionGroupRepository.save(new OptionGroup(checkBoxGrowthQuestionId, KEYWORD_CHECKBOX_MIN_COUNT, KEYWORD_CHECKBOX_MAX_COUNT)).getId(); + optionItemRepository.save(new OptionItem("어떤 상황에도 긍정적인 태도로 임해요.",growthOptionGroupId,1, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("주변 사람들한테 질문하는 것을 부끄러워하지 않아요.",growthOptionGroupId,2, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("어려움이 있어도 끝까지 해내요.",growthOptionGroupId,3, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("함께 성장하기 위해, 배운 내용을 다른 사람과 공유해요.",growthOptionGroupId,4, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("새로운 것을 두려워하지 않고 적극적으로 배워나가요.",growthOptionGroupId,5, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("이론적 학습에서 그치지 않고 직접 적용하려 노력해요.",growthOptionGroupId,6, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("다른 사람들과 비교하지 않고 본인만의 속도로 성장하는 법을 알고 있어요.",growthOptionGroupId,7, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("받은 피드백을 빠르게 수용해요.",growthOptionGroupId,8, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("회고를 통해 성장할 수 있는 방법을 스스로 탐색해요.",growthOptionGroupId,9, OptionType.KEYWORD )); + optionItemRepository.save(new OptionItem("새로운 아이디어를 시도하고, 기존의 틀을 깨는 것을 두려워하지 않아요.",growthOptionGroupId,10, OptionType.KEYWORD )); + + // 성장 목표 설정 섹션 + long textGrowthGoalQuestionId = questionRepository.save(new Question(true, QuestionType.TEXT, "앞으로의 성장을 위해서 {revieweeName}이/가 어떤 목표를 설정하면 좋을까요?", "어떤 점을 보완하면 좋을지와 함께 '이렇게 해보면 어떨까?'하는 간단한 솔루션을 제안해봐요.", 1)).getId(); + long textGrowthGoalSectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(textGrowthGoalQuestionId), null, "보완할 점", "{revieweeName}의 성장을 도와주세요!", 7)).getId(); + + // 응원의 말 섹션 + long textCheerUpQuestionId = questionRepository.save(new Question(false, QuestionType.TEXT, "{revieweeName}에게 전하고 싶은 다른 리뷰가 있거나 응원의 말이 있다면 적어주세요.", null, 1)).getId(); + long cheerUpSectionId = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(textCheerUpQuestionId), null, "추가 리뷰/응원", "리뷰를 더 하고 싶은 리뷰어를 위한 추가 리뷰!", 8)).getId(); + + templateRepository.save(new Template(List.of( + categorySectionId, + communicationSectionId, + problemSolvingSectionId, + timeManagingSectionId, + technicalSectionId, + growthSectionId, + textGrowthGoalSectionId, + cheerUpSectionId + ))); + } +} diff --git a/backend/src/main/java/reviewme/ReviewMeApplication.java b/backend/src/main/java/reviewme/ReviewMeApplication.java new file mode 100644 index 000000000..7645801f3 --- /dev/null +++ b/backend/src/main/java/reviewme/ReviewMeApplication.java @@ -0,0 +1,12 @@ +package reviewme; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ReviewMeApplication { + + public static void main(String[] args) { + SpringApplication.run(ReviewMeApplication.class, args); + } +} diff --git a/backend/src/main/java/reviewme/config/CorsConfig.java b/backend/src/main/java/reviewme/config/CorsConfig.java new file mode 100644 index 000000000..448e95eb7 --- /dev/null +++ b/backend/src/main/java/reviewme/config/CorsConfig.java @@ -0,0 +1,16 @@ +package reviewme.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedMethods("*") + .allowedOrigins("*"); + } +} diff --git a/backend/src/main/java/reviewme/config/SwaggerConfig.java b/backend/src/main/java/reviewme/config/SwaggerConfig.java new file mode 100644 index 000000000..0c6becb78 --- /dev/null +++ b/backend/src/main/java/reviewme/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package reviewme.config; + +import io.swagger.v3.oas.models.OpenAPI; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reviewme.config.properties.SwaggerProperties; + +@Configuration +@EnableConfigurationProperties(SwaggerProperties.class) +@RequiredArgsConstructor +public class SwaggerConfig { + + private final SwaggerProperties swaggerProperties; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(swaggerProperties.swaggerInfo()); + } +} diff --git a/backend/src/main/java/reviewme/config/WebConfig.java b/backend/src/main/java/reviewme/config/WebConfig.java new file mode 100644 index 000000000..423c8f0e5 --- /dev/null +++ b/backend/src/main/java/reviewme/config/WebConfig.java @@ -0,0 +1,16 @@ +package reviewme.config; + +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import reviewme.global.HeaderPropertyArgumentResolver; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new HeaderPropertyArgumentResolver()); + } +} diff --git a/backend/src/main/java/reviewme/config/properties/SwaggerProperties.java b/backend/src/main/java/reviewme/config/properties/SwaggerProperties.java new file mode 100644 index 000000000..babdf727b --- /dev/null +++ b/backend/src/main/java/reviewme/config/properties/SwaggerProperties.java @@ -0,0 +1,19 @@ +package reviewme.config.properties; + +import io.swagger.v3.oas.models.info.Info; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "docs.info") +public record SwaggerProperties( + String title, + String description, + String version +) { + + public Info swaggerInfo() { + return new Info() + .title(title) + .description(description) + .version(version); + } +} diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java new file mode 100644 index 000000000..e6fba936a --- /dev/null +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -0,0 +1,128 @@ +package reviewme.global; + +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.TypeMismatchException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.method.MethodValidationException; +import org.springframework.web.HttpMediaTypeException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.MissingRequestValueException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import reviewme.global.exception.BadRequestException; +import reviewme.global.exception.DataInconsistencyException; +import reviewme.global.exception.FieldErrorResponse; +import reviewme.global.exception.NotFoundException; +import reviewme.global.exception.UnauthorizedException; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ProblemDetail handleNotFoundException(NotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getErrorMessage()); + } + + @ExceptionHandler(BadRequestException.class) + public ProblemDetail handleBadRequestException(BadRequestException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getErrorMessage()); + } + + @ExceptionHandler(UnexpectedRequestException.class) + public ProblemDetail handleUnexpectedRequestException(UnexpectedRequestException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getErrorMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ProblemDetail handleUnauthorizedException(UnauthorizedException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getErrorMessage()); + } + + @ExceptionHandler(DataInconsistencyException.class) + public ProblemDetail handleDataConsistencyException(DataInconsistencyException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrorMessage()); + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleException(Exception ex) { + log.error("Initial server error has occurred", ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러가 발생했습니다."); + } + + // Following exceptions are exceptions that occur in Spring + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ProblemDetail handleHttpRequestMethodNotSupportedException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."); + } + + @ExceptionHandler(HttpMediaTypeException.class) + public ProblemDetail handleHttpMediaTypeException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "잘못된 media type 입니다."); + } + + @ExceptionHandler({MissingRequestValueException.class, MissingServletRequestPartException.class}) + public ProblemDetail handleMissingRequestException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "필수 요청 데이터가 누락되었습니다."); + } + + @ExceptionHandler({ServletRequestBindingException.class, HttpMessageNotReadableException.class}) + public ProblemDetail handleServletRequestBindingException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "요청을 읽을 수 없습니다."); + } + + @ExceptionHandler({ + MethodValidationException.class, BindException.class, + TypeMismatchException.class, HandlerMethodValidationException.class + }) + public ProblemDetail handleRequestFormatException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "요청의 형식이 잘못되었습니다."); + } + + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) + public ProblemDetail handleNoHandlerFoundException(Exception ex) { + logSpringException(ex); + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "잘못된 경로의 요청입니다."); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + logSpringException(ex); + List fieldErrors = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(fieldError -> new FieldErrorResponse( + fieldError.getField(), + fieldError.getRejectedValue(), + fieldError.getDefaultMessage())) + .toList(); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다." + ); + Map properties = Map.of("fieldErrors", fieldErrors); + problemDetail.setProperties(properties); + return problemDetail; + } + + private void logSpringException(Exception ex) { + log.info("Spring error has occurred - {}: {}", ex.getClass().getSimpleName(), ex.getLocalizedMessage()); + } +} diff --git a/backend/src/main/java/reviewme/global/HeaderProperty.java b/backend/src/main/java/reviewme/global/HeaderProperty.java new file mode 100644 index 000000000..86462c596 --- /dev/null +++ b/backend/src/main/java/reviewme/global/HeaderProperty.java @@ -0,0 +1,18 @@ +package reviewme.global; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface HeaderProperty { + + @AliasFor("headerName") + String value() default ""; + + @AliasFor("value") + String headerName() default ""; +} diff --git a/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java b/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java new file mode 100644 index 000000000..5c825e3de --- /dev/null +++ b/backend/src/main/java/reviewme/global/HeaderPropertyArgumentResolver.java @@ -0,0 +1,32 @@ +package reviewme.global; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import reviewme.global.exception.MissingHeaderPropertyException; + +public class HeaderPropertyArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(HeaderProperty.class); + } + + @Override + public String resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + HeaderProperty parameterAnnotation = parameter.getParameterAnnotation(HeaderProperty.class); + String headerName = parameterAnnotation.headerName(); + String headerProperty = request.getHeader(headerName); + + if (headerProperty == null) { + throw new MissingHeaderPropertyException(headerName); + } + return headerProperty; + } +} diff --git a/backend/src/main/java/reviewme/global/exception/BadRequestException.java b/backend/src/main/java/reviewme/global/exception/BadRequestException.java new file mode 100644 index 000000000..b53263987 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class BadRequestException extends ReviewMeException { + + protected BadRequestException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java b/backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java new file mode 100644 index 000000000..1f91caeff --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/DataInconsistencyException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class DataInconsistencyException extends ReviewMeException { + + protected DataInconsistencyException(String errorMessage) { + super(errorMessage); + log.error("", this); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java b/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java new file mode 100644 index 000000000..e44edf619 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/FieldErrorResponse.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public record FieldErrorResponse( + String field, + Object value, + String message +) { +} diff --git a/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java b/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java new file mode 100644 index 000000000..8fc4dd76f --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/MissingHeaderPropertyException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MissingHeaderPropertyException extends BadRequestException { + + public MissingHeaderPropertyException(String headerName) { + super("요청에 %s이(가) 존재하지 않아요.".formatted(headerName)); + log.info("Missing header property: {}", headerName); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/NotFoundException.java b/backend/src/main/java/reviewme/global/exception/NotFoundException.java new file mode 100644 index 000000000..44bb4ac5f --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class NotFoundException extends ReviewMeException { + + protected NotFoundException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/ReviewMeException.java b/backend/src/main/java/reviewme/global/exception/ReviewMeException.java new file mode 100644 index 000000000..1d2c2e26e --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/ReviewMeException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public abstract class ReviewMeException extends RuntimeException { + + private final String errorMessage; +} diff --git a/backend/src/main/java/reviewme/global/exception/UnauthorizedException.java b/backend/src/main/java/reviewme/global/exception/UnauthorizedException.java new file mode 100644 index 000000000..150fc998b --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class UnauthorizedException extends ReviewMeException { + + protected UnauthorizedException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java b/backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java new file mode 100644 index 000000000..1267cdc74 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/UnexpectedRequestException.java @@ -0,0 +1,12 @@ +package reviewme.global.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class UnexpectedRequestException extends ReviewMeException { + + protected UnexpectedRequestException(String errorMessage) { + super(errorMessage); + log.warn("", this); + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionGroup.java b/backend/src/main/java/reviewme/question/domain/OptionGroup.java new file mode 100644 index 000000000..61aa3d23a --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/OptionGroup.java @@ -0,0 +1,39 @@ +package reviewme.question.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "option_group") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class OptionGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "question_id", nullable = false) + private long questionId; + + @Column(name = "min_selection_count", nullable = false) + private int minSelectionCount; + + @Column(name = "max_selection_count", nullable = false) + private int maxSelectionCount; + + public OptionGroup(long questionId, int minSelectionCount, int maxSelectionCount) { + this.questionId = questionId; + this.minSelectionCount = minSelectionCount; + this.maxSelectionCount = maxSelectionCount; + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionItem.java b/backend/src/main/java/reviewme/question/domain/OptionItem.java new file mode 100644 index 000000000..59b29bc3b --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/OptionItem.java @@ -0,0 +1,46 @@ +package reviewme.question.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "option_item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class OptionItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content", nullable = false) + private String content; + + @Column(name = "option_group_id", nullable = false) + private long optionGroupId; + + @Column(name = "position", nullable = false) + private int position; + + @Column(name = "option_type", nullable = false) + @Enumerated(EnumType.STRING) + private OptionType optionType; + + public OptionItem(String content, long optionGroupId, int position, OptionType optionType) { + this.content = content; + this.optionGroupId = optionGroupId; + this.position = position; + this.optionType = optionType; + } +} diff --git a/backend/src/main/java/reviewme/question/domain/OptionType.java b/backend/src/main/java/reviewme/question/domain/OptionType.java new file mode 100644 index 000000000..dfa86920b --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/OptionType.java @@ -0,0 +1,6 @@ +package reviewme.question.domain; + +public enum OptionType { + CATEGORY, + KEYWORD, +} diff --git a/backend/src/main/java/reviewme/question/domain/Question.java b/backend/src/main/java/reviewme/question/domain/Question.java new file mode 100644 index 000000000..584f05215 --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/Question.java @@ -0,0 +1,69 @@ +package reviewme.question.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "question") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Question { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "required", nullable = false) + private boolean required; + + @Column(name = "question_type", nullable = false) + @Enumerated(EnumType.STRING) + private QuestionType questionType; + + @Column(name = "content", nullable = false, length = 1_000) + private String content; + + @Column(name = "guideline", nullable = true, length = 1_000) + private String guideline; + + @Column(name = "position", nullable = false) + private int position; + + public Question(boolean required, QuestionType questionType, String content, String guideline, int position) { + this.required = required; + this.questionType = questionType; + this.content = content; + this.guideline = guideline; + this.position = position; + } + + public boolean isSelectable() { + return questionType == QuestionType.CHECKBOX; + } + + public boolean hasGuideline() { + return guideline != null && !guideline.isEmpty(); + } + + public String convertContent(String target, String replacement) { + return content.replace(target, replacement); + } + + public String convertGuideLine(String target, String replacement) { + if (guideline == null) { + return null; + } + return guideline.replace(target, replacement); + } +} diff --git a/backend/src/main/java/reviewme/question/domain/QuestionType.java b/backend/src/main/java/reviewme/question/domain/QuestionType.java new file mode 100644 index 000000000..863ba56e5 --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/QuestionType.java @@ -0,0 +1,8 @@ +package reviewme.question.domain; + +public enum QuestionType { + CHECKBOX, + TEXT, + ; + +} diff --git a/backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java b/backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java new file mode 100644 index 000000000..a9a016b91 --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/exception/MissingOptionItemsInOptionGroupException.java @@ -0,0 +1,13 @@ +package reviewme.question.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class MissingOptionItemsInOptionGroupException extends DataInconsistencyException { + + public MissingOptionItemsInOptionGroupException(long optionGroupId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("OptionGroup has no OptionItems - optionGroupId: {}", optionGroupId); + } +} diff --git a/backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java b/backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java new file mode 100644 index 000000000..a76e9e3ba --- /dev/null +++ b/backend/src/main/java/reviewme/question/domain/exception/QuestionNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.question.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class QuestionNotFoundException extends NotFoundException { + + public QuestionNotFoundException(long questionId) { + super("질문이 존재하지 않아요."); + log.warn("Question not found - questionId: {}", questionId, this); + } +} diff --git a/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java b/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java new file mode 100644 index 000000000..1be923085 --- /dev/null +++ b/backend/src/main/java/reviewme/question/repository/OptionGroupRepository.java @@ -0,0 +1,12 @@ +package reviewme.question.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.question.domain.OptionGroup; + +@Repository +public interface OptionGroupRepository extends JpaRepository { + + Optional findByQuestionId(long questionId); +} diff --git a/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java b/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java new file mode 100644 index 000000000..0b639b6fb --- /dev/null +++ b/backend/src/main/java/reviewme/question/repository/OptionItemRepository.java @@ -0,0 +1,48 @@ +package reviewme.question.repository; + +import java.util.List; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; + +@Repository +public interface OptionItemRepository extends JpaRepository { + + List findAllByOptionGroupId(long optionGroupId); + + @Query(value = """ + SELECT o.id FROM option_item o + LEFT JOIN checkbox_answer_selected_option c + ON c.selected_option_id = o.id + LEFT JOIN checkbox_answer ca + ON c.checkbox_answer_id = ca.id + WHERE ca.review_id = :reviewId + """, nativeQuery = true) + Set findSelectedOptionItemIdsByReviewId(long reviewId); + + @Query(value = """ + SELECT o.* FROM option_item o + LEFT JOIN checkbox_answer_selected_option c + ON c.selected_option_id = o.id + LEFT JOIN checkbox_answer ca + ON c.checkbox_answer_id = ca.id + WHERE ca.review_id = :reviewId + AND ca.question_id = :questionId + ORDER BY o.position ASC + """, nativeQuery = true) + List findSelectedOptionItemsByReviewIdAndQuestionId(long reviewId, long questionId); + + @Query(value = """ + SELECT o.* FROM option_item o + INNER JOIN checkbox_answer_selected_option cao + ON cao.selected_option_id = o.id + INNER JOIN checkbox_answer ca + ON cao.checkbox_answer_id = ca.id + WHERE ca.review_id = :reviewId + AND o.option_type = :#{#optionType.name()} + """, nativeQuery = true) + List findByReviewIdAndOptionType(long reviewId, OptionType optionType); +} diff --git a/backend/src/main/java/reviewme/question/repository/QuestionRepository.java b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java new file mode 100644 index 000000000..9ae5bd75a --- /dev/null +++ b/backend/src/main/java/reviewme/question/repository/QuestionRepository.java @@ -0,0 +1,29 @@ +package reviewme.question.repository; + +import java.util.List; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import reviewme.question.domain.Question; + +public interface QuestionRepository extends JpaRepository { + + @Query(value = """ + SELECT q.* FROM question q + LEFT JOIN section_question sq + ON sq.question_id = q.id + WHERE sq.section_id = :sectionId + ORDER BY q.position ASC + """, nativeQuery = true) + List findAllBySectionId(long sectionId); + + @Query(value = """ + SELECT q.* FROM question q + LEFT JOIN section_question sq + ON sq.question_id = q.id + LEFT JOIN template_section ts + ON sq.section_id = ts.section_id + WHERE ts.template_id = :templateId + """, nativeQuery = true) + Set findAllQuestionIdByTemplateId(long templateId); +} diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java new file mode 100644 index 000000000..4871f7377 --- /dev/null +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -0,0 +1,57 @@ +package reviewme.review.controller; + +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reviewme.global.HeaderProperty; +import reviewme.review.service.CreateReviewService; +import reviewme.review.service.ReviewDetailLookupService; +import reviewme.review.service.ReviewService; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; + +@RestController +@RequiredArgsConstructor +public class ReviewController { + + private static final String GROUP_ACCESS_CODE_HEADER = "GroupAccessCode"; + + private final CreateReviewService createReviewService; + private final ReviewService reviewService; + private final ReviewDetailLookupService reviewDetailLookupService; + + @PostMapping("/v2/reviews") + public ResponseEntity createReview(@Valid @RequestBody CreateReviewRequest request) { + long savedReviewId = createReviewService.createReview(request); + return ResponseEntity.created(URI.create("/reviews/" + savedReviewId)).build(); + } + + @GetMapping("/v2/reviews") + public ResponseEntity findReceivedReviews( + @RequestParam String reviewRequestCode, + @HeaderProperty(GROUP_ACCESS_CODE_HEADER) String groupAccessCode + ) { + ReceivedReviewsResponse response = reviewService.findReceivedReviews(reviewRequestCode, groupAccessCode); + return ResponseEntity.ok(response); + } + + @GetMapping("/v2/reviews/{id}") + public ResponseEntity findReceivedReviewDetail( + @PathVariable long id, + @RequestParam String reviewRequestCode, + @HeaderProperty(GROUP_ACCESS_CODE_HEADER) String groupAccessCode + ) { + TemplateAnswerResponse response = reviewDetailLookupService.getReviewDetail( + id, reviewRequestCode, groupAccessCode + ); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java b/backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java new file mode 100644 index 000000000..8a19dc049 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/CheckBoxAnswerSelectedOption.java @@ -0,0 +1,32 @@ +package reviewme.review.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "checkbox_answer_selected_option") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class CheckBoxAnswerSelectedOption { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "checkbox_answer_id", nullable = false, insertable = false, updatable = false) + private long checkboxAnswerId; + + @Column(name = "selected_option_id", nullable = false) + private long selectedOptionId; + + public CheckBoxAnswerSelectedOption(long selectedOptionId) { + this.selectedOptionId = selectedOptionId; + } +} diff --git a/backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java b/backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java new file mode 100644 index 000000000..6f6cf5aab --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/CheckboxAnswer.java @@ -0,0 +1,46 @@ +package reviewme.review.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "checkbox_answer") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class CheckboxAnswer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "review_id", nullable = false, insertable = false, updatable = false) + private long reviewId; + + @Column(name = "question_id", nullable = false) + private long questionId; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "checkbox_answer_id", nullable = false, updatable = false) + private List selectedOptionIds; + + public CheckboxAnswer(long questionId, List selectedOptionIds) { + this.questionId = questionId; + this.selectedOptionIds = selectedOptionIds.stream() + .map(CheckBoxAnswerSelectedOption::new) + .toList(); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java b/backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java new file mode 100644 index 000000000..bc2447f5d --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/CheckboxAnswers.java @@ -0,0 +1,28 @@ +package reviewme.review.domain; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import reviewme.review.domain.exception.MissingCheckboxAnswerForQuestionException; + +public class CheckboxAnswers { + + private final Map checkboxAnswers; + + public CheckboxAnswers(List checkboxAnswers) { + this.checkboxAnswers = checkboxAnswers.stream() + .collect(Collectors.toMap(CheckboxAnswer::getQuestionId, Function.identity())); + } + + public CheckboxAnswer getAnswerByQuestionId(long questionId) { + if (!checkboxAnswers.containsKey(questionId)) { + throw new MissingCheckboxAnswerForQuestionException(questionId); + } + return checkboxAnswers.get(questionId); + } + + public boolean hasAnswerByQuestionId(long questionId) { + return checkboxAnswers.containsKey(questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/Review.java b/backend/src/main/java/reviewme/review/domain/Review.java new file mode 100644 index 000000000..4bf4a6856 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/Review.java @@ -0,0 +1,61 @@ +package reviewme.review.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "review") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_id", nullable = false) + private long templateId; + + @Column(name = "review_group_id", nullable = false) + private long reviewGroupId; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) + @JoinColumn(name = "review_id", nullable = false, updatable = false) + private List textAnswers; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST) + @JoinColumn(name = "review_id", nullable = false, updatable = false) + private List checkboxAnswers; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + public Review(long templateId, long reviewGroupId, + List textAnswers, List checkboxAnswers) { + this.templateId = templateId; + this.reviewGroupId = reviewGroupId; + this.textAnswers = textAnswers; + this.checkboxAnswers = checkboxAnswers; + this.createdAt = LocalDateTime.now(); + } + + public LocalDate getCreatedDate() { + return createdAt.toLocalDate(); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/TextAnswer.java b/backend/src/main/java/reviewme/review/domain/TextAnswer.java new file mode 100644 index 000000000..ac54530a9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/TextAnswer.java @@ -0,0 +1,35 @@ +package reviewme.review.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "text_answer") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class TextAnswer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "question_id", nullable = false) + private long questionId; + + @Column(name = "content", nullable = false, length = 5000) + private String content; + + public TextAnswer(long questionId, String content) { + this.questionId = questionId; + this.content = content; + } +} diff --git a/backend/src/main/java/reviewme/review/domain/TextAnswers.java b/backend/src/main/java/reviewme/review/domain/TextAnswers.java new file mode 100644 index 000000000..4ce230eb0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/TextAnswers.java @@ -0,0 +1,30 @@ +package reviewme.review.domain; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import reviewme.review.domain.exception.MissingTextAnswerForQuestionException; + +@Slf4j +public class TextAnswers { + + private final Map textAnswers; + + public TextAnswers(List textAnswers) { + this.textAnswers = textAnswers.stream() + .collect(Collectors.toMap(TextAnswer::getQuestionId, Function.identity())); + } + + public TextAnswer getAnswerByQuestionId(long questionId) { + if (!textAnswers.containsKey(questionId)) { + throw new MissingTextAnswerForQuestionException(questionId); + } + return textAnswers.get(questionId); + } + + public boolean hasAnswerByQuestionId(long questionId) { + return textAnswers.containsKey(questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java b/backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java new file mode 100644 index 000000000..bd50713ee --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/CategoryOptionByReviewNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class CategoryOptionByReviewNotFoundException extends NotFoundException { + + public CategoryOptionByReviewNotFoundException(long reviewId) { + super("리뷰에 선택한 카테고리가 없어요."); + log.warn("CategoryOptionNotFoundException is occured - reviewId: {}", reviewId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java new file mode 100644 index 000000000..457cebfac --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidProjectNameLengthException.java @@ -0,0 +1,14 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidProjectNameLengthException extends BadRequestException { + + public InvalidProjectNameLengthException(int projectNameLength, int minLength, int maxLength) { + super("프로젝트 이름은 %d글자 이상 %d글자 이하여야 해요.".formatted(minLength, maxLength)); + log.warn("ProjectName is out of bound - projectNameLength:{}, minLength:{}, maxLength: {}", + projectNameLength, minLength, maxLength, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java new file mode 100644 index 000000000..89b802fcf --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidReviewAccessByReviewGroupException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +public class InvalidReviewAccessByReviewGroupException extends UnexpectedRequestException { + + public InvalidReviewAccessByReviewGroupException(long reviewId, long reviewGroupId) { + super("리뷰가 존재하지 않아요."); + log.warn("Review is not in review group - reviewId: {}, reviewGroupId: {}", reviewId, reviewGroupId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java new file mode 100644 index 000000000..0294685ef --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidRevieweeNameLengthException.java @@ -0,0 +1,14 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidRevieweeNameLengthException extends BadRequestException { + + public InvalidRevieweeNameLengthException(int revieweeNameLength, int minLength, int maxLength) { + super("리뷰이 이름은 %d글자 이상 %d글자 이하여야 해요.".formatted(minLength, maxLength)); + log.warn("RevieweeName is out of bound - revieweeNameLength:{}, minLength:{}, maxLength: {}", + revieweeNameLength, minLength, maxLength, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java b/backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java new file mode 100644 index 000000000..236531179 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/InvalidTextAnswerLengthException.java @@ -0,0 +1,14 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidTextAnswerLengthException extends BadRequestException { + + public InvalidTextAnswerLengthException(int answerLength, int minLength, int maxLength) { + super("답변의 길이는 %d자 이상 %d자 이하여야 해요.".formatted(minLength, maxLength)); + log.warn("AnswerLength is out of bound - answerLength: {}, minLength: {}, maxLength: {}", + answerLength, minLength, maxLength, this); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java b/backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java new file mode 100644 index 000000000..f64df7f25 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/MissingCheckboxAnswerForQuestionException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class MissingCheckboxAnswerForQuestionException extends NotFoundException { + + public MissingCheckboxAnswerForQuestionException(long questionId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Checkbox Answer not found for questionId: {}", questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java b/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java new file mode 100644 index 000000000..6ed567514 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/MissingTextAnswerForQuestionException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class MissingTextAnswerForQuestionException extends DataInconsistencyException { + + public MissingTextAnswerForQuestionException(long questionId) { + super("질문에 해당하는 서술형 답변을 찾지 못했어요."); + log.error("The question is a text question but text answer not found for questionId: {}", questionId); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java new file mode 100644 index 000000000..345fbe3a1 --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByGroupAccessCodeException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewGroupNotFoundByGroupAccessCodeException extends NotFoundException { + + public ReviewGroupNotFoundByGroupAccessCodeException(String groupAccessCode) { + super("리뷰 그룹을 찾을 수 없어요."); + log.info("ReviewGroup not found by groupAccessCode - groupAccessCode: {}", groupAccessCode); + } +} diff --git a/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java new file mode 100644 index 000000000..6b8cb64fe --- /dev/null +++ b/backend/src/main/java/reviewme/review/domain/exception/ReviewGroupNotFoundByReviewRequestCodeException.java @@ -0,0 +1,13 @@ +package reviewme.review.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewGroupNotFoundByReviewRequestCodeException extends NotFoundException { + + public ReviewGroupNotFoundByReviewRequestCodeException(String reviewRequestCode) { + super("리뷰 요청 코드에 대한 리뷰 그룹을 찾을 수 없어요."); + log.info("ReviewGroup not found by reviewRequestCode - reviewRequestCode: {}", reviewRequestCode); + } +} diff --git a/backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java b/backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java new file mode 100644 index 000000000..30ce44014 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/CheckboxAnswerRepository.java @@ -0,0 +1,9 @@ +package reviewme.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.CheckboxAnswer; + +@Repository +public interface CheckboxAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java new file mode 100644 index 000000000..1ab6a1cf3 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -0,0 +1,17 @@ +package reviewme.review.repository; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.Review; + +@Repository +public interface ReviewRepository extends JpaRepository { + + @Query("SELECT r FROM Review r WHERE r.reviewGroupId=:reviewGroupId ORDER BY r.createdAt DESC") + List findReceivedReviewsByGroupId(long reviewGroupId); + + Optional findByIdAndReviewGroupId(long reviewId, long reviewGroupId); +} diff --git a/backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java b/backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java new file mode 100644 index 000000000..0c3465399 --- /dev/null +++ b/backend/src/main/java/reviewme/review/repository/TextAnswerRepository.java @@ -0,0 +1,9 @@ +package reviewme.review.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.review.domain.TextAnswer; + +@Repository +public interface TextAnswerRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java b/backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java new file mode 100644 index 000000000..d9a10a434 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidator.java @@ -0,0 +1,77 @@ +package reviewme.review.service; + +import java.util.HashSet; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; +import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.template.domain.exception.OptionGroupNotFoundByQuestionIdException; + +@Component +@RequiredArgsConstructor +public class CreateCheckBoxAnswerRequestValidator { + + private final QuestionRepository questionRepository; + private final OptionGroupRepository optionGroupRepository; + private final OptionItemRepository optionItemRepository; + + public void validate(CreateReviewAnswerRequest request) { + validateNotContainingText(request); + Question question = questionRepository.findById(request.questionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(request.questionId())); + OptionGroup optionGroup = optionGroupRepository.findByQuestionId(question.getId()) + .orElseThrow(() -> new OptionGroupNotFoundByQuestionIdException(question.getId())); + validateRequiredQuestion(request, question); + validateOnlyIncludingProvidedOptionItem(request, optionGroup); + validateCheckedOptionItemCount(request, optionGroup); + } + + private void validateNotContainingText(CreateReviewAnswerRequest request) { + if (request.text() != null) { + throw new CheckBoxAnswerIncludedTextException(); + } + } + + private void validateRequiredQuestion(CreateReviewAnswerRequest request, Question question) { + if (question.isRequired() && request.selectedOptionIds() == null) { + throw new RequiredQuestionNotAnsweredException(question.getId()); + } + } + + private void validateOnlyIncludingProvidedOptionItem(CreateReviewAnswerRequest request, OptionGroup optionGroup) { + List providedOptionItemIds = optionItemRepository.findAllByOptionGroupId(optionGroup.getId()) + .stream() + .map(OptionItem::getId) + .toList(); + List submittedOptionItemIds = request.selectedOptionIds(); + + if (!new HashSet<>(providedOptionItemIds).containsAll(submittedOptionItemIds)) { + throw new CheckBoxAnswerIncludedNotProvidedOptionItemException( + request.questionId(), providedOptionItemIds, submittedOptionItemIds + ); + } + } + + private void validateCheckedOptionItemCount(CreateReviewAnswerRequest request, OptionGroup optionGroup) { + if (request.selectedOptionIds().size() < optionGroup.getMinSelectionCount() + || request.selectedOptionIds().size() > optionGroup.getMaxSelectionCount()) { + throw new SelectedOptionItemCountOutOfRangeException( + request.questionId(), + request.selectedOptionIds().size(), + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount() + ); + } + } +} diff --git a/backend/src/main/java/reviewme/review/service/CreateReviewService.java b/backend/src/main/java/reviewme/review/service/CreateReviewService.java new file mode 100644 index 000000000..d6225f133 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/CreateReviewService.java @@ -0,0 +1,144 @@ +package reviewme.review.service; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.exception.MissingRequiredQuestionException; +import reviewme.review.service.exception.SubmittedQuestionAndProvidedQuestionMismatchException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.review.service.exception.UnnecessaryQuestionIncludedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.SectionQuestion; +import reviewme.template.domain.Template; +import reviewme.template.domain.exception.TemplateNotFoundByReviewGroupException; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@Service +@RequiredArgsConstructor +public class CreateReviewService { + + private final ReviewRepository reviewRepository; + private final QuestionRepository questionRepository; + private final ReviewGroupRepository reviewGroupRepository; + private final CreateTextAnswerRequestValidator createTextAnswerRequestValidator; + private final CreateCheckBoxAnswerRequestValidator createCheckBoxAnswerRequestValidator; + private final TemplateRepository templateRepository; + private final SectionRepository sectionRepository; + + @Transactional + public long createReview(CreateReviewRequest request) { + ReviewGroup reviewGroup = validateReviewGroupByRequestCode(request.reviewRequestCode()); + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId())); + validateSubmittedQuestionsContainedInTemplate(reviewGroup.getTemplateId(), request); + validateOnlyRequiredQuestionsSubmitted(template, request); + + return saveReview(request, reviewGroup); + } + + private ReviewGroup validateReviewGroupByRequestCode(String reviewRequestCode) { + return reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + } + + private void validateSubmittedQuestionsContainedInTemplate(long templateId, CreateReviewRequest request) { + Set providedQuestionIds = questionRepository.findAllQuestionIdByTemplateId(templateId); + Set submittedQuestionIds = request.answers() + .stream() + .map(CreateReviewAnswerRequest::questionId) + .collect(Collectors.toSet()); + if (!providedQuestionIds.containsAll(submittedQuestionIds)) { + throw new SubmittedQuestionAndProvidedQuestionMismatchException(submittedQuestionIds, providedQuestionIds); + } + } + + private void validateOnlyRequiredQuestionsSubmitted(Template template, CreateReviewRequest request) { + // 제출된 리뷰의 옵션 아이템 ID 목록 + List selectedOptionItemIds = request.answers() + .stream() + .filter(answer -> answer.selectedOptionIds() != null) + .flatMap(answer -> answer.selectedOptionIds().stream()) + .toList(); + + // 제출된 리뷰의 질문 ID 목록 + List submittedQuestionIds = request.answers() + .stream() + .map(CreateReviewAnswerRequest::questionId) + .toList(); + + // 섹션에서 답해야 할 질문 ID 목록 + List requiredQuestionIdsCandidates = sectionRepository.findAllByTemplateId(template.getId()) + .stream() + // 선택된 optionItem 에 따라 required 를 다르게 책정해서 필터링 + .filter(section -> section.isVisibleBySelectedOptionIds(selectedOptionItemIds)) + .flatMap(section -> section.getQuestionIds().stream()) + .map(SectionQuestion::getQuestionId) + .toList(); + List requiredQuestionIds = questionRepository.findAllById(requiredQuestionIdsCandidates) + .stream() + .filter(Question::isRequired) + .map(Question::getId) + .toList(); + + // 제출된 리뷰의 질문 중에서 제출해야 할 질문이 모두 포함되었는지 검사 + Set submittedQuestionIds2 = new HashSet<>(submittedQuestionIds); + if (!submittedQuestionIds2.containsAll(requiredQuestionIds)) { + List missingRequiredQuestionIds = new ArrayList<>(requiredQuestionIds); + missingRequiredQuestionIds.removeAll(submittedQuestionIds2); + throw new MissingRequiredQuestionException(missingRequiredQuestionIds); + } + + // 제출된 리뷰의 질문 중에서 필수가 아닌 질문이 포함되었는지 검사 + requiredQuestionIds.forEach(submittedQuestionIds2::remove); + List unnecessaryQuestionIds = questionRepository.findAllById(submittedQuestionIds2) + .stream() + .filter(Question::isRequired) + .map(Question::getId) + .toList(); + if (!unnecessaryQuestionIds.isEmpty()) { + throw new UnnecessaryQuestionIncludedException(unnecessaryQuestionIds); + } + } + + private Long saveReview(CreateReviewRequest request, ReviewGroup reviewGroup) { + List textAnswers = new ArrayList<>(); + List checkboxAnswers = new ArrayList<>(); + for (CreateReviewAnswerRequest answerRequests : request.answers()) { + Question question = questionRepository.findById(answerRequests.questionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(answerRequests.questionId())); + QuestionType questionType = question.getQuestionType(); + if (questionType == QuestionType.TEXT && answerRequests.isNotBlank()) { + createTextAnswerRequestValidator.validate(answerRequests); + textAnswers.add(new TextAnswer(question.getId(), answerRequests.text())); + continue; + } + if (questionType == QuestionType.CHECKBOX) { + createCheckBoxAnswerRequestValidator.validate(answerRequests); + checkboxAnswers.add(new CheckboxAnswer(question.getId(), answerRequests.selectedOptionIds())); + } + } + + Review savedReview = reviewRepository.save( + new Review(reviewGroup.getTemplateId(), reviewGroup.getId(), textAnswers, checkboxAnswers) + ); + return savedReview.getId(); + } +} diff --git a/backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java b/backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java new file mode 100644 index 000000000..f47d03852 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/CreateTextAnswerRequestValidator.java @@ -0,0 +1,48 @@ +package reviewme.review.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.Question; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.exception.InvalidTextAnswerLengthException; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; + +@Component +@RequiredArgsConstructor +public class CreateTextAnswerRequestValidator { + + private static final int MIN_LENGTH = 20; + private static final int MAX_LENGTH = 1_000; + + private final QuestionRepository questionRepository; + + public void validate(CreateReviewAnswerRequest request) { + Question question = questionRepository.findById(request.questionId()) + .orElseThrow(() -> new SubmittedQuestionNotFoundException(request.questionId())); + validateNotIncludingOptions(request); + validateQuestionRequired(question, request); + validateLength(request); + } + + private void validateNotIncludingOptions(CreateReviewAnswerRequest request) { + if (request.selectedOptionIds() != null) { + throw new TextAnswerIncludedOptionItemException(); + } + } + + private void validateQuestionRequired(Question question, CreateReviewAnswerRequest request) { + if (question.isRequired() && request.text() == null) { + throw new RequiredQuestionNotAnsweredException(question.getId()); + } + } + + private void validateLength(CreateReviewAnswerRequest request) { + int textLength = request.text().length(); + if (textLength < MIN_LENGTH || textLength > MAX_LENGTH) { + throw new InvalidTextAnswerLengthException(textLength, MIN_LENGTH, MAX_LENGTH); + } + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java new file mode 100644 index 000000000..eb9fb04f9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java @@ -0,0 +1,155 @@ +package reviewme.review.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.Question; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswers; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.domain.TextAnswers; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.detail.OptionGroupAnswerResponse; +import reviewme.review.service.dto.response.detail.OptionItemAnswerResponse; +import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.review.service.exception.ReviewNotFoundByIdAndGroupException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.exception.OptionGroupNotFoundByQuestionIdException; +import reviewme.template.repository.SectionRepository; + +@Service +@Transactional(readOnly = true) +@AllArgsConstructor +public class ReviewDetailLookupService { + + private final SectionRepository sectionRepository; + private final ReviewRepository reviewRepository; + private final ReviewGroupRepository reviewGroupRepository; + private final QuestionRepository questionRepository; + private final OptionItemRepository optionItemRepository; + private final OptionGroupRepository optionGroupRepository; + + public TemplateAnswerResponse getReviewDetail(long reviewId, String reviewRequestCode, String groupAccessCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + if (!reviewGroup.matchesGroupAccessCode(groupAccessCode)) { + throw new ReviewGroupUnauthorizedException(reviewGroup.getId()); + } + Review review = reviewRepository.findByIdAndReviewGroupId(reviewId, reviewGroup.getId()) + .orElseThrow(() -> new ReviewNotFoundByIdAndGroupException(reviewId, reviewGroup.getId())); + + long templateId = review.getTemplateId(); + + List
sections = sectionRepository.findAllByTemplateId(templateId); + List sectionResponses = new ArrayList<>(); + + for (Section section : sections) { + addSectionResponse(review, reviewGroup, section, sectionResponses); + } + + return new TemplateAnswerResponse( + templateId, + reviewGroup.getReviewee(), + reviewGroup.getProjectName(), + review.getCreatedDate(), + sectionResponses + ); + } + + private void addSectionResponse(Review review, ReviewGroup reviewGroup, + Section section, List sectionResponses) { + ArrayList questionResponses = new ArrayList<>(); + + for (Question question : questionRepository.findAllBySectionId(section.getId())) { + if (question.isSelectable()) { + addCheckboxQuestionResponse(review, reviewGroup, question, questionResponses); + } else { + addTextQuestionResponse(review, reviewGroup, question, questionResponses); + } + } + + if (!questionResponses.isEmpty()) { + sectionResponses.add(new SectionAnswerResponse( + section.getId(), + section.convertHeader("{revieweeName}", reviewGroup.getReviewee()), + questionResponses + )); + } + } + + private void addCheckboxQuestionResponse(Review review, ReviewGroup reviewGroup, + Question question, ArrayList questionResponses) { + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(review.getCheckboxAnswers()); + + if (checkboxAnswers.hasAnswerByQuestionId(question.getId())) { + questionResponses.add(getCheckboxAnswerResponse(review, question, reviewGroup)); + } + + } + + private void addTextQuestionResponse(Review review, ReviewGroup reviewGroup, + Question question, ArrayList questionResponses) { + TextAnswers textAnswers = new TextAnswers(review.getTextAnswers()); + + if (textAnswers.hasAnswerByQuestionId(question.getId())) { + questionResponses.add(getTextAnswerResponse(textAnswers, question, reviewGroup)); + } + } + + private QuestionAnswerResponse getCheckboxAnswerResponse(Review review, Question question, + ReviewGroup reviewGroup) { + OptionGroup optionGroup = optionGroupRepository.findByQuestionId(question.getId()) + .orElseThrow(() -> new OptionGroupNotFoundByQuestionIdException(question.getId())); + Set selectedOptionItemIds = optionItemRepository.findSelectedOptionItemIdsByReviewId(review.getId()); + List optionItemResponse = + optionItemRepository.findSelectedOptionItemsByReviewIdAndQuestionId(review.getId(), question.getId()) + .stream() + .map(optionItem -> new OptionItemAnswerResponse( + optionItem.getId(), + optionItem.getContent(), + selectedOptionItemIds.contains(optionItem.getId())) + ).toList(); + + OptionGroupAnswerResponse optionGroupAnswerResponse = new OptionGroupAnswerResponse( + optionGroup.getId(), + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount(), + optionItemResponse + ); + + return new QuestionAnswerResponse( + question.getId(), + question.isRequired(), + question.getQuestionType(), + question.convertContent("{revieweeName}", reviewGroup.getReviewee()), + optionGroupAnswerResponse, + null + ); + } + + private QuestionAnswerResponse getTextAnswerResponse(TextAnswers textAnswers, Question question, + ReviewGroup reviewGroup) { + TextAnswer textAnswer = textAnswers.getAnswerByQuestionId(question.getId()); + return new QuestionAnswerResponse( + question.getId(), + question.isRequired(), + question.getQuestionType(), + question.convertContent("{revieweeName}", reviewGroup.getReviewee()), + null, + textAnswer.getContent() + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java b/backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java new file mode 100644 index 000000000..d0d49781d --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewPreviewGenerator.java @@ -0,0 +1,20 @@ +package reviewme.review.service; + +import java.util.List; +import reviewme.review.domain.TextAnswer; + +public class ReviewPreviewGenerator { + + private static final int PREVIEW_LENGTH = 150; + + public String generatePreview(List reviewTextAnswers) { + if (reviewTextAnswers.isEmpty()) { + return ""; + } + String answer = reviewTextAnswers.get(0).getContent(); + if (answer.length() > PREVIEW_LENGTH) { + return answer.substring(0, PREVIEW_LENGTH); + } + return answer; + } +} diff --git a/backend/src/main/java/reviewme/review/service/ReviewService.java b/backend/src/main/java/reviewme/review/service/ReviewService.java new file mode 100644 index 000000000..1ac3d0b3b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/ReviewService.java @@ -0,0 +1,63 @@ +package reviewme.review.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.repository.OptionItemRepository; +import reviewme.review.domain.Review; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.list.ReceivedReviewCategoryResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewGroupRepository reviewGroupRepository; + private final OptionItemRepository optionItemRepository; + private final ReviewRepository reviewRepository; + + private final ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); + + @Transactional(readOnly = true) + public ReceivedReviewsResponse findReceivedReviews(String reviewRequestCode, String groupAccessCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + if (!reviewGroup.matchesGroupAccessCode(groupAccessCode)) { + throw new ReviewGroupUnauthorizedException(reviewGroup.getId()); + } + + List reviewResponses = + reviewRepository.findReceivedReviewsByGroupId(reviewGroup.getId()) + .stream() + .map(this::createReceivedReviewResponse) + .toList(); + + return new ReceivedReviewsResponse(reviewGroup.getReviewee(), reviewGroup.getProjectName(), reviewResponses); + } + + private ReceivedReviewResponse createReceivedReviewResponse(Review review) { + List categoryOptionItems = optionItemRepository.findByReviewIdAndOptionType(review.getId(), + OptionType.CATEGORY); + + List categoryResponses = categoryOptionItems.stream() + .map(optionItem -> new ReceivedReviewCategoryResponse(optionItem.getId(), optionItem.getContent())) + .toList(); + + return new ReceivedReviewResponse( + review.getId(), + review.getCreatedAt().toLocalDate(), + reviewPreviewGenerator.generatePreview(review.getTextAnswers()), + categoryResponses + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java new file mode 100644 index 000000000..32ee6b238 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewAnswerRequest.java @@ -0,0 +1,21 @@ +package reviewme.review.service.dto.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record CreateReviewAnswerRequest( + + @NotNull(message = "질문 ID를 입력해주세요.") + Long questionId, + + @Nullable + List selectedOptionIds, + + @Nullable + String text +) { + public boolean isNotBlank() { + return text != null && !text.isBlank(); + } +} diff --git a/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java new file mode 100644 index 000000000..bfb47b769 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/request/CreateReviewRequest.java @@ -0,0 +1,15 @@ +package reviewme.review.service.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record CreateReviewRequest( + + @NotBlank(message = "리뷰 요청 코드를 입력해주세요.") + String reviewRequestCode, + + @NotEmpty(message = "답변 내용을 입력해주세요.") + List answers +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java new file mode 100644 index 000000000..894dbaae8 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionGroupAnswerResponse.java @@ -0,0 +1,11 @@ +package reviewme.review.service.dto.response.detail; + +import java.util.List; + +public record OptionGroupAnswerResponse( + long optionGroupId, + long minCount, + long maxCount, + List options +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java new file mode 100644 index 000000000..6bd424f5f --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/OptionItemAnswerResponse.java @@ -0,0 +1,8 @@ +package reviewme.review.service.dto.response.detail; + +public record OptionItemAnswerResponse( + long optionId, + String content, + boolean isChecked +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java new file mode 100644 index 000000000..000eb83c8 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/QuestionAnswerResponse.java @@ -0,0 +1,14 @@ +package reviewme.review.service.dto.response.detail; + +import jakarta.annotation.Nullable; +import reviewme.question.domain.QuestionType; + +public record QuestionAnswerResponse( + long questionId, + boolean required, + QuestionType questionType, + String content, + @Nullable OptionGroupAnswerResponse optionGroup, + @Nullable String answer +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java new file mode 100644 index 000000000..ad2887644 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/SectionAnswerResponse.java @@ -0,0 +1,10 @@ +package reviewme.review.service.dto.response.detail; + +import java.util.List; + +public record SectionAnswerResponse( + long sectionId, + String header, + List questions +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java new file mode 100644 index 000000000..0e838236b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/detail/TemplateAnswerResponse.java @@ -0,0 +1,13 @@ +package reviewme.review.service.dto.response.detail; + +import java.time.LocalDate; +import java.util.List; + +public record TemplateAnswerResponse( + long formId, + String revieweeName, + String projectName, + LocalDate createdAt, + List sections +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java new file mode 100644 index 000000000..298e78faa --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewCategoryResponse.java @@ -0,0 +1,7 @@ +package reviewme.review.service.dto.response.list; + +public record ReceivedReviewCategoryResponse( + long optionId, + String content +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java new file mode 100644 index 000000000..fa6804c18 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewResponse.java @@ -0,0 +1,12 @@ +package reviewme.review.service.dto.response.list; + +import java.time.LocalDate; +import java.util.List; + +public record ReceivedReviewResponse( + long reviewId, + LocalDate createdAt, + String contentPreview, + List categories +) { +} diff --git a/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java new file mode 100644 index 000000000..877b3a0de --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/dto/response/list/ReceivedReviewsResponse.java @@ -0,0 +1,10 @@ +package reviewme.review.service.dto.response.list; + +import java.util.List; + +public record ReceivedReviewsResponse( + String revieweeName, + String projectName, + List reviews +) { +} diff --git a/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java new file mode 100644 index 000000000..a7d7e04b0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedNotProvidedOptionItemException.java @@ -0,0 +1,17 @@ +package reviewme.review.service.exception; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +public class CheckBoxAnswerIncludedNotProvidedOptionItemException extends UnexpectedRequestException { + + public CheckBoxAnswerIncludedNotProvidedOptionItemException(long questionId, + List providedOptionIds, + List submittedOptionIds) { + super("제공되는 선택지에 없는 선택지를 응답했어요."); + log.warn("Answer included not provided options - questionId:{}, providedOptionIds: {}, submittedOptionIds: {}", + questionId, providedOptionIds, submittedOptionIds, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java new file mode 100644 index 000000000..f3a7843b6 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/CheckBoxAnswerIncludedTextException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class CheckBoxAnswerIncludedTextException extends BadRequestException { + + public CheckBoxAnswerIncludedTextException() { + super("체크박스형 응답은 텍스트를 포함할 수 없어요."); + log.warn("CheckBox type answer cannot have option items"); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java b/backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java new file mode 100644 index 000000000..efac7de80 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/MissingRequiredQuestionException.java @@ -0,0 +1,15 @@ +package reviewme.review.service.exception; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class MissingRequiredQuestionException extends BadRequestException { + + public MissingRequiredQuestionException(List missingRequiredQuestionIds) { + super("필수 질문을 제출하지 않았어요."); + log.warn("Required question is not submitted. Missing Required questionIds: {}", + missingRequiredQuestionIds, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java b/backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java new file mode 100644 index 000000000..517354d35 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/OptionItemNotFoundBySelectedOptionId.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class OptionItemNotFoundBySelectedOptionId extends DataInconsistencyException { + + public OptionItemNotFoundBySelectedOptionId(long selectedOptionId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Submitted checkBox's option item is not exist in database - selectedOptionId: {}", selectedOptionId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java b/backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java new file mode 100644 index 000000000..0367b93f6 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/RequiredQuestionNotAnsweredException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class RequiredQuestionNotAnsweredException extends BadRequestException { + + public RequiredQuestionNotAnsweredException(long questionId) { + super("필수 질문의 답변을 작성하지 않았어요."); + log.warn("Required question must be answered - questionId: {}", questionId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java new file mode 100644 index 000000000..7fa65044b --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupNotFoundByCodesException.java @@ -0,0 +1,14 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class ReviewGroupNotFoundByCodesException extends BadRequestException { + + public ReviewGroupNotFoundByCodesException(String reviewRequestCode, String groupAccessCode) { + super("인증 정보에 해당하는 리뷰 확인 코드와 리뷰 요청 코드를 통해 찾을 수 있는 리뷰 그룹이 없어요."); + log.info("ReviewGroup not found by codes - reviewRequestCode: {}, groupAccessCode: {}", + reviewRequestCode, groupAccessCode); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java new file mode 100644 index 000000000..e18bd7e34 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewGroupUnauthorizedException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnauthorizedException; + +@Slf4j +public class ReviewGroupUnauthorizedException extends UnauthorizedException { + + public ReviewGroupUnauthorizedException(long reviewGroupId) { + super("리뷰를 확인할 권한이 없어요."); + log.info("Group access code mismatch on review group: {}", reviewGroupId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java new file mode 100644 index 000000000..11cbc93e5 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundByIdAndGroupException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewNotFoundByIdAndGroupException extends NotFoundException { + + public ReviewNotFoundByIdAndGroupException(long reviewId, long reviewGroupId) { + super("리뷰를 찾을 수 없어요"); + log.info("Review not found from group - reviewGroupId: {}, reviewId: {}", reviewGroupId, reviewId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java new file mode 100644 index 000000000..ed4d79c00 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class ReviewNotFoundException extends NotFoundException { + + public ReviewNotFoundException(String reviewRequestCode, long reviewId) { + super("리뷰가 존재하지 않아요."); + log.info("Review not found: reviewRequestCode: {}, reviewId: {}", reviewRequestCode, reviewId); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java b/backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java new file mode 100644 index 000000000..a91cc26d0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SelectedOptionItemCountOutOfRangeException.java @@ -0,0 +1,18 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class SelectedOptionItemCountOutOfRangeException extends BadRequestException { + + public SelectedOptionItemCountOutOfRangeException(long questionId, int selectedCount, + int minSelectionCount, int maxSelectionCount) { + super("체크박스 응답 개수가 범위를 벗어났어요. (선택된 개수: %d, 최소 개수: %d, 최대 개수: %d)" + .formatted(selectedCount, minSelectionCount, maxSelectionCount)); + log.warn( + "CheckBox answer count out of range - questionId: {}, selectedCount: {}, minSelectionCount: {}, maxSelectionCount: {}", + questionId, selectedCount, minSelectionCount, maxSelectionCount, this + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java new file mode 100644 index 000000000..d2a981e12 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionAndProvidedQuestionMismatchException.java @@ -0,0 +1,18 @@ +package reviewme.review.service.exception; + +import java.util.Collection; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.UnexpectedRequestException; + +@Slf4j +public class SubmittedQuestionAndProvidedQuestionMismatchException extends UnexpectedRequestException { + + public SubmittedQuestionAndProvidedQuestionMismatchException(Collection submittedQuestionIds, + Collection providedQuestionIds) { + super("제출된 응답이 제공된 질문과 매칭되지 않아요."); + log.warn( + "Submitted questions and provided questions mismatch. submittedQuestionIds: {}, providedQuestionIds: {}", + submittedQuestionIds, providedQuestionIds, this + ); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java new file mode 100644 index 000000000..dc326ac32 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/SubmittedQuestionNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.NotFoundException; + +@Slf4j +public class SubmittedQuestionNotFoundException extends NotFoundException { + + public SubmittedQuestionNotFoundException(long questionId) { + super("제출된 질문이 존재하지 않아요."); + log.warn("Submitted question not found - questionId: {}", questionId, this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java b/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java new file mode 100644 index 000000000..681af19a9 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/TextAnswerIncludedOptionItemException.java @@ -0,0 +1,13 @@ +package reviewme.review.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class TextAnswerIncludedOptionItemException extends BadRequestException { + + public TextAnswerIncludedOptionItemException() { + super("텍스트형 응답은 옵션 항목을 포함할 수 없어요."); + log.warn("Text type answer cannot have option items", this); + } +} diff --git a/backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java b/backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java new file mode 100644 index 000000000..2afeaf0b0 --- /dev/null +++ b/backend/src/main/java/reviewme/review/service/exception/UnnecessaryQuestionIncludedException.java @@ -0,0 +1,14 @@ +package reviewme.review.service.exception; + +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class UnnecessaryQuestionIncludedException extends BadRequestException { + + public UnnecessaryQuestionIncludedException(List unnecessaryQuestionIds) { + super("제출해야 할 질문 이외의 질문에 응답했습니다."); + log.warn("Unnecessary question has submitted. unnecessaryQuestionIds: {}", unnecessaryQuestionIds, this); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java new file mode 100644 index 000000000..4bf631b98 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/controller/ReviewGroupController.java @@ -0,0 +1,47 @@ +package reviewme.reviewgroup.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reviewme.reviewgroup.service.ReviewGroupLookupService; +import reviewme.reviewgroup.service.ReviewGroupService; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; + +@RestController +@RequiredArgsConstructor +public class ReviewGroupController { + + private final ReviewGroupService reviewGroupService; + private final ReviewGroupLookupService reviewGroupLookupService; + + @GetMapping("/v2/groups") + public ResponseEntity getReviewGroupSummary(@RequestParam String reviewRequestCode) { + ReviewGroupResponse response = reviewGroupLookupService.getReviewGroupSummary(reviewRequestCode); + return ResponseEntity.ok(response); + } + + @PostMapping("/v2/groups") + public ResponseEntity createReviewGroup( + @Valid @RequestBody ReviewGroupCreationRequest request + ) { + ReviewGroupCreationResponse response = reviewGroupService.createReviewGroup(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/v2/groups/check") + public ResponseEntity checkGroupAccessCode( + @RequestBody @Valid CheckValidAccessRequest request + ) { + CheckValidAccessResponse response = reviewGroupService.checkGroupAccessCode(request); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java b/backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java new file mode 100644 index 000000000..25764e63d --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/domain/GroupAccessCode.java @@ -0,0 +1,38 @@ +package reviewme.reviewgroup.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.reviewgroup.domain.exception.InvalidGroupAccessCodeFormatException; +import reviewme.util.Encoder; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class GroupAccessCode { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]{4,20}$"); + + @Column(name = "group_access_code", nullable = false) + private String code; + + public GroupAccessCode(String code) { + validateGroupAccessCode(code); + this.code = Encoder.encode(code); + } + + private void validateGroupAccessCode(String groupAccessCode) { + Matcher matcher = PATTERN.matcher(groupAccessCode); + if (!matcher.matches()) { + throw new InvalidGroupAccessCodeFormatException(groupAccessCode); + } + } + + public boolean matches(String groupAccessCode) { + return code.equals(Encoder.encode(groupAccessCode)); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java new file mode 100644 index 000000000..9da094186 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/domain/ReviewGroup.java @@ -0,0 +1,76 @@ +package reviewme.reviewgroup.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import reviewme.review.domain.exception.InvalidProjectNameLengthException; +import reviewme.review.domain.exception.InvalidRevieweeNameLengthException; + +@Entity +@Table(name = "review_group") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ReviewGroup { + + private static final int MIN_REVIEWEE_LENGTH = 1; + private static final int MAX_REVIEWEE_LENGTH = 50; + private static final int MIN_PROJECT_NAME_LENGTH = 1; + private static final int MAX_PROJECT_NAME_LENGTH = 50; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reviewee", nullable = false) + private String reviewee; + + @Column(name = "project_name", nullable = false) + private String projectName; + + @Column(name = "review_request_code", nullable = false) + private String reviewRequestCode; + + @Embedded + private GroupAccessCode groupAccessCode; + + @Column(name = "template_id", nullable = false) + private long templateId = 1L; + + public ReviewGroup(String reviewee, String projectName, String reviewRequestCode, String groupAccessCode) { + validateRevieweeLength(reviewee); + validateProjectNameLength(projectName); + this.reviewee = reviewee; + this.projectName = projectName; + this.reviewRequestCode = reviewRequestCode; + this.groupAccessCode = new GroupAccessCode(groupAccessCode); + } + + private void validateRevieweeLength(String reviewee) { + if (reviewee.length() < MIN_REVIEWEE_LENGTH || reviewee.length() > MAX_REVIEWEE_LENGTH) { + throw new InvalidRevieweeNameLengthException(reviewee.length(), MIN_REVIEWEE_LENGTH, MAX_REVIEWEE_LENGTH); + } + } + + private void validateProjectNameLength(String projectName) { + if (projectName.length() < MIN_PROJECT_NAME_LENGTH || projectName.length() > MAX_PROJECT_NAME_LENGTH) { + throw new InvalidProjectNameLengthException( + projectName.length(), MIN_PROJECT_NAME_LENGTH, MAX_PROJECT_NAME_LENGTH + ); + } + } + + public boolean matchesGroupAccessCode(String code) { + return groupAccessCode.matches(code); + } + + public String getGroupAccessCode() { + return groupAccessCode.getCode(); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java new file mode 100644 index 000000000..e4425871b --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/domain/exception/InvalidGroupAccessCodeFormatException.java @@ -0,0 +1,13 @@ +package reviewme.reviewgroup.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.BadRequestException; + +@Slf4j +public class InvalidGroupAccessCodeFormatException extends BadRequestException { + + public InvalidGroupAccessCodeFormatException(String groupAccessCode) { + super("그룹 액세스 코드 형식이 올바르지 않아요."); + log.warn("Invalid groupAccessCode format - groupAccessCode: {}", groupAccessCode); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java b/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java new file mode 100644 index 000000000..5dd2d3ed8 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java @@ -0,0 +1,18 @@ +package reviewme.reviewgroup.repository; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.reviewgroup.domain.ReviewGroup; + +@Repository +public interface ReviewGroupRepository extends JpaRepository { + + Optional findByReviewRequestCode(String reviewRequestCode); + + Optional findByReviewRequestCodeAndGroupAccessCode_Code( + String reviewRequestCode, String groupAccessCode + ); + + boolean existsByReviewRequestCode(String reviewRequestCode); +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java b/backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java new file mode 100644 index 000000000..852140f2c --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/RandomCodeGenerator.java @@ -0,0 +1,23 @@ +package reviewme.reviewgroup.service; + +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Component; + +@Component +public class RandomCodeGenerator { + + private static final Random random = ThreadLocalRandom.current(); + private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + private static final String NUMBERS = "0123456789"; + private static final String CHARACTER = UPPERCASE + LOWERCASE + NUMBERS; + + public String generate(int length) { + StringBuilder sb = new StringBuilder(); + random.ints(length, 0, CHARACTER.length()) + .mapToObj(CHARACTER::charAt) + .forEach(sb::append); + return sb.toString(); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java new file mode 100644 index 000000000..324d56817 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupLookupService.java @@ -0,0 +1,22 @@ +package reviewme.reviewgroup.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; + +@Service +@RequiredArgsConstructor +public class ReviewGroupLookupService { + + private final ReviewGroupRepository reviewGroupRepository; + + public ReviewGroupResponse getReviewGroupSummary(String reviewRequestCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + return new ReviewGroupResponse(reviewGroup.getReviewee(), reviewGroup.getProjectName()); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java new file mode 100644 index 000000000..fa197a3e7 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/ReviewGroupService.java @@ -0,0 +1,46 @@ +package reviewme.reviewgroup.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; + +@Service +@RequiredArgsConstructor +public class ReviewGroupService { + + private static final int REVIEW_REQUEST_CODE_LENGTH = 8; + + private final ReviewGroupRepository reviewGroupRepository; + private final RandomCodeGenerator randomCodeGenerator; + + @Transactional + public ReviewGroupCreationResponse createReviewGroup(ReviewGroupCreationRequest request) { + String reviewRequestCode; + do { + reviewRequestCode = randomCodeGenerator.generate(REVIEW_REQUEST_CODE_LENGTH); + } while (reviewGroupRepository.existsByReviewRequestCode(reviewRequestCode)); + + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup( + request.revieweeName(), request.projectName(), reviewRequestCode, request.groupAccessCode() + ) + ); + return new ReviewGroupCreationResponse(reviewGroup.getReviewRequestCode()); + } + + @Transactional(readOnly = true) + public CheckValidAccessResponse checkGroupAccessCode(CheckValidAccessRequest request) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(request.reviewRequestCode()) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(request.reviewRequestCode())); + + boolean hasAccess = reviewGroup.matchesGroupAccessCode(request.groupAccessCode()); + return new CheckValidAccessResponse(hasAccess); + } +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java new file mode 100644 index 000000000..8a55df064 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessRequest.java @@ -0,0 +1,13 @@ +package reviewme.reviewgroup.service.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CheckValidAccessRequest( + + @NotBlank(message = "리뷰 요청 코드를 입력하세요.") + String reviewRequestCode, + + @NotBlank(message = "리뷰 확인 코드를 입력하세요.") + String groupAccessCode +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java new file mode 100644 index 000000000..01444a880 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/CheckValidAccessResponse.java @@ -0,0 +1,6 @@ +package reviewme.reviewgroup.service.dto; + +public record CheckValidAccessResponse( + boolean hasAccess +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java new file mode 100644 index 000000000..fdfe49dc5 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationRequest.java @@ -0,0 +1,16 @@ +package reviewme.reviewgroup.service.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ReviewGroupCreationRequest( + + @NotBlank(message = "리뷰이 이름을 입력해주세요.") + String revieweeName, + + @NotBlank(message = "프로젝트 이름을 입력해주세요.") + String projectName, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String groupAccessCode +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java new file mode 100644 index 000000000..d1c61dcf3 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupCreationResponse.java @@ -0,0 +1,6 @@ +package reviewme.reviewgroup.service.dto; + +public record ReviewGroupCreationResponse( + String reviewRequestCode +) { +} diff --git a/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java new file mode 100644 index 000000000..ea6f12a29 --- /dev/null +++ b/backend/src/main/java/reviewme/reviewgroup/service/dto/ReviewGroupResponse.java @@ -0,0 +1,8 @@ +package reviewme.reviewgroup.service.dto; + +public record ReviewGroupResponse( + + String revieweeName, + String projectName +) { +} diff --git a/backend/src/main/java/reviewme/template/controller/TemplateController.java b/backend/src/main/java/reviewme/template/controller/TemplateController.java new file mode 100644 index 000000000..f09650656 --- /dev/null +++ b/backend/src/main/java/reviewme/template/controller/TemplateController.java @@ -0,0 +1,22 @@ +package reviewme.template.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reviewme.template.service.TemplateService; +import reviewme.template.service.dto.response.TemplateResponse; + +@RestController +@RequiredArgsConstructor +public class TemplateController { + + private final TemplateService templateService; + + @GetMapping("/v2/reviews/write") + public ResponseEntity getReviewForm(@RequestParam String reviewRequestCode) { + TemplateResponse response = templateService.generateReviewForm(reviewRequestCode); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/Section.java b/backend/src/main/java/reviewme/template/domain/Section.java new file mode 100644 index 000000000..696f39bc6 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/Section.java @@ -0,0 +1,72 @@ +package reviewme.template.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.Collection; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "section") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Section { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "visible_type", nullable = false) + @Enumerated(EnumType.STRING) + private VisibleType visibleType; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "section_id", nullable = false, updatable = false) + private List questionIds; + + @Column(name = "on_selected_option_id", nullable = true) + private Long onSelectedOptionId; + + @Column(name = "section_name", nullable = false) + private String sectionName; + + @Column(name = "header", nullable = false, length = 1_000) + private String header; + + @Column(name = "position", nullable = false) + private int position; + + public Section(VisibleType visibleType, List questionIds, + Long onSelectedOptionId, String sectionName, String header, int position) { + this.visibleType = visibleType; + this.questionIds = questionIds.stream() + .map(SectionQuestion::new) + .toList(); + this.onSelectedOptionId = onSelectedOptionId; + this.sectionName = sectionName; + this.header = header; + this.position = position; + } + + public boolean isVisibleBySelectedOptionIds(Collection selectedOptionIds) { + return visibleType == VisibleType.ALWAYS || selectedOptionIds.contains(onSelectedOptionId); + } + + public String convertHeader(String target, String replacement) { + return header.replace(target, replacement); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/SectionQuestion.java b/backend/src/main/java/reviewme/template/domain/SectionQuestion.java new file mode 100644 index 000000000..eaac6e73e --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/SectionQuestion.java @@ -0,0 +1,32 @@ +package reviewme.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "section_question") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class SectionQuestion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "section_id", nullable = false, insertable = false, updatable = false) + private long sectionId; + + @Column(name = "question_id", nullable = false) + private long questionId; + + public SectionQuestion(long questionId) { + this.questionId = questionId; + } +} diff --git a/backend/src/main/java/reviewme/template/domain/Template.java b/backend/src/main/java/reviewme/template/domain/Template.java new file mode 100644 index 000000000..29b6f36d7 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/Template.java @@ -0,0 +1,38 @@ +package reviewme.template.domain; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.List; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "template") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode(of = "id") +@Getter +public class Template { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "template_id", nullable = false, updatable = false) + private List sectionIds; + + public Template(List sectionIds) { + this.sectionIds = sectionIds.stream() + .map(TemplateSection::new) + .toList(); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/TemplateSection.java b/backend/src/main/java/reviewme/template/domain/TemplateSection.java new file mode 100644 index 000000000..6d451ee80 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/TemplateSection.java @@ -0,0 +1,32 @@ +package reviewme.template.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "template_section") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class TemplateSection { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "template_id", nullable = false, insertable = false, updatable = false) + private long templateId; + + @Column(name = "section_id", nullable = false) + private long sectionId; + + public TemplateSection(long sectionId) { + this.sectionId = sectionId; + } +} diff --git a/backend/src/main/java/reviewme/template/domain/VisibleType.java b/backend/src/main/java/reviewme/template/domain/VisibleType.java new file mode 100644 index 000000000..3f899f526 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/VisibleType.java @@ -0,0 +1,8 @@ +package reviewme.template.domain; + +public enum VisibleType { + + ALWAYS, + CONDITIONAL, + ; +} diff --git a/backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java b/backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java new file mode 100644 index 000000000..88bcd7f3b --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/exception/OptionGroupNotFoundByQuestionIdException.java @@ -0,0 +1,13 @@ +package reviewme.template.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class OptionGroupNotFoundByQuestionIdException extends DataInconsistencyException { + + public OptionGroupNotFoundByQuestionIdException(long questionId) { + super("응답한 질문과 대응하는 선택형 문항이 존재하지 않아요."); + log.error("User submitted checkBoxAnswer without provided options - questionId: {}", questionId); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java b/backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java new file mode 100644 index 000000000..35d03ab73 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/exception/SectionInTemplateNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.template.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class SectionInTemplateNotFoundException extends DataInconsistencyException { + + public SectionInTemplateNotFoundException(long templateId, long sectionId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.warn("SectionNotFoundException has occurred - templateId: {}, sectionId: {}", templateId, sectionId); + } +} diff --git a/backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java b/backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java new file mode 100644 index 000000000..8380dc304 --- /dev/null +++ b/backend/src/main/java/reviewme/template/domain/exception/TemplateNotFoundByReviewGroupException.java @@ -0,0 +1,14 @@ +package reviewme.template.domain.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class TemplateNotFoundByReviewGroupException extends DataInconsistencyException { + + public TemplateNotFoundByReviewGroupException(long reviewGroupId, long templateId) { + super("서버 내부에서 문제가 발생했어요. 서버에 문의해주세요."); + log.error("Template not found by groupAccessCode - reviewGroupId: {}, templateId: {}", + reviewGroupId, templateId, this); + } +} diff --git a/backend/src/main/java/reviewme/template/repository/SectionRepository.java b/backend/src/main/java/reviewme/template/repository/SectionRepository.java new file mode 100644 index 000000000..2b1babc98 --- /dev/null +++ b/backend/src/main/java/reviewme/template/repository/SectionRepository.java @@ -0,0 +1,20 @@ +package reviewme.template.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import reviewme.template.domain.Section; + +@Repository +public interface SectionRepository extends JpaRepository { + + @Query(value = """ + SELECT s.* FROM section s + LEFT JOIN template_section ts + ON ts.section_id = s.id + WHERE ts.template_id = :templateId + ORDER BY s.position ASC + """, nativeQuery = true) + List
findAllByTemplateId(long templateId); +} diff --git a/backend/src/main/java/reviewme/template/repository/TemplateRepository.java b/backend/src/main/java/reviewme/template/repository/TemplateRepository.java new file mode 100644 index 000000000..e336a4a09 --- /dev/null +++ b/backend/src/main/java/reviewme/template/repository/TemplateRepository.java @@ -0,0 +1,9 @@ +package reviewme.template.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import reviewme.template.domain.Template; + +@Repository +public interface TemplateRepository extends JpaRepository { +} diff --git a/backend/src/main/java/reviewme/template/service/TemplateMapper.java b/backend/src/main/java/reviewme/template/service/TemplateMapper.java new file mode 100644 index 000000000..15b5cb0a2 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/TemplateMapper.java @@ -0,0 +1,111 @@ +package reviewme.template.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.Question; +import reviewme.question.domain.exception.MissingOptionItemsInOptionGroupException; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.template.domain.Section; +import reviewme.template.domain.SectionQuestion; +import reviewme.template.domain.Template; +import reviewme.template.domain.TemplateSection; +import reviewme.template.domain.exception.SectionInTemplateNotFoundException; +import reviewme.template.repository.SectionRepository; +import reviewme.template.service.dto.response.OptionGroupResponse; +import reviewme.template.service.dto.response.OptionItemResponse; +import reviewme.template.service.dto.response.QuestionResponse; +import reviewme.template.service.dto.response.SectionResponse; +import reviewme.template.service.dto.response.TemplateResponse; +import reviewme.template.service.exception.QuestionInSectionNotFoundException; + +@Component +@RequiredArgsConstructor +public class TemplateMapper { + + private final SectionRepository sectionRepository; + private final QuestionRepository questionRepository; + private final OptionGroupRepository optionGroupRepository; + private final OptionItemRepository optionItemRepository; + + public TemplateResponse mapToTemplateResponse(ReviewGroup reviewGroup, Template template) { + List sectionResponses = template.getSectionIds() + .stream() + .map(templateSection -> mapToSectionResponse(templateSection, reviewGroup)) + .toList(); + + return new TemplateResponse( + template.getId(), + reviewGroup.getReviewee(), + reviewGroup.getProjectName(), + sectionResponses + ); + } + + private SectionResponse mapToSectionResponse(TemplateSection templateSection, ReviewGroup reviewGroup) { + Section section = sectionRepository.findById(templateSection.getSectionId()) + .orElseThrow(() -> new SectionInTemplateNotFoundException( + templateSection.getTemplateId(), templateSection.getSectionId()) + ); + List questionResponses = section.getQuestionIds() + .stream() + .map(sectionQuestion -> mapToQuestionResponse(sectionQuestion, reviewGroup)) + .toList(); + + return new SectionResponse( + section.getId(), + section.getSectionName(), + section.getVisibleType().name(), + section.getOnSelectedOptionId(), + section.convertHeader("{revieweeName}", reviewGroup.getReviewee()), + questionResponses + ); + } + + private QuestionResponse mapToQuestionResponse(SectionQuestion sectionQuestion, ReviewGroup reviewGroup) { + Question question = questionRepository.findById(sectionQuestion.getQuestionId()) + .orElseThrow(() -> new QuestionInSectionNotFoundException( + sectionQuestion.getSectionId(), sectionQuestion.getQuestionId()) + ); + OptionGroupResponse optionGroupResponse = optionGroupRepository.findByQuestionId(question.getId()) + .map(this::mapToOptionGroupResponse) + .orElse(null); + + return new QuestionResponse( + question.getId(), + question.isRequired(), + question.convertContent("{revieweeName}", reviewGroup.getReviewee()), + question.getQuestionType().name(), + optionGroupResponse, + question.hasGuideline(), + question.convertGuideLine("{revieweeName}", reviewGroup.getReviewee()) + ); + } + + private OptionGroupResponse mapToOptionGroupResponse(OptionGroup optionGroup) { + List optionItems = optionItemRepository.findAllByOptionGroupId(optionGroup.getId()); + if (optionItems.isEmpty()) { + throw new MissingOptionItemsInOptionGroupException(optionGroup.getId()); + } + + List optionItemResponses = optionItems.stream() + .map(this::mapToOptionItemResponse) + .toList(); + + return new OptionGroupResponse( + optionGroup.getId(), + optionGroup.getMinSelectionCount(), + optionGroup.getMaxSelectionCount(), + optionItemResponses + ); + } + + private OptionItemResponse mapToOptionItemResponse(OptionItem optionItem) { + return new OptionItemResponse(optionItem.getId(), optionItem.getContent()); + } +} diff --git a/backend/src/main/java/reviewme/template/service/TemplateService.java b/backend/src/main/java/reviewme/template/service/TemplateService.java new file mode 100644 index 000000000..905de7ce9 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/TemplateService.java @@ -0,0 +1,34 @@ +package reviewme.template.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.template.domain.Template; +import reviewme.template.domain.exception.TemplateNotFoundByReviewGroupException; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.TemplateResponse; + +@Service +@RequiredArgsConstructor +public class TemplateService { + + private final ReviewGroupRepository reviewGroupRepository; + private final TemplateRepository templateRepository; + private final TemplateMapper templateMapper; + + @Transactional(readOnly = true) + public TemplateResponse generateReviewForm(String reviewRequestCode) { + ReviewGroup reviewGroup = reviewGroupRepository.findByReviewRequestCode(reviewRequestCode) + .orElseThrow(() -> new ReviewGroupNotFoundByReviewRequestCodeException(reviewRequestCode)); + + Template template = templateRepository.findById(reviewGroup.getTemplateId()) + .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( + reviewGroup.getId(), reviewGroup.getTemplateId() + )); + + return templateMapper.mapToTemplateResponse(reviewGroup, template); + } +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java new file mode 100644 index 000000000..c46f38148 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/OptionGroupResponse.java @@ -0,0 +1,11 @@ +package reviewme.template.service.dto.response; + +import java.util.List; + +public record OptionGroupResponse( + long optionGroupId, + int minCount, + int maxCount, + List options +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java new file mode 100644 index 000000000..b9e456989 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/OptionItemResponse.java @@ -0,0 +1,7 @@ +package reviewme.template.service.dto.response; + +public record OptionItemResponse( + long optionId, + String content +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java new file mode 100644 index 000000000..90d1fb45e --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/QuestionResponse.java @@ -0,0 +1,14 @@ +package reviewme.template.service.dto.response; + +import jakarta.annotation.Nullable; + +public record QuestionResponse( + long questionId, + boolean required, + String content, + String questionType, + @Nullable OptionGroupResponse optionGroup, + boolean hasGuideline, + @Nullable String guideline +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java new file mode 100644 index 000000000..31ae9d849 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/SectionResponse.java @@ -0,0 +1,14 @@ +package reviewme.template.service.dto.response; + +import jakarta.annotation.Nullable; +import java.util.List; + +public record SectionResponse( + long sectionId, + String sectionName, + String visible, + @Nullable Long onSelectedOptionId, + String header, + List questions +) { +} diff --git a/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java b/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java new file mode 100644 index 000000000..35575ca26 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/dto/response/TemplateResponse.java @@ -0,0 +1,11 @@ +package reviewme.template.service.dto.response; + +import java.util.List; + +public record TemplateResponse( + long formId, + String revieweeName, + String projectName, + List sections +) { +} diff --git a/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java b/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java new file mode 100644 index 000000000..23b7130e3 --- /dev/null +++ b/backend/src/main/java/reviewme/template/service/exception/QuestionInSectionNotFoundException.java @@ -0,0 +1,13 @@ +package reviewme.template.service.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.DataInconsistencyException; + +@Slf4j +public class QuestionInSectionNotFoundException extends DataInconsistencyException { + + public QuestionInSectionNotFoundException(long sectionId, long questionId) { + super("섹션에 질문이 존재하지 않아요."); + log.error("Question in section not found - sectionId: {}, questionId: {}", sectionId, questionId); + } +} diff --git a/backend/src/main/java/reviewme/util/Encoder.java b/backend/src/main/java/reviewme/util/Encoder.java new file mode 100644 index 000000000..4b096d196 --- /dev/null +++ b/backend/src/main/java/reviewme/util/Encoder.java @@ -0,0 +1,32 @@ +package reviewme.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class Encoder { + + private static final String SHA_256 = "SHA-256"; + + private Encoder() { + } + + public static String encode(String code) { + try { + MessageDigest messageDigest = MessageDigest.getInstance(SHA_256); + byte[] digest = messageDigest.digest(code.getBytes(UTF_8)); + return formatHexadecimal(digest); + } catch (NoSuchAlgorithmException e) { + throw new EncoderAlgorithmInitializationException(SHA_256); + } + } + + private static String formatHexadecimal(byte[] bytes) { + StringBuilder builder = new StringBuilder(); + for (byte b : bytes) { + builder.append("%02x".formatted(b)); + } + return builder.toString(); + } +} diff --git a/backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java b/backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java new file mode 100644 index 000000000..2155c6618 --- /dev/null +++ b/backend/src/main/java/reviewme/util/EncoderAlgorithmInitializationException.java @@ -0,0 +1,13 @@ +package reviewme.util; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.ReviewMeException; + +@Slf4j +public class EncoderAlgorithmInitializationException extends ReviewMeException { + + public EncoderAlgorithmInitializationException(String algorithm) { + super("서버 내부에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."); + log.error("Failed to initialize encoder: Algorithm not found: {}", algorithm, this); + } +} diff --git a/backend/src/main/resources/api-docs.yml b/backend/src/main/resources/api-docs.yml new file mode 100644 index 000000000..d267ece30 --- /dev/null +++ b/backend/src/main/resources/api-docs.yml @@ -0,0 +1,11 @@ +docs: + info: + title: "리뷰미 API" + description: "이 문서는 리뷰미 API 구현 방법을 소개합니다." + version: "0.0.1" + +springdoc: + swagger-ui: + path: /api-docs + operations-sorter: alpha + tags-sorter: alpha diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 000000000..3cc43c9a7 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + profiles: + active: local + + config: + import: + - classpath:api-docs.yml + - classpath:logback.yml + + datasource: + url: jdbc:h2:mem:test + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + hibernate: + ddl-auto: update diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..3b744db47 --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,33 @@ + + + + + + + + + UTF-8 + + ${CONSOLE_LOG_PATTERN} + + + + + UTF-8 + + ${FILE_LOG_PATTERN} + utf8 + + ${LOG_PATH}/${LOG_FILE} + + ${LOG_PATH}/${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN} + ${LOGBACK_ROLLINGPOLICY_MAX_HISTORY} + + + + + + + + + diff --git a/backend/src/main/resources/logback.yml b/backend/src/main/resources/logback.yml new file mode 100644 index 000000000..c1f811eff --- /dev/null +++ b/backend/src/main/resources/logback.yml @@ -0,0 +1,14 @@ +logging: + config: classpath:logback-spring.xml + file: + path: logs + name: review-me.log + level: + springframework: DEBUG + logback: + rolling-policy: + max-history: 100 + file-name-pattern: review-me.%d{yyyy-MM-dd}.log + pattern: + console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:- } [%15.15t] %-40.40logger{39} : %m%n%wEx" diff --git a/backend/src/main/resources/secret b/backend/src/main/resources/secret new file mode 160000 index 000000000..9e15707bb --- /dev/null +++ b/backend/src/main/resources/secret @@ -0,0 +1 @@ +Subproject commit 9e15707bb4d91435d6c460b09343d2fb6e819fe1 diff --git a/backend/src/test/java/reviewme/ReviewMeApplicationTests.java b/backend/src/test/java/reviewme/ReviewMeApplicationTests.java new file mode 100644 index 000000000..5c3210417 --- /dev/null +++ b/backend/src/test/java/reviewme/ReviewMeApplicationTests.java @@ -0,0 +1,12 @@ +package reviewme; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ReviewMeApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java new file mode 100644 index 000000000..a0bf49b5d --- /dev/null +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -0,0 +1,94 @@ +package reviewme.api; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.restassured.module.mockmvc.specification.MockMvcRequestSpecification; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.mockmvc.MockMvcOperationPreprocessorsConfigurer; +import org.springframework.restdocs.operation.preprocess.HeadersModifyingOperationPreprocessor; +import org.springframework.restdocs.operation.preprocess.UriModifyingOperationPreprocessor; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import reviewme.review.controller.ReviewController; +import reviewme.review.service.CreateReviewService; +import reviewme.review.service.ReviewDetailLookupService; +import reviewme.review.service.ReviewService; +import reviewme.reviewgroup.controller.ReviewGroupController; +import reviewme.reviewgroup.service.ReviewGroupLookupService; +import reviewme.reviewgroup.service.ReviewGroupService; +import reviewme.template.controller.TemplateController; +import reviewme.template.service.TemplateService; + +@WebMvcTest({ + ReviewGroupController.class, + ReviewController.class, + TemplateController.class +}) +@ExtendWith(RestDocumentationExtension.class) +public abstract class ApiTest { + + private MockMvcRequestSpecification spec; + + @MockBean + protected ReviewService reviewService; + + @MockBean + protected ReviewGroupService reviewGroupService; + + @MockBean + protected TemplateService templateService; + + @MockBean + protected CreateReviewService createReviewService; + + @MockBean + protected ReviewDetailLookupService reviewDetailLookupService; + + @MockBean + protected ReviewGroupLookupService reviewGroupLookupService; + + @BeforeEach + void setUpRestDocs(WebApplicationContext context, RestDocumentationContextProvider provider) { + UriModifyingOperationPreprocessor uriModifier = modifyUris() + .scheme("https") + .host("api.review-me.page") + .removePort(); + HeadersModifyingOperationPreprocessor requestHeaderModifier = modifyHeaders() + .set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .remove(HttpHeaders.CONTENT_LENGTH); + HeadersModifyingOperationPreprocessor responseHeaderModifier = modifyHeaders() + .remove(HttpHeaders.CONTENT_LENGTH) + .remove(HttpHeaders.CONNECTION) + .remove(HttpHeaders.TRANSFER_ENCODING) + .remove(HttpHeaders.VARY) + .remove("Keep-Alive"); + + MockMvcOperationPreprocessorsConfigurer configurer = documentationConfiguration(provider) + .operationPreprocessors() + .withRequestDefaults(prettyPrint(), uriModifier, requestHeaderModifier) + .withResponseDefaults(prettyPrint(), responseHeaderModifier); + + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(configurer) + .build(); + + spec = RestAssuredMockMvc.given() + .mockMvc(mockMvc); + } + + protected MockMvcRequestSpecification givenWithSpec() { + return spec.contentType(MediaType.APPLICATION_JSON_VALUE); + } +} diff --git a/backend/src/test/java/reviewme/api/ReviewApiTest.java b/backend/src/test/java/reviewme/api/ReviewApiTest.java new file mode 100644 index 000000000..1d276aae1 --- /dev/null +++ b/backend/src/test/java/reviewme/api/ReviewApiTest.java @@ -0,0 +1,261 @@ +package reviewme.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; + +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.restdocs.headers.HeaderDescriptor; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.dto.response.list.ReceivedReviewCategoryResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.exception.ReviewGroupNotFoundByCodesException; + +class ReviewApiTest extends ApiTest { + + private final String request = """ + { + "reviewRequestCode": "ABCD1234", + "answers": [ + { + "questionId": 1, + "selectedOptionIds": [1, 2] + }, + { + "questionId": 2, + "text": "답변 예시 1" + } + ] + } + """; + + @Test + void 리뷰를_등록한다() { + BDDMockito.given(createReviewService.createReview(any(CreateReviewRequest.class))) + .willReturn(1L); + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + + fieldWithPath("answers[]").description("답변 목록"), + fieldWithPath("answers[].questionId").description("질문 ID"), + fieldWithPath("answers[].selectedOptionIds").description("선택한 옵션 ID 목록").optional(), + fieldWithPath("answers[].text").description("서술 답변").optional() + }; + + RestDocumentationResultHandler handler = document( + "create-review", + requestFields(requestFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(201); + } + + @Test + void 리뷰_그룹_코드가_올바르지_않은_경우_예외가_발생한다() { + BDDMockito.given(createReviewService.createReview(any(CreateReviewRequest.class))) + .willThrow(new ReviewGroupNotFoundByReviewRequestCodeException(anyString())); + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + + fieldWithPath("answers[]").description("답변 목록"), + fieldWithPath("answers[].questionId").description("질문 ID"), + fieldWithPath("answers[].selectedOptionIds").description("선택한 옵션 ID 목록").optional(), + fieldWithPath("answers[].text").description("서술형 답변").optional() + }; + + RestDocumentationResultHandler handler = document( + "create-review-invalid-review-request-code", + requestFields(requestFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(404); + } + + @Test + void 자신이_받은_리뷰_한_개를_조회한다() { + BDDMockito.given(reviewDetailLookupService.getReviewDetail(anyLong(), anyString(), anyString())) + .willReturn(TemplateFixture.templateAnswerResponse()); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + ParameterDescriptor[] requestPathDescriptors = { + parameterWithName("id").description("리뷰 ID") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("createdAt").description("리뷰 작성 날짜"), + fieldWithPath("formId").description("폼 ID"), + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + + fieldWithPath("sections[]").description("섹션 목록"), + fieldWithPath("sections[].sectionId").description("섹션 ID"), + fieldWithPath("sections[].header").description("섹션 제목"), + + fieldWithPath("sections[].questions[]").description("질문 목록"), + fieldWithPath("sections[].questions[].questionId").description("질문 ID"), + fieldWithPath("sections[].questions[].required").description("필수 여부"), + fieldWithPath("sections[].questions[].content").description("질문 내용"), + fieldWithPath("sections[].questions[].questionType").description("질문 타입"), + + fieldWithPath("sections[].questions[].optionGroup").description("옵션 그룹").optional(), + fieldWithPath("sections[].questions[].optionGroup.optionGroupId").description("옵션 그룹 ID"), + fieldWithPath("sections[].questions[].optionGroup.minCount").description("최소 선택 개수"), + fieldWithPath("sections[].questions[].optionGroup.maxCount").description("최대 선택 개수"), + + fieldWithPath("sections[].questions[].optionGroup.options[]").description("선택 항목 목록"), + fieldWithPath("sections[].questions[].optionGroup.options[].optionId").description("선택 항목 ID"), + fieldWithPath("sections[].questions[].optionGroup.options[].content").description("선택 항목 내용"), + fieldWithPath("sections[].questions[].optionGroup.options[].isChecked").description("선택 여부"), + fieldWithPath("sections[].questions[].answer").description("서술형 답변").optional(), + }; + + RestDocumentationResultHandler handler = document( + "review-detail", + requestHeaders(requestHeaderDescriptors), + pathParameters(requestPathDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .pathParam("id", "1") + .queryParam("reviewRequestCode", "00001234") + .header("groupAccessCode", "abc12344") + .when().get("/v2/reviews/{id}") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_단건_조회시_접근_코드가_올바르지_않은_경우_예외를_발생한다() { + long reviewId = 1L; + String reviewRequestCode = "00001234"; + String groupAccessCode = "43214321"; + BDDMockito.given(reviewDetailLookupService.getReviewDetail(reviewId, reviewRequestCode, groupAccessCode)) + .willThrow(new ReviewGroupNotFoundByCodesException(reviewRequestCode, groupAccessCode)); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + ParameterDescriptor[] requestPathDescriptors = { + parameterWithName("id").description("리뷰 ID") + }; + + RestDocumentationResultHandler handler = document( + "review-detail-invalid-group-access-code", + requestHeaders(requestHeaderDescriptors), + pathParameters(requestPathDescriptors) + ); + + givenWithSpec().log().all() + .pathParam("id", reviewId) + .queryParam("reviewRequestCode", reviewRequestCode) + .header("groupAccessCode", groupAccessCode) + .when().get("/v2/reviews/{id}") + .then().log().all() + .apply(handler) + .statusCode(400); + } + + @Test + void 자신이_받은_리뷰_목록을_조회한다() { + List receivedReviews = List.of( + new ReceivedReviewResponse(1L, LocalDate.of(2024, 8, 1), "(리뷰 미리보기 1)", + List.of(new ReceivedReviewCategoryResponse(1L, "카테고리 1"))), + new ReceivedReviewResponse(2L, LocalDate.of(2024, 8, 2), "(리뷰 미리보기 2)", + List.of(new ReceivedReviewCategoryResponse(2L, "카테고리 2"))) + ); + ReceivedReviewsResponse response = new ReceivedReviewsResponse("아루", "리뷰미", receivedReviews); + BDDMockito.given(reviewService.findReceivedReviews(anyString(), anyString())) + .willReturn(response); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + + fieldWithPath("reviews[]").description("리뷰 목록"), + fieldWithPath("reviews[].reviewId").description("리뷰 ID"), + fieldWithPath("reviews[].createdAt").description("리뷰 작성 날짜"), + fieldWithPath("reviews[].contentPreview").description("리뷰 미리보기"), + + fieldWithPath("reviews[].categories[]").description("카테고리 목록"), + fieldWithPath("reviews[].categories[].optionId").description("카테고리 ID"), + fieldWithPath("reviews[].categories[].content").description("카테고리 내용") + }; + + RestDocumentationResultHandler handler = document( + "received-reviews", + requestHeaders(requestHeaderDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "asdfasdf") + .header("groupAccessCode", "qwerqwer") + .when().get("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 자신이_받은_리뷰_조회시_접근_코드가_올바르지_않은_경우_예외를_발생한다() { + String reviewRequestCode = "43214321"; + String groupAccessCode = "00001234"; + BDDMockito.given(reviewService.findReceivedReviews(reviewRequestCode, groupAccessCode)) + .willThrow(new ReviewGroupNotFoundByCodesException(reviewRequestCode, groupAccessCode)); + + HeaderDescriptor[] requestHeaderDescriptors = { + headerWithName("groupAccessCode").description("그룹 접근 코드") + }; + + RestDocumentationResultHandler handler = document( + "received-reviews-invalid-group-access-code", + requestHeaders(requestHeaderDescriptors) + ); + + givenWithSpec().log().all() + .header("groupAccessCode", groupAccessCode) + .queryParam("reviewRequestCode", reviewRequestCode) + .when().get("/v2/reviews") + .then().log().all() + .apply(handler) + .statusCode(400); + } +} diff --git a/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java b/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java new file mode 100644 index 000000000..3e3df8e2a --- /dev/null +++ b/backend/src/test/java/reviewme/api/ReviewGroupApiTest.java @@ -0,0 +1,125 @@ +package reviewme.api; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; + +class ReviewGroupApiTest extends ApiTest { + + @Test + void 리뷰_그룹을_생성한다() { + BDDMockito.given(reviewGroupService.createReviewGroup(any(ReviewGroupCreationRequest.class))) + .willReturn(new ReviewGroupCreationResponse("ABCD1234")); + + String request = """ + { + "revieweeName": "아루", + "projectName": "리뷰미", + "groupAccessCode": "12341234" + } + """; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + fieldWithPath("groupAccessCode").description("리뷰 확인 코드(비밀번호)") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드") + }; + + RestDocumentationResultHandler handler = document( + "review-group-create", + requestFields(requestFieldDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/groups") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_요청_코드로_리뷰_그룹_정보를_반환한다() { + BDDMockito.given(reviewGroupLookupService.getReviewGroupSummary(anyString())) + .willReturn(new ReviewGroupResponse("아루", "리뷰미")); + + ParameterDescriptor[] parameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름") + }; + + RestDocumentationResultHandler handler = document( + "review-group-summary", + queryParameters(parameterDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/groups") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_그룹_코드와_액세스_코드로_일치_여부를_판단한다() { + BDDMockito.given(reviewGroupService.checkGroupAccessCode(any(CheckValidAccessRequest.class))) + .willReturn(new CheckValidAccessResponse(true)); + + String request = """ + { + "reviewRequestCode": "ABCD1234", + "groupAccessCode": "00001234" + } + """; + + FieldDescriptor[] requestFieldDescriptors = { + fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"), + fieldWithPath("groupAccessCode").description("그룹 접근 코드 (비밀번호)") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("hasAccess").description("코드 일치 여부 (비밀번호 일치)") + }; + + RestDocumentationResultHandler handler = document( + "review-group-check-access", + requestFields(requestFieldDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .body(request) + .when().post("/v2/groups/check") + .then().log().all() + .apply(handler) + .statusCode(200); + } +} diff --git a/backend/src/test/java/reviewme/api/TemplateApiTest.java b/backend/src/test/java/reviewme/api/TemplateApiTest.java new file mode 100644 index 000000000..557e59e8a --- /dev/null +++ b/backend/src/test/java/reviewme/api/TemplateApiTest.java @@ -0,0 +1,93 @@ +package reviewme.api; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; + +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.request.ParameterDescriptor; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; + +class TemplateApiTest extends ApiTest { + + @Test + void 리뷰_작성을_위한_템플릿을_반환한다() { + BDDMockito.given(templateService.generateReviewForm(anyString())) + .willReturn(TemplateFixture.templateResponse()); + + ParameterDescriptor[] requestParameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + FieldDescriptor[] responseFieldDescriptors = { + fieldWithPath("formId").description("폼 ID"), + fieldWithPath("revieweeName").description("리뷰이 이름"), + fieldWithPath("projectName").description("프로젝트 이름"), + + fieldWithPath("sections[]").description("섹션 목록"), + fieldWithPath("sections[].sectionId").description("섹션 ID"), + fieldWithPath("sections[].sectionName").description("섹션 이름"), + fieldWithPath("sections[].visible").description("섹션 표시 여부 (반드시 보이거나, 조건부이거나)"), + fieldWithPath("sections[].onSelectedOptionId").description("섹션이 보이기 위한 선택 항목 ID").optional(), + fieldWithPath("sections[].header").description("섹션 제목"), + + fieldWithPath("sections[].questions[]").description("질문 목록"), + fieldWithPath("sections[].questions[].questionId").description("질문 ID"), + fieldWithPath("sections[].questions[].required").description("필수 여부"), + fieldWithPath("sections[].questions[].content").description("질문 내용"), + fieldWithPath("sections[].questions[].questionType").description("질문 타입"), + fieldWithPath("sections[].questions[].hasGuideline").description("가이드라인 존재 여부"), + fieldWithPath("sections[].questions[].guideline").description("가이드라인 내용").optional(), + + fieldWithPath("sections[].questions[].optionGroup").description("옵션 그룹").optional(), + fieldWithPath("sections[].questions[].optionGroup.optionGroupId").description("옵션 그룹 ID"), + fieldWithPath("sections[].questions[].optionGroup.minCount").description("최소 선택 개수"), + fieldWithPath("sections[].questions[].optionGroup.maxCount").description("최대 선택 개수"), + + fieldWithPath("sections[].questions[].optionGroup.options[]").description("선택 항목 목록"), + fieldWithPath("sections[].questions[].optionGroup.options[].optionId").description("선택 항목 ID"), + fieldWithPath("sections[].questions[].optionGroup.options[].content").description("선택 항목 내용"), + }; + + RestDocumentationResultHandler handler = document( + "get-review-form", + queryParameters(requestParameterDescriptors), + responseFields(responseFieldDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/reviews/write") + .then().log().all() + .apply(handler) + .statusCode(200); + } + + @Test + void 리뷰_그룹이_존재하지_않는_경우_예외를_반환한다() { + BDDMockito.given(templateService.generateReviewForm(anyString())) + .willThrow(new ReviewGroupNotFoundByReviewRequestCodeException(anyString())); + + ParameterDescriptor[] requestParameterDescriptors = { + parameterWithName("reviewRequestCode").description("리뷰 요청 코드") + }; + + RestDocumentationResultHandler handler = document( + "get-review-form-not-found", + queryParameters(requestParameterDescriptors) + ); + + givenWithSpec().log().all() + .queryParam("reviewRequestCode", "ABCD1234") + .when().get("/v2/reviews/write") + .then().log().all() + .apply(handler) + .statusCode(404); + } +} diff --git a/backend/src/test/java/reviewme/api/TemplateFixture.java b/backend/src/test/java/reviewme/api/TemplateFixture.java new file mode 100644 index 000000000..1cee386d0 --- /dev/null +++ b/backend/src/test/java/reviewme/api/TemplateFixture.java @@ -0,0 +1,109 @@ +package reviewme.api; + +import java.time.LocalDate; +import java.util.List; +import reviewme.question.domain.QuestionType; +import reviewme.review.service.dto.response.detail.OptionGroupAnswerResponse; +import reviewme.review.service.dto.response.detail.OptionItemAnswerResponse; +import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.template.domain.VisibleType; +import reviewme.template.service.dto.response.OptionGroupResponse; +import reviewme.template.service.dto.response.OptionItemResponse; +import reviewme.template.service.dto.response.QuestionResponse; +import reviewme.template.service.dto.response.SectionResponse; +import reviewme.template.service.dto.response.TemplateResponse; + +class TemplateFixture { + + public static TemplateResponse templateResponse() { + // Section 1 + List firstSectionOptions = List.of( + new OptionItemResponse(1, "커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)"), + new OptionItemResponse(2, "문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)"), + new OptionItemResponse(3, "시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)") + ); + List firstSectionQuestions = List.of( + new QuestionResponse( + 1, + true, + "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", + QuestionType.CHECKBOX.name(), + new OptionGroupResponse(1, 1, 2, firstSectionOptions), + false, + null + ) + ); + SectionResponse firstSection = new SectionResponse( + 1, "카테고리 선택", VisibleType.ALWAYS.name(), null, "아루와 함께 한 기억을 떠올려볼게요.", firstSectionQuestions + ); + + // Section 2 + List secondSectionOptions = List.of( + new OptionItemResponse(4, "반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요."), + new OptionItemResponse(5, "팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요."), + new OptionItemResponse(6, "팀의 분위기를 주도해요.") + ); + List secondSectionQuestions = List.of( + new QuestionResponse( + 2, + true, + "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", + QuestionType.CHECKBOX.name(), + new OptionGroupResponse(2, 1, 3, secondSectionOptions), + false, + null + ), + new QuestionResponse( + 3, + true, + "위에서 선택한 사항에 대해 조금 더 자세히 설명해주세요.", + QuestionType.TEXT.name(), + null, + true, + "상황을 자세하게 기록할수록 아루에게 도움이 돼요. 아루 덕분에 팀이 원활한 소통을 이뤘거나, 함께 일하면서 배울 점이 있었는지 떠올려 보세요." + ) + ); + SectionResponse secondSection = new SectionResponse( + 2, "커뮤니케이션 능력", VisibleType.ALWAYS.name(), 1L, "아루의 커뮤니케이션, 협업 능력을 평가해주세요.", secondSectionQuestions + ); + + return new TemplateResponse(1, "아루", "리뷰미", List.of(firstSection, secondSection)); + } + + public static TemplateAnswerResponse templateAnswerResponse() { + // Section 1 + List firstOptionAnswers = List.of( + new OptionItemAnswerResponse(1, "커뮤니케이션, 협업 능력 (ex: 팀원간의 원활한 정보 공유, 명확한 의사소통)", true), + new OptionItemAnswerResponse(2, "문제 해결 능력 (ex: 프로젝트 중 만난 버그/오류를 분석하고 이를 해결하는 능력)", false), + new OptionItemAnswerResponse(3, "시간 관리 능력 (ex: 일정과 마감 기한 준수, 업무의 우선 순위 분배)", false) + ); + OptionGroupAnswerResponse firstOptionGroupAnswer = new OptionGroupAnswerResponse(1, 1, 2, firstOptionAnswers); + QuestionAnswerResponse firstQuestionAnswer = new QuestionAnswerResponse( + 1, true, QuestionType.CHECKBOX, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", firstOptionGroupAnswer, null + ); + SectionAnswerResponse firstSectionAnswer = new SectionAnswerResponse( + 1, "프로젝트 기간 동안, 아루의 강점이 드러났던 순간을 선택해주세요.", List.of(firstQuestionAnswer) + ); + + // Section 2 + List secondOptionAnswers = List.of( + new OptionItemAnswerResponse(4, "반대 의견을 내더라도 듣는 사람이 기분 나쁘지 않게 이야기해요.", true), + new OptionItemAnswerResponse(5, "팀원들의 의견을 잘 모아서 회의가 매끄럽게 진행되도록 해요.", false), + new OptionItemAnswerResponse(6, "팀의 분위기를 주도해요.", true) + ); + OptionGroupAnswerResponse secondOptionGroupAnswer = new OptionGroupAnswerResponse(2, 1, 3, secondOptionAnswers); + QuestionAnswerResponse secondQuestionAnswer = new QuestionAnswerResponse( + 2, true, QuestionType.CHECKBOX, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", secondOptionGroupAnswer, + "아루는 커뮤니케이션과 협업 능력에서 인상깊었어요~" + ); + SectionAnswerResponse secondSectionAnswer = new SectionAnswerResponse( + 2, "커뮤니케이션, 협업 능력에서 어떤 부분이 인상 깊었는지 선택해주세요.", List.of(secondQuestionAnswer) + ); + + return new TemplateAnswerResponse( + 1, "아루", "리뷰미", LocalDate.of(2024, 8, 1), List.of(firstSectionAnswer, secondSectionAnswer) + ); + } +} diff --git a/backend/src/test/java/reviewme/config/TestConfig.java b/backend/src/test/java/reviewme/config/TestConfig.java new file mode 100644 index 000000000..f339dd641 --- /dev/null +++ b/backend/src/test/java/reviewme/config/TestConfig.java @@ -0,0 +1,14 @@ +package reviewme.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import reviewme.support.DatabaseCleaner; + +@TestConfiguration +public class TestConfig { + + @Bean + public DatabaseCleaner databaseCleaner() { + return new DatabaseCleaner(); + } +} diff --git a/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java b/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java new file mode 100644 index 000000000..fdaae95df --- /dev/null +++ b/backend/src/test/java/reviewme/global/HeaderPropertyArgumentResolverTest.java @@ -0,0 +1,56 @@ +package reviewme.global; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.NativeWebRequest; +import reviewme.global.exception.MissingHeaderPropertyException; + +class HeaderPropertyArgumentResolverTest { + + private final HeaderPropertyArgumentResolver resolver = new HeaderPropertyArgumentResolver(); + private final MethodParameter parameter = mock(MethodParameter.class); + private final HeaderProperty headerProperty = mock(HeaderProperty.class); + + @BeforeEach + void setUp() { + given(parameter.hasParameterAnnotation(HeaderProperty.class)).willReturn(true); + given(parameter.getParameterAnnotation(HeaderProperty.class)).willReturn(headerProperty); + } + + @Test + void 검증값이_헤더에_존재하지_않으면_검증에_실패한다() { + // given + NativeWebRequest request = mock(NativeWebRequest.class); + given(request.getNativeRequest()).willReturn(new MockHttpServletRequest()); + given(headerProperty.headerName()).willReturn("test"); + + // when, then + assertThatThrownBy(() -> resolver.resolveArgument(parameter, null, request, null)) + .isInstanceOf(MissingHeaderPropertyException.class); + } + + @Test + void 검증값이_헤더에_존재하면_값을_반환한다() { + // given + String headerName = "test"; + String headerValue = "1234"; + NativeWebRequest request = mock(NativeWebRequest.class); + MockHttpServletRequest mockRequest = (new MockHttpServletRequest()); + mockRequest.addHeader(headerName, headerValue); + given(request.getNativeRequest()).willReturn(mockRequest); + given(headerProperty.headerName()).willReturn(headerName); + + // when + String actual = resolver.resolveArgument(parameter, null, request, null); + + // then + assertThat(actual).isEqualTo(headerValue); + } +} diff --git a/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java b/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java new file mode 100644 index 000000000..046eeeea9 --- /dev/null +++ b/backend/src/test/java/reviewme/question/repository/OptionItemRepositoryTest.java @@ -0,0 +1,84 @@ +package reviewme.question.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.repository.ReviewRepository; + +@DataJpaTest +class OptionItemRepositoryTest { + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Test + void 리뷰_아이디로_선택한_옵션_아이템_아이디를_불러온다() { + // given + long optionId1 = optionItemRepository.save(new OptionItem("1", 0, 1, OptionType.KEYWORD)).getId(); + long optionId2 = optionItemRepository.save(new OptionItem("2", 0, 1, OptionType.KEYWORD)).getId(); + long optionId3 = optionItemRepository.save(new OptionItem("3", 0, 1, OptionType.KEYWORD)).getId(); + long optionId4 = optionItemRepository.save(new OptionItem("4", 0, 1, OptionType.KEYWORD)).getId(); + optionItemRepository.save(new OptionItem("5", 0, 1, OptionType.KEYWORD)); + + List checkboxAnswers = List.of( + new CheckboxAnswer(1, List.of(optionId1, optionId2)), + new CheckboxAnswer(2, List.of(optionId3, optionId4)) + ); + Review review = reviewRepository.save(new Review(0, 0, List.of(), checkboxAnswers)); + + // when + Set actual = optionItemRepository.findSelectedOptionItemIdsByReviewId(review.getId()); + + // then + assertThat(actual).containsExactlyInAnyOrder(optionId1, optionId2, optionId3, optionId4); + } + + @Test + void 리뷰_아이디와_질문_아이디로_선택한_옵션_아이템을_순서대로_불러온다() { + // given + Question question1 = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문1", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문2", null, 2)); + + long optionGroupId = optionGroupRepository.save(new OptionGroup(question1.getId(), 1, 3)).getId(); + long optionId1 = optionItemRepository.save(new OptionItem("1", optionGroupId, 3, OptionType.KEYWORD)).getId(); + long optionId2 = optionItemRepository.save(new OptionItem("2", optionGroupId, 2, OptionType.KEYWORD)).getId(); + long optionId3 = optionItemRepository.save(new OptionItem("3", optionGroupId, 1, OptionType.KEYWORD)).getId(); + long optionId4 = optionItemRepository.save(new OptionItem("4", optionGroupId, 1, OptionType.KEYWORD)).getId(); + long optionId5 = optionItemRepository.save(new OptionItem("5", optionGroupId, 1, OptionType.KEYWORD)).getId(); + + List checkboxAnswers = List.of( + new CheckboxAnswer(question1.getId(), List.of(optionId1, optionId3)), + new CheckboxAnswer(question2.getId(), List.of(optionId4)) + ); + + Review review = reviewRepository.save(new Review(0, 0, List.of(), checkboxAnswers)); + + // when + List actual = optionItemRepository.findSelectedOptionItemsByReviewIdAndQuestionId( + review.getId(), question1.getId() + ); + + // then + assertThat(actual).extracting(OptionItem::getId).containsExactly(optionId3, optionId1); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java b/backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java new file mode 100644 index 000000000..504b10071 --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/CheckboxAnswersTest.java @@ -0,0 +1,52 @@ +package reviewme.review.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.exception.MissingCheckboxAnswerForQuestionException; + +class CheckboxAnswersTest { + + @Test + void 질문에_해당하는_답변이_없으면_예외를_발생한다() { + // given + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(List.of(new CheckboxAnswer(1, List.of(1L)))); + + // when, then + assertThatThrownBy(() -> checkboxAnswers.getAnswerByQuestionId(2)) + .isInstanceOf(MissingCheckboxAnswerForQuestionException.class); + } + + @Test + void 질문_ID로_선택형_답변을_반환한다() { + // given + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(List.of(new CheckboxAnswer(1, List.of(1L)))); + + // when + CheckboxAnswer actual = checkboxAnswers.getAnswerByQuestionId(1); + + // then + assertThat(actual.getSelectedOptionIds()) + .extracting(CheckBoxAnswerSelectedOption::getSelectedOptionId) + .containsExactly(1L); + } + + @Test + void 질문_ID에_해당하는_답변이_있는지_확인한다() { + // given + CheckboxAnswers checkboxAnswers = new CheckboxAnswers(List.of(new CheckboxAnswer(1, List.of(1L)))); + + // when + boolean actual1 = checkboxAnswers.hasAnswerByQuestionId(1); + boolean actual2 = checkboxAnswers.hasAnswerByQuestionId(2); + + // then + assertAll( + () -> assertThat(actual1).isTrue(), + () -> assertThat(actual2).isFalse() + ); + } +} diff --git a/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java b/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java new file mode 100644 index 000000000..82eeb7e0b --- /dev/null +++ b/backend/src/test/java/reviewme/review/domain/TextAnswersTest.java @@ -0,0 +1,50 @@ +package reviewme.review.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import reviewme.review.domain.exception.MissingTextAnswerForQuestionException; + +class TextAnswersTest { + + @Test + void 질문에_해당하는_답변이_없으면_예외를_발생한다() { + // given + TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답".repeat(20)))); + + // when, then + assertThatThrownBy(() -> textAnswers.getAnswerByQuestionId(2)) + .isInstanceOf(MissingTextAnswerForQuestionException.class); + } + + @Test + void 질문_ID로_서술형_답변을_반환한다() { + // given + TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답".repeat(20)))); + + // when + TextAnswer actual = textAnswers.getAnswerByQuestionId(1); + + // then + assertThat(actual.getContent()).isEqualTo("답".repeat(20)); + } + + @Test + void 질문_ID에_해당하는_답변이_있는지_확인한다() { + // given + TextAnswers textAnswers = new TextAnswers(List.of(new TextAnswer(1, "답변"))); + + // when + boolean actual1 = textAnswers.hasAnswerByQuestionId(1); + boolean actual2 = textAnswers.hasAnswerByQuestionId(2); + + // then + assertAll( + () -> assertThat(actual1).isTrue(), + () -> assertThat(actual2).isFalse() + ); + } +} diff --git a/backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java b/backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java new file mode 100644 index 000000000..6ffc56eeb --- /dev/null +++ b/backend/src/test/java/reviewme/review/repository/QuestionRepositoryTest.java @@ -0,0 +1,71 @@ +package reviewme.review.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.QuestionRepository; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@DataJpaTest +class QuestionRepositoryTest { + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 섹션_아이디로_질문_목록을_순서대로_가져온다() { + // given + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문1", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문2", null, 2)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문3", null, 3)); + questionRepository.save(new Question(true, QuestionType.TEXT, "질문4", null, 1)); + + List questionIds = List.of(question3.getId(), question1.getId(), question2.getId()); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, questionIds, null, "sectionName", "header", 0)); + + // when + List actual = questionRepository.findAllBySectionId(section.getId()); + + // then + assertThat(actual).extracting(Question::getId) + .containsExactly(question1.getId(), question2.getId(), question3.getId()); + } + + @Test + void 템플릿_아이디로_질문_목록을_모두_가져온다() { + // given + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문1", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문2", null, 2)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문3", null, 1)); + Question question4 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문4", null, 2)); + + List sectionQuestion1 = List.of(question1.getId(), question2.getId()); + List sectionQuestion2 = List.of(question3.getId(), question4.getId()); + Section section1 = sectionRepository.save(new Section(VisibleType.ALWAYS, sectionQuestion1, null, "sectionName", "header", 0)); + sectionRepository.save(new Section(VisibleType.ALWAYS, sectionQuestion2, null, "sectionName", "header", 0)); + List sectionIds = List.of(section1.getId()); + Template template = templateRepository.save(new Template(sectionIds)); + + // when + Set actual = questionRepository.findAllQuestionIdByTemplateId(template.getId()); + + // then + assertThat(actual).containsExactlyInAnyOrder(question1.getId(), question2.getId()); + } +} diff --git a/backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java b/backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java new file mode 100644 index 000000000..f42bd2072 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/CreateCheckBoxAnswerRequestValidatorTest.java @@ -0,0 +1,160 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.CheckBoxAnswerIncludedNotProvidedOptionItemException; +import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SelectedOptionItemCountOutOfRangeException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.support.ServiceTest; +import reviewme.template.domain.exception.OptionGroupNotFoundByQuestionIdException; + +@ServiceTest +class CreateCheckBoxAnswerRequestValidatorTest { + + @Autowired + private CreateCheckBoxAnswerRequestValidator createCheckBoxAnswerRequestValidator; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + private Question savedQuestion; + + @BeforeEach + void setUp() { + savedQuestion = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문", null, 1)); + } + + @Test + void 저장되지_않은_질문에_대한_응답이면_예외가_발생한다() { + // given + long notSavedQuestionId = 100L; + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + notSavedQuestionId, List.of(1L), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(SubmittedQuestionNotFoundException.class); + } + + @Test + void 선택형_질문에_텍스트_응답을_하면_예외가_발생한다() { + // given + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(1L), "서술형 응답" + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(CheckBoxAnswerIncludedTextException.class); + } + + @Test + void 저장되지_않은_옵션그룹에_대해_응답하면_예외가_발생한다() { + // given + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(1L), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(OptionGroupNotFoundByQuestionIdException.class); + } + + @Test + void 필수_선택형_질문에_응답을_하지_않으면_예외가_발생한다() { + // given + optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 3) + ); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), + null, + null); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(RequiredQuestionNotAnsweredException.class); + } + + @Test + void 옵션그룹에서_제공하지_않은_옵션아이템을_응답하면_예외가_발생한다() { + // given + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 3) + ); + OptionItem savedOptionItem = optionItemRepository.save( + new OptionItem("옵션", savedOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(savedOptionItem.getId() + 1L), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(CheckBoxAnswerIncludedNotProvidedOptionItemException.class); + } + + @Test + void 옵션그룹에서_정한_최소_선택_수_보다_적게_선택하면_예외가_발생한다() { + // given + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 2, 3) + ); + OptionItem savedOptionItem1 = optionItemRepository.save( + new OptionItem("옵션1", savedOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(savedOptionItem1.getId()), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(SelectedOptionItemCountOutOfRangeException.class); + } + + @Test + void 옵션그룹에서_정한_최대_선택_수_보다_많이_선택하면_예외가_발생한다() { + // given + OptionGroup savedOptionGroup = optionGroupRepository.save( + new OptionGroup(savedQuestion.getId(), 1, 1) + ); + OptionItem savedOptionItem1 = optionItemRepository.save( + new OptionItem("옵션1", savedOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + OptionItem savedOptionItem2 = optionItemRepository.save( + new OptionItem("옵션2", savedOptionGroup.getId(), 2, OptionType.KEYWORD) + ); + + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest( + savedQuestion.getId(), List.of(savedOptionItem1.getId(), savedOptionItem2.getId()), null + ); + + // when, then + assertThatCode(() -> createCheckBoxAnswerRequestValidator.validate(request)) + .isInstanceOf(SelectedOptionItemCountOutOfRangeException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java b/backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java new file mode 100644 index 000000000..8901b8d67 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/CreateReviewServiceTest.java @@ -0,0 +1,264 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.repository.CheckboxAnswerRepository; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.repository.TextAnswerRepository; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.dto.request.CreateReviewRequest; +import reviewme.review.service.exception.MissingRequiredQuestionException; +import reviewme.review.service.exception.UnnecessaryQuestionIncludedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class CreateReviewServiceTest { + + @Autowired + private CreateReviewService createReviewService; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private TextAnswerRepository textAnswerRepository; + + @Autowired + private CheckboxAnswerRepository checkboxAnswerRepository; + + @Test + void 필수_질문에_모두_응답하는_경우_예외가_발생하지_않는다() { + // 리뷰 그룹 저장 + String reviewRequestCode = "1234"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + + // 필수 선택형 질문, 섹션 저장 + Question alwaysRequiredQuestion = questionRepository.save( + new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1) + ); + OptionGroup alwaysRequiredOptionGroup = optionGroupRepository.save( + new OptionGroup(alwaysRequiredQuestion.getId(), 1, 2) + ); + OptionItem alwaysRequiredOptionItem1 = optionItemRepository.save( + new OptionItem("선택지", alwaysRequiredOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + OptionItem alwaysRequiredOptionItem2 = optionItemRepository.save( + new OptionItem("선택지", alwaysRequiredOptionGroup.getId(), 2, OptionType.KEYWORD) + ); + Section alwaysRequiredSection = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(alwaysRequiredQuestion.getId()), null, "섹션명", "말머리", 1) + ); + + // 필수가 아닌 서술형 질문 저장 + Question notRequiredQuestion = questionRepository.save( + new Question(false, QuestionType.TEXT, "질문", "가이드라인", 1) + ); + Section notRequiredSection = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(notRequiredQuestion.getId()), null, "섹션명", "말머리", 1) + ); + + // optionItem 선택에 따라서 required 가 달라지는 섹션1 저장 + Question conditionalTextQuestion1 = questionRepository.save( + new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1) + ); + Question conditionalCheckQuestion = questionRepository.save( + new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1) + ); + OptionGroup conditionalOptionGroup = optionGroupRepository.save( + new OptionGroup(conditionalCheckQuestion.getId(), 1, 2) + ); + OptionItem conditionalOptionItem = optionItemRepository.save( + new OptionItem("선택지", conditionalOptionGroup.getId(), 1, OptionType.KEYWORD) + ); + Section conditionalSection1 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, + List.of(conditionalTextQuestion1.getId(), conditionalCheckQuestion.getId()), + alwaysRequiredOptionItem1.getId(), "섹션명", "말머리", 1) + ); + + // optionItem 선택에 따라서 required 가 달라지는 섹션2 저장 + Question conditionalQuestion2 = questionRepository.save( + new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1) + ); + Section conditionalSection2 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(conditionalQuestion2.getId()), + alwaysRequiredOptionItem2.getId(), "섹션명", "말머리", 1) + ); + + // 템플릿 저장 + templateRepository.save(new Template( + List.of(alwaysRequiredSection.getId(), conditionalSection1.getId(), + conditionalSection2.getId(), notRequiredSection.getId()) + )); + + // 각 질문에 대한 답변 생성 + CreateReviewAnswerRequest alwaysRequiredAnswer = new CreateReviewAnswerRequest( + alwaysRequiredQuestion.getId(), List.of(alwaysRequiredOptionItem1.getId()), null); + CreateReviewAnswerRequest conditionalTextAnswer1 = new CreateReviewAnswerRequest( + conditionalTextQuestion1.getId(), null, "답변".repeat(30)); + CreateReviewAnswerRequest conditionalCheckAnswer1 = new CreateReviewAnswerRequest( + conditionalCheckQuestion.getId(), List.of(conditionalOptionItem.getId()), null); + CreateReviewAnswerRequest conditionalTextAnswer2 = new CreateReviewAnswerRequest( + conditionalQuestion2.getId(), null, "답변".repeat(30)); + + // 상황별로 다르게 구성한 리뷰 생성 dto + CreateReviewRequest properRequest = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalTextAnswer1, conditionalCheckAnswer1)); + CreateReviewRequest selectedOptionIdQuestionMissingRequest1 = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer)); + CreateReviewRequest selectedOptionIdQuestionMissingRequest2 = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalTextAnswer1)); + CreateReviewRequest selectedOptionIdQuestionMissingRequest3 = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalCheckAnswer1)); + CreateReviewRequest unnecessaryQuestionIncludedRequest = new CreateReviewRequest( + reviewRequestCode, List.of(alwaysRequiredAnswer, conditionalTextAnswer1, + conditionalCheckAnswer1, conditionalTextAnswer2)); + + // when, then + assertThatCode(() -> createReviewService.createReview(properRequest)) + .doesNotThrowAnyException(); + assertThatCode(() -> createReviewService.createReview(selectedOptionIdQuestionMissingRequest1)) + .isInstanceOf(MissingRequiredQuestionException.class); + assertThatCode(() -> createReviewService.createReview(selectedOptionIdQuestionMissingRequest2)) + .isInstanceOf(MissingRequiredQuestionException.class); + assertThatCode(() -> createReviewService.createReview(selectedOptionIdQuestionMissingRequest3)) + .isInstanceOf(MissingRequiredQuestionException.class); + assertThatCode(() -> createReviewService.createReview(unnecessaryQuestionIncludedRequest)) + .isInstanceOf(UnnecessaryQuestionIncludedException.class); + } + + @Test + void 텍스트가_포함된_리뷰를_저장한다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + String expectedTextAnswer = "답".repeat(20); + Question savedQuestion = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest createReviewAnswerRequest = new CreateReviewAnswerRequest(savedQuestion.getId(), null, + expectedTextAnswer); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(createReviewAnswerRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(textAnswerRepository.findAll()).hasSize(1); + } + + @Test + void 필수가_아닌_텍스트형_응답에_빈문자열이_들어오면_저장하지_않는다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + Question savedQuestion = questionRepository.save( + new Question(false, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest emptyTextReviewRequest = new CreateReviewAnswerRequest( + savedQuestion.getId(), null, ""); + CreateReviewAnswerRequest validTextReviewRequest = new CreateReviewAnswerRequest( + savedQuestion.getId(), null, "질문 1 답변 (20자 이상 입력 적용)"); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(emptyTextReviewRequest, validTextReviewRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(textAnswerRepository.findAll()).hasSize(1); + } + + @Test + void 체크박스가_포함된_리뷰를_저장한다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + Question savedQuestion = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1)); + OptionGroup savedOptionGroup = optionGroupRepository.save(new OptionGroup(savedQuestion.getId(), 2, 2)); + OptionItem savedOptionItem1 = optionItemRepository.save( + new OptionItem("선택지1", savedOptionGroup.getId(), 1, OptionType.KEYWORD)); + OptionItem savedOptionItem2 = optionItemRepository.save( + new OptionItem("선택지2", savedOptionGroup.getId(), 2, OptionType.KEYWORD)); + CreateReviewAnswerRequest createReviewAnswerRequest = new CreateReviewAnswerRequest(savedQuestion.getId(), + List.of(savedOptionItem1.getId(), savedOptionItem2.getId()), null); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(createReviewAnswerRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(checkboxAnswerRepository.findAll()).hasSize(1); + } + + @Test + void 적정_글자수인_텍스트_응답인_경우_정상_저장된다() { + // given + String reviewRequestCode = "0000"; + reviewGroupRepository.save(new ReviewGroup("리뷰어", "프로젝트", reviewRequestCode, "12341234")); + Section section = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(1L), 1L, "섹션명", "말머리", 1)); + templateRepository.save(new Template(List.of(section.getId()))); + + String expectedTextAnswer = "답".repeat(1000); + Question savedQuestion = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest createReviewAnswerRequest = new CreateReviewAnswerRequest(savedQuestion.getId(), null, + expectedTextAnswer); + CreateReviewRequest createReviewRequest = new CreateReviewRequest(reviewRequestCode, + List.of(createReviewAnswerRequest)); + + // when + createReviewService.createReview(createReviewRequest); + + // then + assertThat(reviewRepository.findAll()).hasSize(1); + assertThat(textAnswerRepository.findAll()).hasSize(1); + } +} diff --git a/backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java b/backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java new file mode 100644 index 000000000..8224d4acd --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/CreateTextAnswerRequestValidatorTest.java @@ -0,0 +1,77 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.exception.InvalidTextAnswerLengthException; +import reviewme.review.service.dto.request.CreateReviewAnswerRequest; +import reviewme.review.service.exception.RequiredQuestionNotAnsweredException; +import reviewme.review.service.exception.SubmittedQuestionNotFoundException; +import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; +import reviewme.support.ServiceTest; + +@ServiceTest +class CreateTextAnswerRequestValidatorTest { + + @Autowired + private CreateTextAnswerRequestValidator createTextAnswerRequestValidator; + + @Autowired + private QuestionRepository questionRepository; + + @Test + void 저장되지_않은_질문에_대한_대답이면_예외가_발생한다() { + // given + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(100L, null, "텍스트형 응답"); + + // when, then + assertThatCode(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(SubmittedQuestionNotFoundException.class); + } + + @Test + void 텍스트형_질문에_선택형_응답을_하면_예외가_발생한다() { + // given + Question savedQuestion + = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(savedQuestion.getId(), List.of(1L), "응답"); + + // when, then + assertThatCode(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(TextAnswerIncludedOptionItemException.class); + } + + @Test + void 필수_텍스트형_질문에_응답을_하지_않으면_예외가_발생한다() { + // given + Question savedQuestion + = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(savedQuestion.getId(), null, null); + + // when, then + assertThatCode(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(RequiredQuestionNotAnsweredException.class); + } + + @ParameterizedTest + @ValueSource(ints = {19, 10001}) + void 답변_길이가_유효하지_않으면_예외가_발생한다(int length) { + // given + String textAnswer = "답".repeat(length); + Question savedQuestion + = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1)); + CreateReviewAnswerRequest request = new CreateReviewAnswerRequest(savedQuestion.getId(), null, textAnswer); + + // when, then + assertThatThrownBy(() -> createTextAnswerRequestValidator.validate(request)) + .isInstanceOf(InvalidTextAnswerLengthException.class); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java new file mode 100644 index 000000000..84b54afb2 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewDetailLookupServiceTest.java @@ -0,0 +1,214 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.TextAnswer; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.detail.QuestionAnswerResponse; +import reviewme.review.service.dto.response.detail.SectionAnswerResponse; +import reviewme.review.service.dto.response.detail.TemplateAnswerResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.review.service.exception.ReviewNotFoundByIdAndGroupException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class ReviewDetailLookupServiceTest { + + @Autowired + private ReviewDetailLookupService reviewDetailLookupService; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private SectionRepository sectionRepository; + + @Autowired + private QuestionRepository questionRepository; + + @Autowired + private OptionGroupRepository optionGroupRepository; + + @Autowired + private OptionItemRepository optionItemRepository; + + @Autowired + private TemplateRepository templateRepository; + + @Test + void 잘못된_리뷰_요청_코드로_리뷰를_조회할_경우_예외를_발생한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", reviewRequestCode, groupAccessCode)); + + Review review = reviewRepository.save(new Review(0, reviewGroup.getId(), List.of(), List.of())); + + // when, then + assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( + review.getId(), "wrong" + reviewRequestCode, groupAccessCode + )).isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } + + @Test + void 잘못된_그룹_액세스_코드로_리뷰를_조회할_경우_예외를_발생한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", reviewRequestCode, groupAccessCode)); + + Review review = reviewRepository.save(new Review(0, reviewGroup.getId(), List.of(), List.of())); + + // when, then + assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( + review.getId(), reviewRequestCode, "wrong" + groupAccessCode + )).isInstanceOf(ReviewGroupUnauthorizedException.class); + } + + @Test + void 리뷰_그룹에_해당하지_않는_리뷰를_조회할_경우_예외를_발생한다() { + // given + String reviewRequestCode1 = "reviewRequestCode1"; + String groupAccessCode1 = "groupAccessCode1"; + ReviewGroup reviewGroup1 = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", reviewRequestCode1, groupAccessCode1)); + ReviewGroup reviewGroup2 = reviewGroupRepository.save( + new ReviewGroup("테드", "리뷰미 프로젝트", "ABCD", "1234")); + + Review review1 = reviewRepository.save(new Review(0, reviewGroup1.getId(), List.of(), List.of())); + Review review2 = reviewRepository.save(new Review(0, reviewGroup2.getId(), List.of(), List.of())); + + // when, then + assertThatThrownBy(() -> reviewDetailLookupService.getReviewDetail( + review2.getId(), reviewRequestCode1, groupAccessCode1)) + .isInstanceOf(ReviewNotFoundByIdAndGroupException.class); + } + + @Test + void 사용자가_작성한_리뷰를_확인한다() { + // given + String reviewRequestCode = "ABCD"; + String groupAccessCode = "0000"; + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup("aru", "reviewme", reviewRequestCode, groupAccessCode)); + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", null, 1)); + Question question2 = questionRepository.save(new Question(true, QuestionType.CHECKBOX, "질문", null, 1)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "체크 1 조건", "가이드라인", 1)); + OptionGroup optionGroup = optionGroupRepository.save(new OptionGroup(question2.getId(), 1, 3)); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("체크 1", optionGroup.getId(), 1, OptionType.KEYWORD)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("체크 2", optionGroup.getId(), 1, OptionType.KEYWORD)); + + Section section1 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(question1.getId(), question2.getId()), null, "1번 섹션", "말머리", 1) + ); + Section section2 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(question3.getId()), optionItem1.getId(), "2번 섹션", "말머리", 2) + ); + Template template = templateRepository.save(new Template(List.of(section1.getId(), section2.getId()))); + + List textAnswers = List.of( + new TextAnswer(1, "질문 1 답변 (20자 이상 입력 적용)"), + new TextAnswer(3, "질문 3 답변 (20자 이상 입력 적용)") + ); + List checkboxAnswers = List.of( + new CheckboxAnswer(2, List.of(optionItem1.getId(), optionItem2.getId())) + ); + Review review = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), textAnswers, checkboxAnswers) + ); + + // when + TemplateAnswerResponse reviewDetail = reviewDetailLookupService.getReviewDetail( + review.getId(), reviewRequestCode, groupAccessCode + ); + + // then + assertThat(reviewDetail.sections()).hasSize(2); + } + + @Test + void 답변이_있는_리뷰만_보여준다() { + // given + String reviewRequestCode = "ABCD"; + String groupAccessCode = "0000"; + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup("aru", "reviewme", reviewRequestCode, groupAccessCode)); + Question question1 = questionRepository.save(new Question(true, QuestionType.TEXT, "질문", null, 1)); + Question question2 = questionRepository.save(new Question(false, QuestionType.CHECKBOX, "질문", null, 1)); + Question question3 = questionRepository.save(new Question(true, QuestionType.TEXT, "체크 1 조건", "가이드라인", 1)); + Question question4 = questionRepository.save(new Question(false, QuestionType.TEXT, "선택 질문", "가이드라인", 1)); + OptionGroup optionGroup = optionGroupRepository.save(new OptionGroup(question2.getId(), 1, 3)); + OptionItem optionItem1 = optionItemRepository.save( + new OptionItem("체크 1", optionGroup.getId(), 1, OptionType.KEYWORD)); + OptionItem optionItem2 = optionItemRepository.save( + new OptionItem("체크 2", optionGroup.getId(), 1, OptionType.KEYWORD)); + + Section section1 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(question1.getId(), question2.getId()), null, "1번 섹션", "말머리", 1) + ); + Section section2 = sectionRepository.save( + new Section(VisibleType.CONDITIONAL, List.of(question3.getId()), optionItem1.getId(), "2번 섹션", "말머리", 2) + ); + Section section3 = sectionRepository.save( + new Section(VisibleType.ALWAYS, List.of(question4.getId()), null, "3번 섹션", "말머리", 3) + ); + + Template template = templateRepository.save( + new Template(List.of(section1.getId(), section2.getId(), section3.getId()))); + + List textAnswers = List.of( + new TextAnswer(1, "질문 1 답변"), + new TextAnswer(3, "질문 3 답변") + ); + List checkboxAnswers = new ArrayList<>(); + Review review = reviewRepository.save( + new Review(template.getId(), reviewGroup.getId(), textAnswers, checkboxAnswers) + ); + + // when + TemplateAnswerResponse reviewDetail = reviewDetailLookupService.getReviewDetail( + review.getId(), reviewRequestCode, groupAccessCode + ); + + // then + List sections = reviewDetail.sections(); + + assertAll( + () -> assertThat(sections).extracting(SectionAnswerResponse::sectionId) + .containsExactly(section1.getId(), section2.getId()), + () -> assertThat(sections.get(0).questions()) + .extracting(QuestionAnswerResponse::questionId).containsExactly(question1.getId()), + () -> assertThat(sections.get(1).questions()) + .extracting(QuestionAnswerResponse::questionId).containsExactly(question3.getId()) + ); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java b/backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java new file mode 100644 index 000000000..f63c47b5f --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewPreviewGeneratorTest.java @@ -0,0 +1,41 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reviewme.review.domain.TextAnswer; + +class ReviewPreviewGeneratorTest { + + @Test + void 답변_내용이_미리보기_최대_글자를_넘는_경우_미리보기_길이만큼_잘라서_반환한다() { + // given + ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); + String answer = "*".repeat(151); + TextAnswer textAnswer = new TextAnswer(1, answer); + + // when + String actual = reviewPreviewGenerator.generatePreview(List.of(textAnswer)); + + // then + assertThat(actual).hasSize(150); + } + + @ParameterizedTest + @ValueSource(ints = {149, 150}) + void 답변_내용이_미리보기_최대_글자를_넘지_않는_경우_전체_내용을_반환한다(int length) { + // given + ReviewPreviewGenerator reviewPreviewGenerator = new ReviewPreviewGenerator(); + String answer = "*".repeat(length); + TextAnswer textAnswer = new TextAnswer(1, answer); + + // when + String actual = reviewPreviewGenerator.generatePreview(List.of(textAnswer)); + + // then + assertThat(actual).hasSize(length); + } +} diff --git a/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java new file mode 100644 index 000000000..f5acf1634 --- /dev/null +++ b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java @@ -0,0 +1,109 @@ +package reviewme.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.review.domain.CheckboxAnswer; +import reviewme.review.domain.Review; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.review.repository.CheckboxAnswerRepository; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.dto.response.list.ReceivedReviewsResponse; +import reviewme.review.service.exception.ReviewGroupUnauthorizedException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Template; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; + +@ServiceTest +class ReviewServiceTest { + + @Autowired + ReviewService reviewService; + + @Autowired + QuestionRepository questionRepository; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Autowired + OptionItemRepository optionItemRepository; + + @Autowired + OptionGroupRepository optionGroupRepository; + + @Autowired + SectionRepository sectionRepository; + + @Autowired + TemplateRepository templateRepository; + + @Autowired + CheckboxAnswerRepository checkboxAnswerRepository; + + @Autowired + ReviewRepository reviewRepository; + + @Test + void 리뷰_요청_코드가_존재하지_않는_경우_예외가_발생한다() { + assertThatThrownBy(() -> reviewService.findReceivedReviews("abc", "groupAccessCode")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } + + @Test + void 그룹_액세스_코드가_일치하지_않는_경우_예외가_발생한다() { + // given + String reviewRequestCode = "code"; + String groupAccessCode = "1234"; + reviewGroupRepository.save(new ReviewGroup("커비", "리뷰미", reviewRequestCode, groupAccessCode)); + + // when, then + assertThatThrownBy(() -> reviewService.findReceivedReviews(reviewRequestCode, "5678")) + .isInstanceOf(ReviewGroupUnauthorizedException.class); + } + + @Test + void 확인_코드에_해당하는_그룹이_존재하면_리뷰_리스트를_반환한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + Question question = questionRepository.save( + new Question(true, QuestionType.CHECKBOX, "프로젝트 기간 동안, 팀원의 강점이 드러났던 순간을 선택해주세요. (1~2개)", null, 1) + ); + OptionGroup categoryOptionGroup = optionGroupRepository.save(new OptionGroup(question.getId(), 1, 2)); + OptionItem categoryOption1 = new OptionItem("커뮤니케이션 능력 ", categoryOptionGroup.getId(), 1, OptionType.CATEGORY); + OptionItem categoryOption2 = new OptionItem("시간 관리 능력", categoryOptionGroup.getId(), 2, OptionType.CATEGORY); + optionItemRepository.saveAll(List.of(categoryOption1, categoryOption2)); + + Template template = templateRepository.save(new Template(List.of())); + + ReviewGroup reviewGroup = reviewGroupRepository.save( + new ReviewGroup("커비", "리뷰미", reviewRequestCode, groupAccessCode) + ); + CheckboxAnswer categoryAnswer1 = new CheckboxAnswer(question.getId(), List.of(categoryOption1.getId())); + CheckboxAnswer categoryAnswer2 = new CheckboxAnswer(question.getId(), List.of(categoryOption2.getId())); + Review review1 = new Review(template.getId(), reviewGroup.getId(), List.of(), List.of(categoryAnswer1)); + Review review = new Review(template.getId(), reviewGroup.getId(), List.of(), List.of(categoryAnswer2)); + reviewRepository.saveAll(List.of(review1, review)); + + // when + ReceivedReviewsResponse response = reviewService.findReceivedReviews(reviewRequestCode, groupAccessCode); + + // then + assertThat(response.reviews()).hasSize(2); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java b/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java new file mode 100644 index 000000000..d6ac6055a --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/ReviewGroupTest.java @@ -0,0 +1,63 @@ +package reviewme.reviewgroup; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.Test; +import reviewme.global.exception.BadRequestException; +import reviewme.reviewgroup.domain.ReviewGroup; + +class ReviewGroupTest { + + @Test + void 정상_생성된다() { + // given + int maxLength = 50; + int minLength = 1; + String minLengthName = "*".repeat(minLength); + String maxLengthName = "*".repeat(maxLength); + + // when, then + assertAll( + () -> assertThatCode(() -> new ReviewGroup(minLengthName, "project", "reviewCode", "groupCode")) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> new ReviewGroup(maxLengthName, "project", "reviewCode", "groupCode")) + .doesNotThrowAnyException() + ); + } + + @Test + void 리뷰이_이름이_정해진_길이에_맞지_않으면_예외가_발생한다() { + // given + int maxLength = 50; + int minLength = 1; + String insufficientName = "*".repeat(minLength - 1); + String exceedName = "*".repeat(maxLength + 1); + + // when, then + assertAll( + () -> assertThatCode(() -> new ReviewGroup(insufficientName, "project", "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class), + () -> assertThatThrownBy(() -> new ReviewGroup(exceedName, "project", "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class) + ); + } + + @Test + void 프로젝트_이름이_정해진_길이에_맞지_않으면_예외가_발생한다() { + // given + int maxLength = 50; + int minLength = 1; + String insufficientName = "*".repeat(minLength - 1); + String exceedName = "*".repeat(maxLength + 1); + + // when, then + assertAll( + () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", insufficientName, "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class), + () -> assertThatThrownBy(() -> new ReviewGroup("reviwee", exceedName, "reviewCode", "groupCode")) + .isInstanceOf(BadRequestException.class) + ); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java b/backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java new file mode 100644 index 000000000..fc5b8ec51 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/domain/GroupAccessCodeTest.java @@ -0,0 +1,35 @@ +package reviewme.reviewgroup.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reviewme.reviewgroup.domain.exception.InvalidGroupAccessCodeFormatException; + +class GroupAccessCodeTest { + + @Test + void 코드_일치_여부를_판단한다() { + // given + String code = "hello"; + GroupAccessCode groupAccessCode = new GroupAccessCode(code); + + // when, then + assertThat(groupAccessCode.matches("hello")).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"AZaz", "a0Z9", "aZ09", "ABCD123a", "1234"}) + void 정규식에_일치하면_성공적으로_생성된다(String code) { + assertDoesNotThrow(() -> new GroupAccessCode(code)); + } + + @ParameterizedTest + @ValueSource(strings = {"", "123", "123456789012345678901", "aaaa-"}) + void 정규식에_일치하지_않으면_예외가_발생한다(String code) { + assertThrows(InvalidGroupAccessCodeFormatException.class, () -> new GroupAccessCode(code)); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java b/backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java new file mode 100644 index 000000000..aee9a3b27 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/service/RandomCodeGeneratorTest.java @@ -0,0 +1,21 @@ +package reviewme.reviewgroup.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class RandomCodeGeneratorTest { + + @Test + void 주어진_길이에_맞는_랜덤한_문자열을_생성한다() { + // given + int length = 8; + RandomCodeGenerator randomCodeGenerator = new RandomCodeGenerator(); + + // when + String actual = randomCodeGenerator.generate(length); + + // then + assertThat(actual).matches("[a-zA-Z0-9]{%d}".formatted(length)); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java new file mode 100644 index 000000000..6764e8586 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupLookupServiceTest.java @@ -0,0 +1,52 @@ +package reviewme.reviewgroup.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.ReviewGroupResponse; +import reviewme.support.ServiceTest; + +@ServiceTest +class ReviewGroupLookupServiceTest { + + @Autowired + ReviewGroupLookupService reviewGroupLookupService; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Test + void 리뷰_요청_코드로_리뷰_그룹을_조회한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(new ReviewGroup( + "ted", + "review-me", + "reviewRequestCode", + "groupAccessCode" + )); + + // when + ReviewGroupResponse response = reviewGroupLookupService.getReviewGroupSummary( + reviewGroup.getReviewRequestCode() + ); + + // then + assertAll( + () -> assertThat(response.revieweeName()).isEqualTo(reviewGroup.getReviewee()), + () -> assertThat(response.projectName()).isEqualTo(reviewGroup.getProjectName()) + ); + } + + @Test + void 리뷰_요청_코드에_대한_리뷰_그룹이_존재하지_않을_경우_예외가_발생한다() { + // given, when, then + assertThatThrownBy(() -> reviewGroupLookupService.getReviewGroupSummary("reviewRequestCode")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } +} diff --git a/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java new file mode 100644 index 000000000..d7f693de8 --- /dev/null +++ b/backend/src/test/java/reviewme/reviewgroup/service/ReviewGroupServiceTest.java @@ -0,0 +1,74 @@ +package reviewme.reviewgroup.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.reviewgroup.service.dto.CheckValidAccessRequest; +import reviewme.reviewgroup.service.dto.CheckValidAccessResponse; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationRequest; +import reviewme.reviewgroup.service.dto.ReviewGroupCreationResponse; +import reviewme.support.ServiceTest; + +@ServiceTest +@ExtendWith(MockitoExtension.class) +class ReviewGroupServiceTest { + + @MockBean + private RandomCodeGenerator randomCodeGenerator; + + @Autowired + private ReviewGroupService reviewGroupService; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Test + void 코드가_중복되는_경우_다시_생성한다() { + // given + reviewGroupRepository.save(new ReviewGroup("reviewee", "project", "0000", "1111")); + given(randomCodeGenerator.generate(anyInt())) + .willReturn("0000") // ReviewRequestCode + .willReturn("AAAA"); + + ReviewGroupCreationRequest request = new ReviewGroupCreationRequest("sancho", "reviewme", "groupAccessCode"); + + // when + ReviewGroupCreationResponse response = reviewGroupService.createReviewGroup(request); + + // then + assertThat(response).isEqualTo(new ReviewGroupCreationResponse("AAAA")); + then(randomCodeGenerator).should(times(2)).generate(anyInt()); + } + + @Test + void 리뷰_요청_코드와_리뷰_확인_코드가_일치하는지_확인한다() { + // given + String reviewRequestCode = "reviewRequestCode"; + String groupAccessCode = "groupAccessCode"; + reviewGroupRepository.save(new ReviewGroup("reviewee", "project", reviewRequestCode, groupAccessCode)); + + CheckValidAccessRequest request = new CheckValidAccessRequest(reviewRequestCode, groupAccessCode); + CheckValidAccessRequest wrongRequest = new CheckValidAccessRequest(reviewRequestCode, groupAccessCode + "!"); + + // when + CheckValidAccessResponse expected1 = reviewGroupService.checkGroupAccessCode(request); + CheckValidAccessResponse expected2 = reviewGroupService.checkGroupAccessCode(wrongRequest); + + // then + assertAll( + () -> assertThat(expected1.hasAccess()).isTrue(), + () -> assertThat(expected2.hasAccess()).isFalse() + ); + } +} diff --git a/backend/src/test/java/reviewme/support/DatabaseCleaner.java b/backend/src/test/java/reviewme/support/DatabaseCleaner.java new file mode 100644 index 000000000..b90427980 --- /dev/null +++ b/backend/src/test/java/reviewme/support/DatabaseCleaner.java @@ -0,0 +1,42 @@ +package reviewme.support; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Table; +import jakarta.persistence.metamodel.EntityType; +import jakarta.transaction.Transactional; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class DatabaseCleaner { + + private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s"; + private static final String ALTER_FORMAT = "ALTER TABLE %s ALTER COLUMN ID RESTART WITH 1"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @PostConstruct + public void afterPropertiesSet() { + Set> entities = entityManager.getMetamodel().getEntities(); + tableNames = new ArrayList<>(entities.stream() + .filter(entity -> entity.getJavaType().isAnnotationPresent(Table.class)) + .map(entity -> entity.getJavaType().getAnnotation(Table.class).name()) + .toList()); + } + + @Transactional + public void execute() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : tableNames) { + entityManager.createNativeQuery(TRUNCATE_FORMAT.formatted(tableName)).executeUpdate(); + entityManager.createNativeQuery(ALTER_FORMAT.formatted(tableName)).executeUpdate(); + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} diff --git a/backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java b/backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java new file mode 100644 index 000000000..3e2edf4b8 --- /dev/null +++ b/backend/src/test/java/reviewme/support/DatabaseCleanerExtension.java @@ -0,0 +1,15 @@ +package reviewme.support; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class DatabaseCleanerExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext extensionContext) { + SpringExtension.getApplicationContext(extensionContext) + .getBean(DatabaseCleaner.class) + .execute(); + } +} diff --git a/backend/src/test/java/reviewme/support/ServiceTest.java b/backend/src/test/java/reviewme/support/ServiceTest.java new file mode 100644 index 000000000..34ae4b4fd --- /dev/null +++ b/backend/src/test/java/reviewme/support/ServiceTest.java @@ -0,0 +1,17 @@ +package reviewme.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import reviewme.config.TestConfig; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(webEnvironment = WebEnvironment.NONE, classes = TestConfig.class) +@ExtendWith(DatabaseCleanerExtension.class) +public @interface ServiceTest { +} diff --git a/backend/src/test/java/reviewme/template/domain/SectionTest.java b/backend/src/test/java/reviewme/template/domain/SectionTest.java new file mode 100644 index 000000000..af307e7bd --- /dev/null +++ b/backend/src/test/java/reviewme/template/domain/SectionTest.java @@ -0,0 +1,45 @@ +package reviewme.template.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class SectionTest { + + @Test + void 조건_옵션을_선택하면_섹션이_보인다() { + // given + Section section = new Section(VisibleType.CONDITIONAL, List.of(), 1L, "섹션명", "말머리", 1); + + // when + boolean actual = section.isVisibleBySelectedOptionIds(List.of(1L, 2L, 3L)); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 조건_옵션을_선택하지_않으면_섹션이_보이지_않는다() { + // given + Section section = new Section(VisibleType.CONDITIONAL, List.of(), 1L, "섹션명", "말머리", 1); + + // when + boolean actual = section.isVisibleBySelectedOptionIds(List.of(4L, 5L, 6L)); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 타입이_ALWAYS라면_조건과_상관없이_모두_보인다() { + // given + Section section = new Section(VisibleType.ALWAYS, List.of(), null, "섹션명", "말머리", 1); + + // when + boolean actual = section.isVisibleBySelectedOptionIds(List.of()); + + // then + assertThat(actual).isTrue(); + } +} diff --git a/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java new file mode 100644 index 000000000..0154c40f7 --- /dev/null +++ b/backend/src/test/java/reviewme/template/repository/SectionRepositoryTest.java @@ -0,0 +1,38 @@ +package reviewme.template.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; + +@DataJpaTest +class SectionRepositoryTest { + + @Autowired + private SectionRepository sectionRepository; + @Autowired + private TemplateRepository templateRepository; + + @Test + void 템플릿_아이디로_섹션을_불러온다() { + // given + Section section1 = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "1","말머리", 1)); + Section section2 = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "2","말머리", 1)); + Section section3 = sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "3","말머리", 1)); + sectionRepository.save(new Section(VisibleType.ALWAYS, List.of(), null, "4","말머리", 1)); + Template template = templateRepository.save( + new Template(List.of(section1.getId(), section2.getId(), section3.getId())) + ); + + // when + List
actual = sectionRepository.findAllByTemplateId(template.getId()); + + // then + assertThat(actual).containsExactly(section1, section2, section3); + } +} diff --git a/backend/src/test/java/reviewme/template/service/TemplateMapperTest.java b/backend/src/test/java/reviewme/template/service/TemplateMapperTest.java new file mode 100644 index 000000000..984df25df --- /dev/null +++ b/backend/src/test/java/reviewme/template/service/TemplateMapperTest.java @@ -0,0 +1,210 @@ +package reviewme.template.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.question.domain.OptionGroup; +import reviewme.question.domain.OptionItem; +import reviewme.question.domain.OptionType; +import reviewme.question.domain.Question; +import reviewme.question.domain.QuestionType; +import reviewme.question.domain.exception.MissingOptionItemsInOptionGroupException; +import reviewme.question.repository.OptionGroupRepository; +import reviewme.question.repository.OptionItemRepository; +import reviewme.question.repository.QuestionRepository; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.Section; +import reviewme.template.domain.Template; +import reviewme.template.domain.VisibleType; +import reviewme.template.domain.exception.SectionInTemplateNotFoundException; +import reviewme.template.repository.SectionRepository; +import reviewme.template.repository.TemplateRepository; +import reviewme.template.service.dto.response.QuestionResponse; +import reviewme.template.service.dto.response.SectionResponse; +import reviewme.template.service.dto.response.TemplateResponse; + +@ServiceTest +class TemplateMapperTest { + + @Autowired + TemplateMapper templateMapper; + + @Autowired + TemplateRepository templateRepository; + + @Autowired + SectionRepository sectionRepository; + + @Autowired + QuestionRepository questionRepository; + + @Autowired + OptionGroupRepository optionGroupRepository; + + @Autowired + OptionItemRepository optionItemRepository; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Test + void 리뷰_그룹과_템플릿으로_템플릿_응답을_매핑한다() { + // given + Question question1 = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + Question question2 = new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1); + questionRepository.saveAll(List.of(question1, question2)); + + OptionGroup optionGroup = new OptionGroup(question2.getId(), 1, 2); + optionGroupRepository.save(optionGroup); + + OptionItem optionItem = new OptionItem("선택지", optionGroup.getId(), 1, OptionType.CATEGORY); + optionItemRepository.save(optionItem); + + Section section1 = new Section(VisibleType.ALWAYS, List.of(question1.getId()), null, "섹션명", "말머리1", 1); + Section section2 = new Section(VisibleType.ALWAYS, List.of(question2.getId()), null, "섹션명", "말머리2", 2); + sectionRepository.saveAll(List.of(section1, section2)); + + Template template = new Template(List.of(section1.getId(), section2.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + assertAll( + () -> assertThat(templateResponse.revieweeName()).isEqualTo(reviewGroup.getReviewee()), + () -> assertThat(templateResponse.projectName()).isEqualTo(reviewGroup.getProjectName()), + () -> assertThat(templateResponse.sections()).hasSize(2), + () -> assertThat(templateResponse.sections().get(0).header()).isEqualTo(section1.getHeader()), + () -> assertThat(templateResponse.sections().get(0).questions()).hasSize(1), + () -> assertThat(templateResponse.sections().get(1).header()).isEqualTo(section2.getHeader()), + () -> assertThat(templateResponse.sections().get(1).questions()).hasSize(1) + ); + } + + @Test + void 섹션의_선택된_옵션이_필요없는_경우_제공하지_않는다() { + // given + Question question = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + questionRepository.save(question); + + Section section = new Section(VisibleType.ALWAYS, List.of(question.getId()), null, "섹션명", "말머리", 1); + sectionRepository.save(section); + + Template template = new Template(List.of(section.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + SectionResponse sectionResponse = templateResponse.sections().get(0); + assertThat(sectionResponse.onSelectedOptionId()).isNull(); + } + + @Test + void 가이드라인이_없는_경우_가이드_라인을_제공하지_않는다() { + // given + Question question = new Question(true, QuestionType.TEXT, "질문", null, 1); + questionRepository.save(question); + + OptionGroup optionGroup = new OptionGroup(question.getId(), 1, 2); + optionGroupRepository.save(optionGroup); + + OptionItem optionItem = new OptionItem("선택지", optionGroup.getId(), 1, OptionType.CATEGORY); + optionItemRepository.save(optionItem); + + Section section = new Section(VisibleType.ALWAYS, List.of(question.getId()), null, "섹션명", "말머리", 1); + sectionRepository.save(section); + + Template template = new Template(List.of(section.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + QuestionResponse questionResponse = templateResponse.sections().get(0).questions().get(0); + assertAll( + () -> assertThat(questionResponse.hasGuideline()).isFalse(), + () -> assertThat(questionResponse.guideline()).isNull() + ); + } + + @Test + void 옵션_그룹이_없는_질문의_경우_옵션_그룹을_제공하지_않는다() { + // given + Question question = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + questionRepository.save(question); + + Section section = new Section(VisibleType.ALWAYS, List.of(question.getId()), null, "섹션명", "말머리", 1); + sectionRepository.save(section); + + Template template = new Template(List.of(section.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when + TemplateResponse templateResponse = templateMapper.mapToTemplateResponse(reviewGroup, template); + + // then + QuestionResponse questionResponse = templateResponse.sections().get(0).questions().get(0); + assertThat(questionResponse.optionGroup()).isNull(); + } + + @Test + void 템플릿_매핑_시_템플릿에_제공할_섹션이_없을_경우_예외가_발생한다() { + // given + Template template = new Template(List.of(1L)); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateMapper.mapToTemplateResponse(reviewGroup, template)) + .isInstanceOf(SectionInTemplateNotFoundException.class); + } + + @Test + void 템플릿_매핑_시_옵션_그룹에_해당하는_옵션_아이템이_없을_경우_예외가_발생한다() { + // given + Question question1 = new Question(true, QuestionType.TEXT, "질문", "가이드라인", 1); + Question question2 = new Question(true, QuestionType.CHECKBOX, "질문", "가이드라인", 1); + questionRepository.saveAll(List.of(question1, question2)); + + OptionGroup optionGroup = new OptionGroup(question2.getId(), 1, 2); + optionGroupRepository.save(optionGroup); + + Section section1 = new Section(VisibleType.ALWAYS, List.of(question1.getId()), null, "섹션명", "말머리", 1); + Section section2 = new Section(VisibleType.ALWAYS, List.of(question2.getId()), null, "섹션명", "말머리", 2); + sectionRepository.saveAll(List.of(section1, section2)); + + Template template = new Template(List.of(section1.getId(), section2.getId())); + templateRepository.save(template); + + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateMapper.mapToTemplateResponse(reviewGroup, template)) + .isInstanceOf(MissingOptionItemsInOptionGroupException.class); + } +} diff --git a/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java b/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java new file mode 100644 index 000000000..8d84d6f82 --- /dev/null +++ b/backend/src/test/java/reviewme/template/service/TemplateServiceTest.java @@ -0,0 +1,43 @@ +package reviewme.template.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import reviewme.review.domain.exception.ReviewGroupNotFoundByReviewRequestCodeException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.support.ServiceTest; +import reviewme.template.domain.exception.TemplateNotFoundByReviewGroupException; + +@ServiceTest +class TemplateServiceTest { + + @Autowired + TemplateService templateService; + + @Autowired + ReviewGroupRepository reviewGroupRepository; + + @Test + void 잘못된_리뷰_요청_코드로_리뷰_작성폼을_요청할_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateService.generateReviewForm(reviewGroup.getReviewRequestCode() + " ")) + .isInstanceOf(ReviewGroupNotFoundByReviewRequestCodeException.class); + } + + @Test + void 리뷰이에게_작성될_리뷰_양식_생성_시_저장된_템플릿이_없을_경우_예외가_발생한다() { + // given + ReviewGroup reviewGroup = new ReviewGroup("리뷰이명", "프로젝트명", "reviewRequestCode", "groupAccessCode"); + reviewGroupRepository.save(reviewGroup); + + // when, then + assertThatThrownBy(() -> templateService.generateReviewForm(reviewGroup.getReviewRequestCode())) + .isInstanceOf(TemplateNotFoundByReviewGroupException.class); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 000000000..b941b41ba --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,34 @@ +spring: + datasource: + url: jdbc:h2:mem:test + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + show-sql: true + hibernate: + ddl-auto: update + +springdoc: + swagger-ui: + path: /api-docs + operations-sorter: alpha + tags-sorter: alpha + +logging: + config: classpath:logback-spring.xml + file: + path: logs + name: review-me.log + level: + springframework: DEBUG + logback: + rolling-policy: + max-history: 100 + file-name-pattern: review-me.%d{yyyy-MM-dd}.log + pattern: + console: "%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(${PID:- }){magenta} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wEx" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${PID:- } [%15.15t] %-40.40logger{39} : %m%n%wEx" diff --git a/backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 000000000..f2c62a493 --- /dev/null +++ b/backend/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,11 @@ +|=== +|필드명|타입|필수 여부|설명 + +{{#fields}} +|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}} +|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}false{{/optional}}{{^optional}}true{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/fields}} + +|=== diff --git a/backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet b/backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet new file mode 100644 index 000000000..e45909715 --- /dev/null +++ b/backend/src/test/resources/org/springframework/restdocs/templates/request-headers.snippet @@ -0,0 +1,10 @@ +|=== +|헤더 이름|필수 여부|설명 + +{{#headers}} +|{{#tableCellContent}}{{name}}{{/tableCellContent}} +|{{#tableCellContent}}{{#optional}}false{{/optional}}{{^optional}}true{{/optional}}{{/tableCellContent}} +|{{#tableCellContent}}{{description}}{{/tableCellContent}} +{{/headers}} + +|=== diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 000000000..b6d283909 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,67 @@ +const path = require('path'); + +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'plugin:import/recommended', + 'plugin:react/recommended', + 'prettier', + ], + ignorePatterns: [ + 'dist', + '.eslintrc.cjs', + 'webpack.config.js', + 'jest.config.js', + 'jest.polyfills.js', + 'jest.setup.js', + 'tsconfig.json', + ], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/no-unknown-property': ['error', { ignore: ['css'] }], + 'import/order': [ + 'error', + { + 'newlines-between': 'always', + groups: [['builtin', 'external'], 'internal', 'parent', 'sibling', 'index'], + pathGroups: [ + { + pattern: 'next', + group: 'builtin', + }, + { + pattern: 'react', + group: 'builtin', + }, + { + pattern: '@MyDesignSystem/**', + group: 'internal', + }, + { + pattern: 'src/**', + group: 'internal', + }, + ], + pathGroupsExcludedImportTypes: ['src/**', '@MyDesignSystem/**'], + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + }, + settings: { + 'import/resolver': { + alias: { + map: [['@', path.resolve(__dirname, 'src')]], + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, +}; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..090910b92 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist + +yarn-error.log +.env +.env.sentry-build-plugin diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 000000000..17c2d2067 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,12 @@ +{ + "endOfLine": "auto", + "singleQuote": true, + "semi": true, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 120, + "bracketSpacing": true, + "arrowParens": "always", + "prefer-const": true +} \ No newline at end of file diff --git a/frontend/.stylelintrc.json b/frontend/.stylelintrc.json new file mode 100644 index 000000000..9c644d2c4 --- /dev/null +++ b/frontend/.stylelintrc.json @@ -0,0 +1,13 @@ +{ + "extends": ["stylelint-config-clean-order"], + "plugins": ["stylelint-order"], + "customSyntax": "@stylelint/postcss-css-in-js", + "rules": { + "declaration-empty-line-before": [ + "never", + { + "ignore": ["after-declaration"] + } + ] + } +} diff --git a/frontend/babel.config.json b/frontend/babel.config.json new file mode 100644 index 000000000..8e3a5e35b --- /dev/null +++ b/frontend/babel.config.json @@ -0,0 +1,8 @@ +{ + "presets": [ + "@babel/preset-env", + ["@babel/preset-react", { "runtime": "automatic", "importSource": "@emotion/react" }], + "@babel/preset-typescript" + ], + "plugins": ["@emotion"] +} diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 000000000..3891f259d --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,13 @@ +const { pathsToModuleNameMapper } = require('ts-jest'); + +const { compilerOptions } = require('./tsconfig'); + +module.exports = { + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' }), + setupFiles: ['./jest.polyfills.js'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['./jest.setup.js'], + testEnvironmentOptions: { + customExportConditions: [''], + }, +}; diff --git a/frontend/jest.polyfills.js b/frontend/jest.polyfills.js new file mode 100644 index 000000000..03a705a59 --- /dev/null +++ b/frontend/jest.polyfills.js @@ -0,0 +1,21 @@ +import 'dotenv/config'; +const { TextDecoder, TextEncoder, ReadableStream } = require('node:util'); + +Object.defineProperties(globalThis, { + TextDecoder: { value: TextDecoder }, + TextEncoder: { value: TextEncoder }, + ReadableStream: { value: ReadableStream }, +}); + +const { Blob, File } = require('node:buffer'); +const { fetch, Headers, FormData, Request, Response } = require('undici'); + +Object.defineProperties(globalThis, { + fetch: { value: fetch, writable: true }, + Blob: { value: Blob }, + File: { value: File }, + Headers: { value: Headers }, + FormData: { value: FormData }, + Request: { value: Request }, + Response: { value: Response }, +}); diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js new file mode 100644 index 000000000..ed5421416 --- /dev/null +++ b/frontend/jest.setup.js @@ -0,0 +1,5 @@ +import server from './src/mocks/server'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..7c1e38e29 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,79 @@ +{ + "name": "frontend", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "dev": "webpack-dev-server --mode=development --open --hot --progress", + "start": "webpack serve --open --config webpack.config.js", + "build": "webpack --config webpack.config.js", + "serve": "http-server ./dist", + "lint:styles": "stylelint \"src/**/styles.ts\" --fix", + "test": "jest" + }, + "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@sentry/react": "^8.23.0", + "@sentry/webpack-plugin": "^2.21.1", + "@tanstack/react-query": "^5.51.1", + "@tanstack/react-query-devtools": "^5.51.1", + "dotenv-webpack": "^8.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-router": "^6.24.1", + "react-router-dom": "^6.24.1", + "recoil": "^0.7.7" + }, + "devDependencies": { + "@babel/core": "^7.24.7", + "@babel/preset-env": "^7.24.7", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@emotion/babel-plugin": "^11.11.0", + "@stylelint/postcss-css-in-js": "^0.38.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.12", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "babel-loader": "^9.1.3", + "clean-webpack-plugin": "^4.0.0", + "dotenv": "^16.4.5", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.8", + "history": "^5.3.0", + "html-webpack-plugin": "^5.6.0", + "http-server": "^14.1.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jest-fixed-jsdom": "^0.0.2", + "msw": "2.3.2", + "postcss-syntax": "^0.36.2", + "prettier": "^3.3.2", + "stylelint": "^16.7.0", + "stylelint-config-clean-order": "^6.1.0", + "stylelint-order": "^6.0.4", + "ts-jest": "^29.2.4", + "typescript": "^5.5.3", + "undici": "5.0.0", + "webpack": "^5.92.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.0.4" + }, + "msw": { + "workerDirectory": [ + "public" + ] + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 000000000..ee0cb1c0b --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + REVIEW ME + + + +
+ + + \ No newline at end of file diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..6185b3c32 --- /dev/null +++ b/frontend/public/mockServiceWorker.js @@ -0,0 +1,281 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.3.4'; +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..bb182911e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,30 @@ +import { Outlet } from 'react-router'; + +import { PageLayout, Sidebar, Topbar, SideModal, Footer, Main } from './components'; +import Breadcrumb from './components/common/Breadcrumb'; +import { useSidebar } from './hooks'; +import useBreadcrumbPaths from './hooks/useBreadcrumbPaths'; + +const App = () => { + const { isSidebarHidden, isSidebarModalOpen, closeSidebar, openSidebar } = useSidebar(); + + const breadcrumbPathList = useBreadcrumbPaths(); + + return ( + + {/* {isSidebarModalOpen && ( + + + + )} */} + + {breadcrumbPathList.length > 1 && } +
1)}> + +
+