diff --git a/backend/src/main/java/reviewme/keyword/domain/Keywords.java b/backend/src/main/java/reviewme/keyword/domain/Keywords.java index 4278b9d64..8d8f5e15d 100644 --- a/backend/src/main/java/reviewme/keyword/domain/Keywords.java +++ b/backend/src/main/java/reviewme/keyword/domain/Keywords.java @@ -3,6 +3,7 @@ import jakarta.persistence.CollectionTable; import jakarta.persistence.ElementCollection; import jakarta.persistence.Embeddable; +import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import java.util.Collections; import java.util.List; @@ -21,7 +22,7 @@ public class Keywords { private static final int MAX_KEYWORD_COUNT = 5; - @ElementCollection + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "review_keyword", joinColumns = @JoinColumn(name = "review_id")) private Set keywordIds; diff --git a/backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java b/backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java index 5f93d0c50..7eb185600 100644 --- a/backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java +++ b/backend/src/main/java/reviewme/member/domain/GithubIdReviewerGroup.java @@ -8,15 +8,14 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import java.util.Objects; import lombok.AccessLevel; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Table(name = "github_id_reviewer_group") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode(of = "id") @Getter public class GithubIdReviewerGroup { @@ -35,4 +34,27 @@ public GithubIdReviewerGroup(GithubId githubId, ReviewerGroup reviewerGroup) { this.githubId = githubId; this.reviewerGroup = reviewerGroup; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof GithubIdReviewerGroup githubIdReviewerGroup)) { + return false; + } + if (id == null) { + return Objects.equals(githubId, githubIdReviewerGroup.githubId); + } + + return Objects.equals(id, githubIdReviewerGroup.id); + } + + @Override + public int hashCode() { + if (id == null) { + return Objects.hash(githubId); + } + return Objects.hash(id); + } } diff --git a/backend/src/main/java/reviewme/review/controller/ReviewApi.java b/backend/src/main/java/reviewme/review/controller/ReviewApi.java index 1acfa9bf3..78a35208d 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewApi.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewApi.java @@ -7,8 +7,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; -import reviewme.review.dto.response.ReviewCreationResponse; import reviewme.review.dto.request.CreateReviewRequest; +import reviewme.review.dto.response.ReceivedReviewsResponse; +import reviewme.review.dto.response.ReviewCreationResponse; import reviewme.review.dto.response.ReviewDetailResponse; @Tag(name = "리뷰 관리") @@ -28,7 +29,15 @@ public interface ReviewApi { ResponseEntity findReview(@PathVariable long id, @RequestParam long memberId); @Operation( - summary = "리뷰 생성 시 필요한 정보 조회", + summary = "내가 받은 리뷰 조회", + description = "내가 받은 리뷰를 조회한다. (로그인을 구현하지 않은 지금 시점에서는 memberId로 로그인했다고 가정한다.)" + ) + ResponseEntity findMyReceivedReview(@RequestParam long memberId, + @RequestParam(required = false) Long lastReviewId, + @RequestParam(defaultValue = "10") int size); + + @Operation( + summary = "리뷰 작성 정보 조회", description = "리뷰 생성 시 필요한 정보를 조회한다." ) ResponseEntity findReviewCreationSetup(@RequestParam long reviewerGroupId); diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index 77685df1c..d1b7d0681 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -10,14 +10,15 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import reviewme.review.dto.response.ReviewCreationResponse; import reviewme.review.dto.request.CreateReviewRequest; +import reviewme.review.dto.response.ReceivedReviewsResponse; +import reviewme.review.dto.response.ReviewCreationResponse; import reviewme.review.dto.response.ReviewDetailResponse; import reviewme.review.service.ReviewService; @RestController @RequiredArgsConstructor -public class ReviewController implements ReviewApi{ +public class ReviewController implements ReviewApi { private final ReviewService reviewService; @@ -39,4 +40,12 @@ public ResponseEntity findReviewCreationSetup(@RequestPa ReviewCreationResponse response = reviewService.findReviewCreationSetup(reviewerGroupId); return ResponseEntity.ok(response); } + + @GetMapping("/reviews") + public ResponseEntity findMyReceivedReview(@RequestParam long memberId, + @RequestParam(required = false) Long lastReviewId, + @RequestParam(defaultValue = "10") int size) { + ReceivedReviewsResponse myReceivedReview = reviewService.findMyReceivedReview(memberId, lastReviewId, size); + return ResponseEntity.ok(myReceivedReview); + } } diff --git a/backend/src/main/java/reviewme/review/domain/Review.java b/backend/src/main/java/reviewme/review/domain/Review.java index 5f2fc4422..2eada01ee 100644 --- a/backend/src/main/java/reviewme/review/domain/Review.java +++ b/backend/src/main/java/reviewme/review/domain/Review.java @@ -63,11 +63,11 @@ public Review(Member reviewer, Member reviewee, ReviewerGroup reviewerGroup, } this.reviewer = reviewer; this.reviewee = reviewee; + this.reviewerGroup = reviewerGroup; this.reviewContents = new ArrayList<>(); this.keywords = new Keywords(keywords); this.createdAt = createdAt; reviewerGroup.addReview(this); - this.reviewerGroup = reviewerGroup; this.isPublic = false; } diff --git a/backend/src/main/java/reviewme/review/domain/ReviewContent.java b/backend/src/main/java/reviewme/review/domain/ReviewContent.java index db3fb01c1..4da203237 100644 --- a/backend/src/main/java/reviewme/review/domain/ReviewContent.java +++ b/backend/src/main/java/reviewme/review/domain/ReviewContent.java @@ -7,7 +7,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -22,6 +21,7 @@ public class ReviewContent { private static final int MIN_ANSWER_LENGTH = 20; private static final int MAX_ANSWER_LENGTH = 1_000; + private static final int REVIEW_CONTENT_PREVIEW_MAX_LENGTH = 150; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -31,11 +31,11 @@ public class ReviewContent { @JoinColumn(name = "review_id", nullable = false) private Review review; - @OneToOne + @ManyToOne @JoinColumn(name = "question_id", nullable = false) private Question question; - @Column(name = "answer", nullable = false) + @Column(name = "answer", nullable = false, length = MAX_ANSWER_LENGTH) private String answer; public ReviewContent(Review review, Question question, String answer) { @@ -52,6 +52,10 @@ private void validateAnswerLength(String answer) { } } + public String getAnswerPreview() { + return answer.substring(0, REVIEW_CONTENT_PREVIEW_MAX_LENGTH); + } + public String getQuestion() { return question.getContent(); } diff --git a/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewKeywordsResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewKeywordsResponse.java new file mode 100644 index 000000000..2de98a93b --- /dev/null +++ b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewKeywordsResponse.java @@ -0,0 +1,14 @@ +package reviewme.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "선택된 키워드 응답") +public record ReceivedReviewKeywordsResponse( + + @Schema(description = "키워드 아이디") + long id, + + @Schema(description = "키워드 내용") + String content +) { +} diff --git a/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewResponse.java new file mode 100644 index 000000000..c0fb21339 --- /dev/null +++ b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewResponse.java @@ -0,0 +1,28 @@ +package reviewme.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "리뷰 내용 응답") +public record ReceivedReviewResponse( + + @Schema(description = "리뷰 아이디") + long id, + + @Schema(description = "공개 여부") + boolean isPublic, + + @Schema(description = "리뷰 작성일") + LocalDate createdAt, + + @Schema(description = "응답 내용 미리보기") + String contentPreview, + + @Schema(description = "리뷰어 그룹 정보") + ReceivedReviewReviewerGroupResponse reviewerGroup, + + @Schema(description = "키워드") + List keywords +) { +} diff --git a/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewReviewerGroupResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewReviewerGroupResponse.java new file mode 100644 index 000000000..950d17a5c --- /dev/null +++ b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewReviewerGroupResponse.java @@ -0,0 +1,17 @@ +package reviewme.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "리뷰어 그룹 정보 응답") +public record ReceivedReviewReviewerGroupResponse( + + @Schema(description = "리뷰어 그룹 아이디") + long id, + + @Schema(description = "리뷰어 그룹 이름") + String name, + + @Schema(description = "리뷰어 그룹 썸네일 이미지 URL") + String thumbnailUrl +) { +} diff --git a/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewsResponse.java b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewsResponse.java new file mode 100644 index 000000000..bf62c3f9d --- /dev/null +++ b/backend/src/main/java/reviewme/review/dto/response/ReceivedReviewsResponse.java @@ -0,0 +1,18 @@ +package reviewme.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "내가 받은 리뷰 응답") +public record ReceivedReviewsResponse( + + @Schema(description = "응답 개수") + long size, + + @Schema(description = "마지막 리뷰 아이디") + long lastReviewId, + + @Schema(description = "받은 리뷰 목록") + List reviews +) { +} diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index b9c2a19ab..34be4e8f9 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -1,6 +1,8 @@ package reviewme.review.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.member.domain.Member; import reviewme.member.domain.ReviewerGroup; @@ -15,4 +17,15 @@ public interface ReviewRepository extends JpaRepository { default Review getReviewById(Long id) { return findById(id).orElseThrow(ReviewNotFoundException::new); } + + @Query(""" + SELECT r + FROM Review r + WHERE r.reviewee.id = :revieweeId + AND :lastViewedReviewId IS NULL OR r.id < :lastViewedReviewId + ORDER BY r.createdAt DESC + LIMIT :size + """ + ) + List findLimitedReviewsWrittenForReviewee(long revieweeId, Long lastViewedReviewId, int size); } diff --git a/backend/src/main/java/reviewme/review/service/ReviewService.java b/backend/src/main/java/reviewme/review/service/ReviewService.java index 03fc236c1..0d83485db 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewService.java @@ -12,16 +12,20 @@ import reviewme.keyword.service.KeywordService; import reviewme.member.domain.Member; import reviewme.member.domain.ReviewerGroup; +import reviewme.member.dto.response.ReviewCreationReviewerGroupResponse; import reviewme.member.repository.MemberRepository; import reviewme.member.repository.ReviewerGroupRepository; import reviewme.member.service.ReviewerGroupService; import reviewme.review.domain.Question; import reviewme.review.domain.Review; import reviewme.review.domain.ReviewContent; -import reviewme.review.dto.response.ReviewCreationResponse; -import reviewme.review.dto.response.QuestionResponse; -import reviewme.member.dto.response.ReviewCreationReviewerGroupResponse; import reviewme.review.dto.request.CreateReviewRequest; +import reviewme.review.dto.response.QuestionResponse; +import reviewme.review.dto.response.ReceivedReviewKeywordsResponse; +import reviewme.review.dto.response.ReceivedReviewResponse; +import reviewme.review.dto.response.ReceivedReviewReviewerGroupResponse; +import reviewme.review.dto.response.ReceivedReviewsResponse; +import reviewme.review.dto.response.ReviewCreationResponse; import reviewme.review.dto.response.ReviewDetailResponse; import reviewme.review.dto.response.ReviewDetailReviewContentResponse; import reviewme.review.dto.response.ReviewDetailReviewerGroupResponse; @@ -112,9 +116,57 @@ public ReviewDetailResponse findReview(long reviewId, long memberId) { @Transactional(readOnly = true) public ReviewCreationResponse findReviewCreationSetup(long reviewerGroupId) { - ReviewCreationReviewerGroupResponse reviewerGroup = reviewerGroupService.findReviewCreationReviewerGroup(reviewerGroupId); + ReviewCreationReviewerGroupResponse reviewerGroup = reviewerGroupService.findReviewCreationReviewerGroup( + reviewerGroupId); List questions = questionService.findAllQuestions(); List keywords = keywordService.findAllKeywords(); return new ReviewCreationResponse(reviewerGroup, questions, keywords); } + + @Transactional(readOnly = true) + public ReceivedReviewsResponse findMyReceivedReview(long memberId, Long lastReviewId, int size) { + List reviews = reviewRepository.findLimitedReviewsWrittenForReviewee(memberId, lastReviewId, size); + + if (reviews.isEmpty()) { + return new ReceivedReviewsResponse(0, 0, List.of()); + } + + return new ReceivedReviewsResponse( + reviews.size(), + reviews.get(reviews.size() - 1).getId(), + reviews.stream() + .map(this::createReceivedReviewResponse) + .toList()); + } + + private ReceivedReviewResponse createReceivedReviewResponse(Review review) { + return new ReceivedReviewResponse( + review.getId(), + review.isPublic(), + review.getCreatedAt().toLocalDate(), + createReviewContentPreview(review), + new ReceivedReviewReviewerGroupResponse( + review.getReviewerGroup().getId(), + review.getReviewerGroup().getGroupName(), + review.getReviewerGroup().getThumbnailUrl() + ), + createKeywordResponse(review)); + } + + private String createReviewContentPreview(Review review) { + return reviewContentRepository.findAllByReviewId(review.getId()) + .get(0) + .getAnswerPreview(); + } + + private List createKeywordResponse(Review review) { + return review.getKeywords().getKeywordIds() + .stream() + .map(keywordRepository::getKeywordById) + .map(keyword -> new ReceivedReviewKeywordsResponse( + keyword.getId(), + keyword.getContent() + )) + .toList(); + } } diff --git a/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java index d3e13e216..664e9b430 100644 --- a/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java +++ b/backend/src/test/java/reviewme/review/service/ReviewServiceTest.java @@ -1,9 +1,13 @@ package reviewme.review.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static reviewme.fixture.KeywordFixture.추진력이_좋아요; +import static reviewme.fixture.KeywordFixture.회의를_이끌어요; import static reviewme.fixture.MemberFixture.회원_산초; import static reviewme.fixture.MemberFixture.회원_아루; +import static reviewme.fixture.MemberFixture.회원_커비; +import static reviewme.fixture.MemberFixture.회원_테드; import java.time.LocalDateTime; import java.util.List; @@ -18,8 +22,10 @@ import reviewme.member.repository.ReviewerGroupRepository; import reviewme.review.domain.Question; import reviewme.review.domain.Review; +import reviewme.review.domain.ReviewContent; import reviewme.review.dto.request.CreateReviewContentRequest; import reviewme.review.dto.request.CreateReviewRequest; +import reviewme.review.dto.response.ReceivedReviewsResponse; import reviewme.review.repository.QuestionRepository; import reviewme.review.repository.ReviewContentRepository; import reviewme.review.repository.ReviewRepository; @@ -74,4 +80,66 @@ class ReviewServiceTest { List actual = reviewRepository.findAll(); assertThat(actual).hasSize(1); } + + @Test + void 내가_받은_리뷰를_조회한다() { + // given + Member reviewee = memberRepository.save(회원_아루.create()); + Member reviewerSancho = memberRepository.save(회원_산초.create()); + Member reviewerKirby = memberRepository.save(회원_커비.create()); + Member reviewerTed = memberRepository.save(회원_테드.create()); + Keyword keyword1 = keywordRepository.save(추진력이_좋아요.create()); + Keyword keyword2 = keywordRepository.save(회의를_이끌어요.create()); + ReviewerGroup reviewerGroup = reviewerGroupRepository.save(new ReviewerGroup( + reviewee, + List.of(reviewerSancho.getGithubId(), + reviewerKirby.getGithubId(), + reviewerTed.getGithubId()), + "빼깬드그룹", + "빼깬드그룹 설명", + LocalDateTime.now().plusDays(3) + )); + Question question = questionRepository.save(new Question("질문")); + + Review sanchoReview = reviewRepository.save( + new Review(reviewerSancho, reviewee, reviewerGroup, List.of(keyword1), LocalDateTime.now().minusDays(1)) + ); + Review kirbyReview = reviewRepository.save( + new Review(reviewerKirby, reviewee, reviewerGroup, List.of(keyword2), LocalDateTime.now()) + ); + Review tedReview = reviewRepository.save( + new Review(reviewerTed, reviewee, reviewerGroup, List.of(keyword1, keyword2), + LocalDateTime.now().plusDays(1)) + ); + reviewContentRepository.saveAll(List.of( + new ReviewContent(sanchoReview, question, "산초의 답변".repeat(50)), + new ReviewContent(kirbyReview, question, "커비의 답변".repeat(50)), + new ReviewContent(tedReview, question, "테드의 답변".repeat(50))) + ); + + // when + ReceivedReviewsResponse 가장_최근에_받은_리뷰_조회 + = reviewService.findMyReceivedReview(reviewee.getId(), null, 2); + ReceivedReviewsResponse 특정_리뷰_이전_리뷰_조회 + = reviewService.findMyReceivedReview(reviewee.getId(), 2L, 2); + + // then + assertAll( + () -> assertThat(가장_최근에_받은_리뷰_조회.reviews()) + .hasSize(2), + () -> assertThat(가장_최근에_받은_리뷰_조회.reviews().get(0).id()) + .isEqualTo(tedReview.getId()), + () -> assertThat(가장_최근에_받은_리뷰_조회.reviews().get(1).id()) + .isEqualTo(kirbyReview.getId()), + () -> assertThat(가장_최근에_받은_리뷰_조회.reviews().get(0).contentPreview().length()) + .isLessThanOrEqualTo(150), + + () -> assertThat(특정_리뷰_이전_리뷰_조회.reviews()) + .hasSize(1), + () -> assertThat(특정_리뷰_이전_리뷰_조회.reviews().get(0).id()) + .isEqualTo(sanchoReview.getId()), + () -> assertThat(특정_리뷰_이전_리뷰_조회.reviews().get(0).contentPreview().length()) + .isLessThanOrEqualTo(150) + ); + } }