diff --git a/backend/lombok.config b/backend/lombok.config new file mode 100644 index 00000000..7667c52a --- /dev/null +++ b/backend/lombok.config @@ -0,0 +1,7 @@ +lombok.Setter.flagUsage = error +lombok.toString.flagUsage = error +lombok.builder.flagUsage = error +lombok.value.flagUsage = error +lombok.builder.flagUsage = error +lombok.data.flagUsage = error +lombok.allArgsConstructor.flagUsage = error diff --git a/backend/secrets b/backend/secrets index 63636f7d..0d642198 160000 --- a/backend/secrets +++ b/backend/secrets @@ -1 +1 @@ -Subproject commit 63636f7dedc2a1dacc3233ee3376f94453c85f80 +Subproject commit 0d642198218a0dae8389e5cf8f850347d157379b diff --git a/backend/src/main/java/develup/api/DiscussionApi.java b/backend/src/main/java/develup/api/DiscussionApi.java index 5815e498..e8f5fa6f 100644 --- a/backend/src/main/java/develup/api/DiscussionApi.java +++ b/backend/src/main/java/develup/api/DiscussionApi.java @@ -3,6 +3,9 @@ import java.util.List; import develup.api.auth.Auth; import develup.api.common.ApiResponse; +import develup.api.common.PageResponse; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; import develup.application.auth.Accessor; import develup.application.discussion.CreateDiscussionRequest; import develup.application.discussion.DiscussionReadService; @@ -15,6 +18,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -34,13 +38,24 @@ public class DiscussionApi { @GetMapping("/discussions") @Operation(summary = "디스커션 목록 조회 API", description = "디스커션 목록을 조회합니다.") - public ResponseEntity>> getDiscussions( + public ResponseEntity getDiscussions( @RequestParam(defaultValue = "all") String mission, - @RequestParam(defaultValue = "all") String hashTag + @RequestParam(defaultValue = "all") String hashTag, + @RequestParam(required = false) Integer size, + @RequestParam(required = false) Integer page ) { - List responses = discussionReadService.getSummaries(mission, hashTag); + if (size == null && page == null) { + List response = discussionReadService.getSummaries(mission, hashTag); + return ResponseEntity.ok(new ApiResponse<>(response)); + } - return ResponseEntity.ok(new ApiResponse<>(responses)); + if (size == null || page == null) { + throw new DevelupException(ExceptionType.INVALID_PAGE_REQUEST); + } + + PageResponse> response = discussionReadService + .getSummaries(mission, hashTag, size, page); + return ResponseEntity.ok(response); } @GetMapping("/discussions/{id}") @@ -86,9 +101,35 @@ public ResponseEntity> deleteSolution( @GetMapping("/discussions/mine") @Operation(summary = "나의 디스커션 목록 조회 API", description = "내가 작성한 디스커션 목록을 조회합니다.") - public ResponseEntity>> getMyDiscussions(@Auth Accessor accessor) { - List response = discussionReadService.getDiscussionsByMemberId(accessor.id()); + public ResponseEntity getMyDiscussions( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size, + @Auth Accessor accessor + ) throws MissingServletRequestParameterException { + // TODO : 하위호환 + if (isBothNull(page, size)) { + List response = discussionReadService.getDiscussionsByMemberId(accessor.id()); + return ResponseEntity.ok(new ApiResponse<>(response)); + } - return ResponseEntity.ok(new ApiResponse<>(response)); + requireNonNull(page, size); + + PageResponse> response = + discussionReadService.getDiscussionsByMemberId(accessor.id(), page, size); + return ResponseEntity.ok(response); + } + + private boolean isBothNull(Integer page, Integer size) { + return page == null && size == null; + } + + private void requireNonNull(Integer page, Integer size) throws MissingServletRequestParameterException { + if (page == null) { + throw new MissingServletRequestParameterException("page", "number"); + } + + if (size == null) { + throw new MissingServletRequestParameterException("size", "number"); + } } } diff --git a/backend/src/main/java/develup/api/DiscussionCommentApi.java b/backend/src/main/java/develup/api/DiscussionCommentApi.java index bbc52db5..1c3e1fc9 100644 --- a/backend/src/main/java/develup/api/DiscussionCommentApi.java +++ b/backend/src/main/java/develup/api/DiscussionCommentApi.java @@ -4,6 +4,7 @@ import java.util.List; import develup.api.auth.Auth; import develup.api.common.ApiResponse; +import develup.api.common.PageResponse; import develup.application.auth.Accessor; import develup.application.discussion.comment.CreateDiscussionCommentResponse; import develup.application.discussion.comment.DiscussionCommentReadService; @@ -18,12 +19,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; 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; @RequiredArgsConstructor @@ -46,9 +49,36 @@ public ResponseEntity>> getCo @GetMapping("/discussions/comments/mine") @Operation(summary = "사용자가 디스커션에 단 댓글 조회 API", description = "사용자가 디스커션에 단 댓글 목록을 조회합니다. 댓글 정보와 댓글이 달린 디스커션의 일부 정보를 조회합니다.") - public ResponseEntity>> getMyComments(@Auth Accessor accessor) { - List responses = discussionCommentReadService.getMyComments(accessor.id()); - return ResponseEntity.ok(new ApiResponse<>(responses)); + public ResponseEntity getMyComments( + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size, + @Auth Accessor accessor + ) throws MissingServletRequestParameterException { + // TODO : 하위호환 + if (isBothNull(page, size)) { + List responses = discussionCommentReadService.getMyComments(accessor.id()); + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + + requireNonNull(page, size); + + PageResponse> response = + discussionCommentReadService.getMyComments(accessor.id(), page, size); + return ResponseEntity.ok(response); + } + + private boolean isBothNull(Integer page, Integer size) { + return page == null && size == null; + } + + private void requireNonNull(Integer page, Integer size) throws MissingServletRequestParameterException { + if (page == null) { + throw new MissingServletRequestParameterException("page", "number"); + } + + if (size == null) { + throw new MissingServletRequestParameterException("size", "number"); + } } @PostMapping("/discussions/{discussionId}/comments") diff --git a/backend/src/main/java/develup/api/SolutionApi.java b/backend/src/main/java/develup/api/SolutionApi.java index 700f7962..5dbe5050 100644 --- a/backend/src/main/java/develup/api/SolutionApi.java +++ b/backend/src/main/java/develup/api/SolutionApi.java @@ -3,6 +3,9 @@ import java.util.List; import develup.api.auth.Auth; import develup.api.common.ApiResponse; +import develup.api.common.PageResponse; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; import develup.application.auth.Accessor; import develup.application.solution.MySolutionResponse; import develup.application.solution.SolutionReadService; @@ -17,6 +20,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -80,13 +84,26 @@ public ResponseEntity> deleteSolution( @GetMapping("/solutions") @Operation(summary = "솔루션 목록 조회 API", description = "솔루션 목록을 조회합니다.") - public ResponseEntity>> getSolutions( + public ResponseEntity getSolutions( @RequestParam(defaultValue = "all") String mission, - @RequestParam(defaultValue = "all") String hashTag + @RequestParam(defaultValue = "all") String hashTag, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size ) { - List responses = solutionReadService.getCompletedSummaries(mission, hashTag); - - return ResponseEntity.ok(new ApiResponse<>(responses)); + if (page == null && size == null) { + List responses = solutionReadService.getCompletedSummaries(mission, hashTag); + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + if (page == null || size == null) { + throw new DevelupException(ExceptionType.INVALID_PAGE_REQUEST); + } + PageResponse> responses = solutionReadService.getCompletedSummaries( + mission, + hashTag, + page, + size + ); + return ResponseEntity.ok(responses); } @GetMapping("/solutions/{id}") @@ -104,4 +121,14 @@ public ResponseEntity>> getMySolutions(@Aut return ResponseEntity.ok(new ApiResponse<>(response)); } + + private void requiredNotNull(Integer page, Integer size) throws MissingServletRequestParameterException { + if (page == null) { + throw new MissingServletRequestParameterException("page", "number"); + } + + if (size == null) { + throw new MissingServletRequestParameterException("size", "number"); + } + } } diff --git a/backend/src/main/java/develup/api/SolutionCommentApi.java b/backend/src/main/java/develup/api/SolutionCommentApi.java index 4b1a6a95..614f499a 100644 --- a/backend/src/main/java/develup/api/SolutionCommentApi.java +++ b/backend/src/main/java/develup/api/SolutionCommentApi.java @@ -4,6 +4,7 @@ import java.util.List; import develup.api.auth.Auth; import develup.api.common.ApiResponse; +import develup.api.common.PageResponse; import develup.application.auth.Accessor; import develup.application.solution.comment.CreateSolutionCommentResponse; import develup.application.solution.comment.MySolutionCommentResponse; @@ -17,6 +18,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -24,7 +26,9 @@ 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 org.springframework.web.server.ResponseStatusException; @RequiredArgsConstructor @RestController @@ -46,9 +50,32 @@ public ResponseEntity>> getComm @GetMapping("/solutions/comments/mine") @Operation(summary = "사용자가 솔루션에 단 댓글 조회 API", description = "사용자가 솔루션에 단 댓글 목록을 조회합니다. 댓글 정보와 댓글이 달린 솔루션의 일부 정보를 조회합니다.") - public ResponseEntity>> getMyComments(@Auth Accessor accessor) { - List responses = solutionCommentReadService.getMyComments(accessor.id()); - return ResponseEntity.ok(new ApiResponse<>(responses)); + public ResponseEntity getMyComments( + @Auth Accessor accessor, + @RequestParam(required = false) Integer page, + @RequestParam(required = false) Integer size + ) { + if (isPaginationNotProvided(page, size)) { + List responses = solutionCommentReadService.getMyComments(accessor.id()); + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + if (isPaginationPartiallyProvided(page, size)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + PageResponse> responses = solutionCommentReadService.getMyComments( + accessor.id(), + page, + size + ); + return ResponseEntity.ok(responses); + } + + private boolean isPaginationNotProvided(Integer page, Integer size) { + return page == null && size == null; + } + + private boolean isPaginationPartiallyProvided(Integer page, Integer size) { + return page == null || size == null; } @PostMapping("/solutions/{solutionId}/comments") diff --git a/backend/src/main/java/develup/api/common/PageResponse.java b/backend/src/main/java/develup/api/common/PageResponse.java new file mode 100644 index 00000000..174c674b --- /dev/null +++ b/backend/src/main/java/develup/api/common/PageResponse.java @@ -0,0 +1,4 @@ +package develup.api.common; + +public record PageResponse(T data, int currentPage, int totalPage) { +} diff --git a/backend/src/main/java/develup/api/exception/ExceptionType.java b/backend/src/main/java/develup/api/exception/ExceptionType.java index f32433cc..18bd21ff 100644 --- a/backend/src/main/java/develup/api/exception/ExceptionType.java +++ b/backend/src/main/java/develup/api/exception/ExceptionType.java @@ -33,7 +33,7 @@ public enum ExceptionType { HASHTAG_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 해시태그입니다."), DISCUSSION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 디스커션입니다."), DISCUSSION_NOT_WRITTEN_BY_MEMBER(HttpStatus.FORBIDDEN, "디스커션 작성자가 아닙니다."), - + INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "페이지 요청이 잘못되었습니다."), ; private final HttpStatus status; diff --git a/backend/src/main/java/develup/application/discussion/DiscussionReadService.java b/backend/src/main/java/develup/application/discussion/DiscussionReadService.java index 095d88b3..5fba21f0 100644 --- a/backend/src/main/java/develup/application/discussion/DiscussionReadService.java +++ b/backend/src/main/java/develup/application/discussion/DiscussionReadService.java @@ -1,12 +1,15 @@ package develup.application.discussion; import java.util.List; +import develup.api.common.PageResponse; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; import develup.domain.discussion.Discussion; import develup.domain.discussion.DiscussionRepositoryCustom; import develup.domain.discussion.comment.DiscussionCommentCounts; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +21,7 @@ public class DiscussionReadService { private final DiscussionRepositoryCustom discussionRepositoryCustom; public List getSummaries(String mission, String hashTagName) { - List discussions = discussionRepositoryCustom.findAllByMissionAndHashTagName(mission, hashTagName); + List discussions = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc(mission, hashTagName); DiscussionCommentCounts discussionCommentCounts = new DiscussionCommentCounts( discussionRepositoryCustom.findAllDiscussionCommentCounts() ); @@ -31,6 +34,30 @@ public List getSummaries(String mission, String ha .toList(); } + public PageResponse> getSummaries( + String mission, + String hashTagName, + Integer size, + Integer page + ) { + PageRequest pageRequest = PageRequest.of(page, size); + Page discussions = discussionRepositoryCustom + .findAllByMissionAndHashTagNameOrderByDesc(mission, hashTagName, pageRequest); + + DiscussionCommentCounts discussionCommentCounts = new DiscussionCommentCounts( + discussionRepositoryCustom.findAllDiscussionCommentCounts() + ); + + List responseWithCommentCounts = discussions.stream() + .map(discussion -> SummarizedDiscussionResponse.of( + discussion, + discussionCommentCounts.getCount(discussion)) + ) + .toList(); + + return new PageResponse<>(responseWithCommentCounts, pageRequest.getPageNumber(), discussions.getTotalPages()); + } + public List getDiscussionsByMemberId(Long memberId) { List myDiscussions = discussionRepositoryCustom.findAllByMemberIdOrderByDesc(memberId); DiscussionCommentCounts discussionCommentCounts = new DiscussionCommentCounts( @@ -45,6 +72,27 @@ public List getDiscussionsByMemberId(Long memberId .toList(); } + public PageResponse> getDiscussionsByMemberId( + Long memberId, + Integer page, + Integer size + ) { + PageRequest pageRequest = PageRequest.of(page, size); + Page myDiscussions = discussionRepositoryCustom.findPageByMemberIdOrderByDesc(memberId, pageRequest); + DiscussionCommentCounts discussionCommentCounts = new DiscussionCommentCounts( + discussionRepositoryCustom.findAllDiscussionCommentCounts() + ); + + List countIncludeData = myDiscussions.getContent().stream() + .map(discussion -> SummarizedDiscussionResponse.of( + discussion, + discussionCommentCounts.getCount(discussion)) + ) + .toList(); + + return new PageResponse<>(countIncludeData, pageRequest.getPageNumber(), myDiscussions.getTotalPages()); + } + public DiscussionResponse getById(Long id) { Discussion discussion = getDiscussion(id); diff --git a/backend/src/main/java/develup/application/discussion/comment/DiscussionCommentReadService.java b/backend/src/main/java/develup/application/discussion/comment/DiscussionCommentReadService.java index a6fd7780..726f989f 100644 --- a/backend/src/main/java/develup/application/discussion/comment/DiscussionCommentReadService.java +++ b/backend/src/main/java/develup/application/discussion/comment/DiscussionCommentReadService.java @@ -1,6 +1,7 @@ package develup.application.discussion.comment; import java.util.List; +import develup.api.common.PageResponse; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; import develup.domain.discussion.comment.DiscussionComment; @@ -8,6 +9,8 @@ import develup.domain.discussion.comment.DiscussionCommentRepositoryCustom; import develup.domain.discussion.comment.MyDiscussionComment; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,4 +47,14 @@ public List getMyComments(Long memberId) { .map(MyDiscussionCommentResponse::from) .toList(); } + + public PageResponse> getMyComments(Long memberId, Integer page, Integer size) { + PageRequest pageRequest = PageRequest.of(page, size); + Page mySolutionComments = discussionCommentRepositoryCustom.findPageMyDiscussionCommentOrderByCreatedAtDesc(memberId, pageRequest); + List data = mySolutionComments.getContent().stream() + .map(MyDiscussionCommentResponse::from) + .toList(); + + return new PageResponse<>(data, pageRequest.getPageNumber(), mySolutionComments.getTotalPages()); + } } diff --git a/backend/src/main/java/develup/application/solution/SolutionReadService.java b/backend/src/main/java/develup/application/solution/SolutionReadService.java index ca7cdafb..944c90fc 100644 --- a/backend/src/main/java/develup/application/solution/SolutionReadService.java +++ b/backend/src/main/java/develup/application/solution/SolutionReadService.java @@ -1,6 +1,7 @@ package develup.application.solution; import java.util.List; +import develup.api.common.PageResponse; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; import develup.domain.solution.Solution; @@ -8,6 +9,8 @@ import develup.domain.solution.SolutionRepositoryCustom; import develup.domain.solution.SolutionStatus; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,12 +36,38 @@ public List getSubmittedSolutionsByMemberId(Long memberId) { .toList(); } + public PageResponse> getSubmittedSolutionsByMemberId(Long memberId, Integer page, Integer size) { + PageRequest pageRequest = PageRequest.of(page, size); + Page mySolutions = solutionRepositoryCustom.findPageByMemberIdOrderByDesc(memberId, pageRequest); + + List responses = mySolutions.getContent().stream() + .map(MySolutionResponse::from) + .toList(); + + return new PageResponse<>(responses, pageRequest.getPageNumber(), mySolutions.getTotalPages()); + } + public List getCompletedSummaries(String missionTitle, String hashTagName) { return solutionRepositoryCustom.findAllCompletedSolutionByHashTagName(missionTitle, hashTagName).stream() .map(SummarizedSolutionResponse::from) .toList(); } + public PageResponse> getCompletedSummaries( + String missionTitle, + String hashTagName, + int page, + int size + ) { + Page pageResponse = solutionRepositoryCustom.findAllCompletedSolutionByHashTagName( + missionTitle, + hashTagName, + PageRequest.of(page, size) + ) + .map(SummarizedSolutionResponse::from); + return new PageResponse<>(pageResponse.toList(), page, pageResponse.getTotalPages()); + } + public Solution getSolution(Long solutionId) { return solutionRepository.findById(solutionId) .orElseThrow(() -> new DevelupException(ExceptionType.SOLUTION_NOT_FOUND)); diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentReadService.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentReadService.java index 05b23c5f..58e3252a 100644 --- a/backend/src/main/java/develup/application/solution/comment/SolutionCommentReadService.java +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentReadService.java @@ -1,6 +1,7 @@ package develup.application.solution.comment; import java.util.List; +import develup.api.common.PageResponse; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; import develup.domain.solution.comment.MySolutionComment; @@ -8,6 +9,9 @@ import develup.domain.solution.comment.SolutionCommentRepository; import develup.domain.solution.comment.SolutionCommentRepositoryCustom; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,4 +50,12 @@ public List getMyComments(Long memberId) { .map(MySolutionCommentResponse::from) .toList(); } + + public PageResponse> getMyComments(Long memberId, Integer page, Integer size) { + Pageable pageable = PageRequest.of(page, size); + Page mySolutionComments = solutionCommentRepositoryCustom + .findAllMySolutionCommentOrderByDesc(memberId, pageable) + .map(MySolutionCommentResponse::from); + return new PageResponse<>(mySolutionComments.getContent(), page, mySolutionComments.getTotalPages()); + } } diff --git a/backend/src/main/java/develup/domain/IdentifiableEntity.java b/backend/src/main/java/develup/domain/IdentifiableEntity.java index 775d00d1..b8bb3628 100644 --- a/backend/src/main/java/develup/domain/IdentifiableEntity.java +++ b/backend/src/main/java/develup/domain/IdentifiableEntity.java @@ -6,14 +6,12 @@ import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.Hibernate; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PROTECTED) @MappedSuperclass public abstract class IdentifiableEntity { @@ -21,6 +19,10 @@ public abstract class IdentifiableEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) protected Long id; + protected IdentifiableEntity(Long id) { + this.id = id; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/backend/src/main/java/develup/domain/discussion/DiscussionHashTag.java b/backend/src/main/java/develup/domain/discussion/DiscussionHashTag.java index 01b0e6af..af346f3a 100644 --- a/backend/src/main/java/develup/domain/discussion/DiscussionHashTag.java +++ b/backend/src/main/java/develup/domain/discussion/DiscussionHashTag.java @@ -9,14 +9,12 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.Hibernate; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class DiscussionHashTag { @@ -37,6 +35,12 @@ public DiscussionHashTag(Discussion discussion, HashTag hashTag) { this(new DiscussionHashTagId(discussion.getId(), hashTag.getId()), discussion, hashTag); } + public DiscussionHashTag(DiscussionHashTagId id, Discussion discussion, HashTag hashTag) { + this.id = id; + this.discussion = discussion; + this.hashTag = hashTag; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/backend/src/main/java/develup/domain/discussion/DiscussionHashTagId.java b/backend/src/main/java/develup/domain/discussion/DiscussionHashTagId.java index 7578992c..bd3a56b7 100644 --- a/backend/src/main/java/develup/domain/discussion/DiscussionHashTagId.java +++ b/backend/src/main/java/develup/domain/discussion/DiscussionHashTagId.java @@ -5,11 +5,9 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Embeddable public class DiscussionHashTagId implements Serializable { @@ -19,6 +17,11 @@ public class DiscussionHashTagId implements Serializable { @Column(nullable = false) private Long hashTagId; + public DiscussionHashTagId(Long discussionId, Long hashTagId) { + this.discussionId = discussionId; + this.hashTagId = hashTagId; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/backend/src/main/java/develup/domain/discussion/DiscussionRepositoryCustom.java b/backend/src/main/java/develup/domain/discussion/DiscussionRepositoryCustom.java index 52e2b634..24ae3cbf 100644 --- a/backend/src/main/java/develup/domain/discussion/DiscussionRepositoryCustom.java +++ b/backend/src/main/java/develup/domain/discussion/DiscussionRepositoryCustom.java @@ -12,10 +12,16 @@ import java.util.Optional; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import develup.domain.discussion.comment.DiscussionCommentCount; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; @Repository @@ -25,7 +31,53 @@ public class DiscussionRepositoryCustom { private final JPAQueryFactory queryFactory; private final EntityManager entityManager; - public List findAllByMissionAndHashTagName(String missionTitle, String hashTagName) { + public Page findAllByMissionAndHashTagNameOrderByDesc(String missionTitle, String hashTagName, PageRequest pageRequest) { + long offset = pageRequest.getOffset(); + int limit = pageRequest.getPageSize(); + + JPAQuery countQuery = createCountQuery(missionTitle, hashTagName); + List discussionIds = getDiscussionIds(missionTitle, hashTagName, offset, limit); + List data = fetchDiscussionsByIds(discussionIds); + + return PageableExecutionUtils.getPage(data, pageRequest, countQuery::fetchOne); + } + + private JPAQuery createCountQuery(String missionTitle, String hashTagName) { + return queryFactory.select(discussion.count()) + .from(discussion) + .where(filterByMissionName(missionTitle), filterByHashTagName(hashTagName)); + } + + private List getDiscussionIds(String missionTitle, String hashTagName, long offset, int limit) { + return queryFactory + .select(discussion.id) + .from(discussion).distinct() + .innerJoin(discussion.member, member) + .leftJoin(discussion.mission, mission) + .leftJoin(mission.missionHashTags.hashTags, missionHashTag) + .leftJoin(discussion.discussionHashTags.hashTags, discussionHashTag) + .leftJoin(discussionHashTag.hashTag, hashTag) + .where(filterByMissionName(missionTitle), filterByHashTagName(hashTagName)) + .orderBy(discussion.id.desc()) + .offset(offset) + .limit(limit) + .fetch(); + } + + private List fetchDiscussionsByIds(List discussionIds) { + return queryFactory + .selectFrom(discussion) + .innerJoin(discussion.member, member).fetchJoin() + .leftJoin(discussion.mission, mission).fetchJoin() + .leftJoin(mission.missionHashTags.hashTags, missionHashTag).fetchJoin() + .leftJoin(discussion.discussionHashTags.hashTags, discussionHashTag).fetchJoin() + .leftJoin(discussionHashTag.hashTag, hashTag).fetchJoin() + .where(discussion.id.in(discussionIds)) + .orderBy(discussion.id.desc()) + .fetch(); + } + + public List findAllByMissionAndHashTagNameOrderByDesc(String missionTitle, String hashTagName) { return queryFactory .selectFrom(discussion) .innerJoin(discussion.member, member).fetchJoin() @@ -50,8 +102,14 @@ private BooleanExpression filterByHashTagName(String hashTagName) { return null; } - return discussionHashTag.hashTag.name.eq(hashTagName) - .and(discussionHashTag.discussion.id.eq(discussion.id)); + return JPAExpressions.selectOne() + .from(discussionHashTag) + .join(discussionHashTag.hashTag) + .where( + discussionHashTag.discussion.id.eq(discussion.id), + discussionHashTag.hashTag.name.eq(hashTagName) + ) + .exists(); } public Optional findFetchById(Long id) { @@ -79,6 +137,35 @@ public List findAllByMemberIdOrderByDesc(Long memberId) { .fetch(); } + public Page findPageByMemberIdOrderByDesc(Long memberId, Pageable pageRequest) { + long offset = pageRequest.getOffset(); + int limit = pageRequest.getPageSize(); + JPAQuery countQuery = getMemberDiscussionsCountQuery(memberId); + List data = fetchMemberDiscussions(memberId, offset, limit); + + return PageableExecutionUtils.getPage(data, pageRequest, countQuery::fetchOne); + } + + private JPAQuery getMemberDiscussionsCountQuery(Long memberId) { + return queryFactory.select(discussion.count()) + .from(discussion) + .where(discussion.member.id.eq(memberId)); + } + + private List fetchMemberDiscussions(Long memberId, Long offset, Integer limit) { + return queryFactory.select(discussion).distinct() + .from(discussion) + .innerJoin(discussion.member, member).fetchJoin() + .leftJoin(discussion.mission, mission).fetchJoin() + .leftJoin(discussion.discussionHashTags.hashTags, discussionHashTag) + .leftJoin(discussionHashTag.hashTag, hashTag) + .where(member.id.eq(memberId)) + .orderBy(discussion.id.desc()) + .offset(offset) + .limit(limit) + .fetch(); + } + public List findAllDiscussionCommentCounts() { return queryFactory .select(Projections.constructor(DiscussionCommentCount.class, diff --git a/backend/src/main/java/develup/domain/discussion/comment/DiscussionComment.java b/backend/src/main/java/develup/domain/discussion/comment/DiscussionComment.java index 4c4d4d35..f04d4931 100644 --- a/backend/src/main/java/develup/domain/discussion/comment/DiscussionComment.java +++ b/backend/src/main/java/develup/domain/discussion/comment/DiscussionComment.java @@ -12,13 +12,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class DiscussionComment extends CreatedAtAuditableEntity { @@ -39,6 +37,16 @@ public class DiscussionComment extends CreatedAtAuditableEntity { @Column private LocalDateTime deletedAt; + public DiscussionComment( + String content, + Discussion discussion, + Member member, + Long parentCommentId, + LocalDateTime deletedAt + ) { + this(null, content, discussion, member, parentCommentId, deletedAt); + } + public DiscussionComment( Long id, String content, diff --git a/backend/src/main/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustom.java b/backend/src/main/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustom.java index 0bdcaa8a..dbac94c5 100644 --- a/backend/src/main/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustom.java +++ b/backend/src/main/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustom.java @@ -6,8 +6,12 @@ import java.util.List; import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; @Repository @@ -39,4 +43,37 @@ public List findAllMyDiscussionCommentOrderByCreatedAtDesc( .orderBy(discussionComment.createdAt.desc()) .fetch(); } + + public Page findPageMyDiscussionCommentOrderByCreatedAtDesc(Long memberId, Pageable pageRequest) { + long offset = pageRequest.getOffset(); + int limit = pageRequest.getPageSize(); + JPAQuery countQuery = getMemberDiscussionCommentsCountQuery(memberId); + List data = fetchMemberDiscussionComments(memberId, offset, limit); + + return PageableExecutionUtils.getPage(data, pageRequest, countQuery::fetchOne); + } + + private JPAQuery getMemberDiscussionCommentsCountQuery(Long memberId) { + return queryFactory.select(discussionComment.count()) + .from(discussionComment) + .where(discussionComment.member.id.eq(memberId).and(discussionComment.deletedAt.isNull())); + } + + private List fetchMemberDiscussionComments(Long memberId, Long offset, Integer limit) { + return queryFactory.select(Projections.constructor(MyDiscussionComment.class, + discussionComment.id, + discussionComment.discussion.id, + discussionComment.content, + discussionComment.createdAt, + discussion.title.value + )) + .from(discussionComment) + .join(discussionComment.discussion, discussion) + .join(discussionComment.member) + .where(discussionComment.member.id.eq(memberId).and(discussionComment.deletedAt.isNull())) + .orderBy(discussionComment.createdAt.desc()) + .offset(offset) + .limit(limit) + .fetch(); + } } diff --git a/backend/src/main/java/develup/domain/hashtag/HashTag.java b/backend/src/main/java/develup/domain/hashtag/HashTag.java index 77036739..c344fcdd 100644 --- a/backend/src/main/java/develup/domain/hashtag/HashTag.java +++ b/backend/src/main/java/develup/domain/hashtag/HashTag.java @@ -4,13 +4,11 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class HashTag extends IdentifiableEntity { diff --git a/backend/src/main/java/develup/domain/member/Member.java b/backend/src/main/java/develup/domain/member/Member.java index 8f64416f..f436fac8 100644 --- a/backend/src/main/java/develup/domain/member/Member.java +++ b/backend/src/main/java/develup/domain/member/Member.java @@ -6,13 +6,11 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class Member extends CreatedAtAuditableEntity { @@ -32,6 +30,10 @@ public class Member extends CreatedAtAuditableEntity { @Column(nullable = false) private String imageUrl; + public Member(String email, OAuthProvider provider, Long socialId, String name, String imageUrl) { + this(null, email, provider, socialId, name, imageUrl); + } + public Member(Long id, String email, OAuthProvider provider, Long socialId, String name, String imageUrl) { super(id); this.id = id; diff --git a/backend/src/main/java/develup/domain/mission/MissionHashTag.java b/backend/src/main/java/develup/domain/mission/MissionHashTag.java index f9824944..31c8d1a3 100644 --- a/backend/src/main/java/develup/domain/mission/MissionHashTag.java +++ b/backend/src/main/java/develup/domain/mission/MissionHashTag.java @@ -7,13 +7,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class MissionHashTag extends IdentifiableEntity { @@ -25,6 +23,10 @@ public class MissionHashTag extends IdentifiableEntity { @JoinColumn(nullable = false) private HashTag hashTag; + public MissionHashTag(Mission mission, HashTag hashTag) { + this(null, mission, hashTag); + } + public MissionHashTag(Long id, Mission mission, HashTag hashTag) { super(id); this.mission = mission; diff --git a/backend/src/main/java/develup/domain/mission/MissionUrl.java b/backend/src/main/java/develup/domain/mission/MissionUrl.java index 1eba270d..b0ed1777 100644 --- a/backend/src/main/java/develup/domain/mission/MissionUrl.java +++ b/backend/src/main/java/develup/domain/mission/MissionUrl.java @@ -3,14 +3,12 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @EqualsAndHashCode @Embeddable public class MissionUrl { @@ -21,6 +19,10 @@ public class MissionUrl { @Column(name = "url", nullable = false) private String value; + public MissionUrl(String value) { + this.value = value; + } + public boolean isValidPullRequestUrl(String pullRequestUrl) { return pullRequestUrl.startsWith(value + "/"); } diff --git a/backend/src/main/java/develup/domain/solution/Solution.java b/backend/src/main/java/develup/domain/solution/Solution.java index 22ac4ec7..eb382d41 100644 --- a/backend/src/main/java/develup/domain/solution/Solution.java +++ b/backend/src/main/java/develup/domain/solution/Solution.java @@ -17,13 +17,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class Solution extends CreatedAtAuditableEntity { @@ -50,6 +48,18 @@ public class Solution extends CreatedAtAuditableEntity { private LocalDateTime submittedAt; + public Solution( + Mission mission, + Member member, + SolutionTitle title, + String description, + PullRequestUrl pullRequestUrl, + SolutionStatus status, + LocalDateTime submittedAt + ) { + this(null, mission, member, title, description, pullRequestUrl, status, submittedAt); + } + public Solution( Long id, Mission mission, diff --git a/backend/src/main/java/develup/domain/solution/SolutionRepositoryCustom.java b/backend/src/main/java/develup/domain/solution/SolutionRepositoryCustom.java index 5de994bd..909b233b 100644 --- a/backend/src/main/java/develup/domain/solution/SolutionRepositoryCustom.java +++ b/backend/src/main/java/develup/domain/solution/SolutionRepositoryCustom.java @@ -6,14 +6,22 @@ import static develup.domain.solution.QSolution.solution; import static develup.domain.solution.comment.QSolutionComment.solutionComment; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import com.querydsl.core.Tuple; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import develup.domain.solution.comment.SolutionCommentCount; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; @Repository @@ -23,12 +31,66 @@ public class SolutionRepositoryCustom { private final JPAQueryFactory queryFactory; private final EntityManager entityManager; + public Page findAllCompletedSolutionByHashTagName( + String missionTitle, + String hashTagName, + PageRequest pageRequest + ) { + Long totalCount = queryFactory.select(solution.countDistinct()) + .from(solution) + .join(solution.mission, mission) + .join(mission.missionHashTags.hashTags, missionHashTag) + .join(missionHashTag.hashTag) + .where( + eqCompleted(), + eqMissionTitle(missionTitle), + eqHashTagName(hashTagName) + ) + .fetchOne(); + + List tuples = queryFactory.select(solution.id, solution.submittedAt) + .from(solution) + .join(solution.mission, mission) + .join(mission.missionHashTags.hashTags, missionHashTag) + .join(missionHashTag.hashTag) + .where( + eqCompleted(), + eqMissionTitle(missionTitle), + eqHashTagName(hashTagName) + ) + .offset(pageRequest.getOffset()) + .limit(pageRequest.getPageSize()) + .orderBy(solution.submittedAt.desc()) + .distinct() + .fetch(); + + List ids = new ArrayList<>(tuples.size()); + for (Tuple tuple : tuples) { + ids.add(tuple.get(0, Long.class)); + } + + List data = queryFactory.selectFrom(solution) + .from(solution).fetchJoin() + .join(solution.mission, mission).fetchJoin() + .join(mission.missionHashTags.hashTags, missionHashTag).fetchJoin() + .join(missionHashTag.hashTag).fetchJoin() + .where(solution.id.in(ids)) + .orderBy(solution.submittedAt.desc()) + .fetch(); + + return new PageImpl<>(data, pageRequest, totalCount); + } + public List findAllCompletedSolutionByHashTagName(String missionTitle, String hashTagName) { return queryFactory.selectFrom(solution) .join(solution.mission, mission).fetchJoin() .join(mission.missionHashTags.hashTags, missionHashTag).fetchJoin() .join(missionHashTag.hashTag).fetchJoin() - .where(eqCompleted(), eqMissionTitle(missionTitle), eqHashTagName(hashTagName)) + .where( + eqCompleted(), + eqMissionTitle(missionTitle), + eqHashTagName(hashTagName) + ) .orderBy(solution.submittedAt.desc()) .fetch(); } @@ -50,7 +112,14 @@ private BooleanExpression eqHashTagName(String hashTagName) { return null; } - return missionHashTag.hashTag.name.eq(hashTagName); + return JPAExpressions.selectOne() + .from(missionHashTag) + .join(missionHashTag.hashTag) + .where( + missionHashTag.mission.id.eq(mission.id), + missionHashTag.hashTag.name.eq(hashTagName) + ) + .exists(); } public Optional findFetchById(Long solutionId) { @@ -85,4 +154,33 @@ public List findAllSolutionCommentCounts() { .groupBy(solution.id) .fetch(); } + + public Page findPageByMemberIdOrderByDesc(Long memberId, PageRequest pageRequest) { + long offset = pageRequest.getOffset(); + int limit = pageRequest.getPageSize(); + JPAQuery countQuery = getMemberSolutionCountQuery(memberId); + List data = fetchMemberSolutions(memberId, offset, limit); + + return PageableExecutionUtils.getPage(data, pageRequest, countQuery::fetchOne); + } + + private JPAQuery getMemberSolutionCountQuery(Long memberId) { + return queryFactory.select(solution.count()) + .from(solution) + .where(solution.member.id.eq(memberId)); + } + + private List fetchMemberSolutions(Long memberId, long offset, int limit) { + return queryFactory.select(solution).distinct() + .from(solution) + .innerJoin(solution.member, member).fetchJoin() + .join(solution.mission, mission).fetchJoin() + .join(mission.missionHashTags.hashTags, missionHashTag).fetchJoin() + .join(missionHashTag.hashTag).fetchJoin() + .where(member.id.eq(memberId)) + .orderBy(solution.submittedAt.desc()) + .offset(offset) + .limit(limit) + .fetch(); + } } diff --git a/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java b/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java index b88ea81c..26099c96 100644 --- a/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java +++ b/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java @@ -12,13 +12,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Entity public class SolutionComment extends CreatedAtAuditableEntity { @@ -39,6 +37,16 @@ public class SolutionComment extends CreatedAtAuditableEntity { @Column private LocalDateTime deletedAt; + public SolutionComment( + String content, + Solution solution, + Member member, + Long parentCommentId, + LocalDateTime deletedAt + ) { + this(null, content, solution, member, parentCommentId, deletedAt); + } + public SolutionComment( Long id, String content, diff --git a/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepositoryCustom.java b/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepositoryCustom.java index 745c132f..48201adb 100644 --- a/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepositoryCustom.java +++ b/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepositoryCustom.java @@ -5,8 +5,12 @@ import java.util.List; import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; @Repository @@ -39,6 +43,31 @@ public List findAllMySolutionCommentOrderByDesc(Long memberId .where(solutionComment.member.id.eq(memberId).and(solutionComment.deletedAt.isNull())) .orderBy(solutionComment.createdAt.desc()) .fetch(); + } + + public Page findAllMySolutionCommentOrderByDesc(Long memberId, Pageable pageable) { + List mySolutionComments = queryFactory + .select(Projections.constructor(MySolutionComment.class, + solutionComment.id, + solutionComment.solution.id, + solutionComment.content, + solutionComment.createdAt, + solutionComment.solution.title.value + )) + .from(solutionComment) + .join(solutionComment.solution) + .join(solutionComment.member) + .where(solutionComment.member.id.eq(memberId).and(solutionComment.deletedAt.isNull())) + .orderBy(solutionComment.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + JPAQuery count = queryFactory + .select(solutionComment.count()) + .from(solutionComment) + .where(solutionComment.member.id.eq(memberId).and(solutionComment.deletedAt.isNull())); + return PageableExecutionUtils.getPage(mySolutionComments, pageable, count::fetchOne); } } diff --git a/backend/src/main/java/develup/infra/auth/oauth/github/GithubOAuthStrategy.java b/backend/src/main/java/develup/infra/auth/oauth/github/GithubOAuthStrategy.java index 300a2b41..7dc09b8a 100644 --- a/backend/src/main/java/develup/infra/auth/oauth/github/GithubOAuthStrategy.java +++ b/backend/src/main/java/develup/infra/auth/oauth/github/GithubOAuthStrategy.java @@ -26,7 +26,6 @@ public String buildOAuthLoginUrl(String next) { return UriComponentsBuilder.fromHttpUrl("https://github.com/login/oauth/authorize") .queryParam("client_id", properties.clientId()) .queryParam("redirect_uri", redirectUriWithNext) - .queryParam("scope", "user:email") .build() .toUriString(); } diff --git a/backend/src/main/java/develup/infra/auth/oauth/github/dto/GithubUserInfoResponse.java b/backend/src/main/java/develup/infra/auth/oauth/github/dto/GithubUserInfoResponse.java index 8f5c9740..b913c31c 100644 --- a/backend/src/main/java/develup/infra/auth/oauth/github/dto/GithubUserInfoResponse.java +++ b/backend/src/main/java/develup/infra/auth/oauth/github/dto/GithubUserInfoResponse.java @@ -19,7 +19,7 @@ public OAuthUserInfo toOAuthUserInfo() { id, login, avatarUrl, - email, + null, name ); } diff --git a/backend/src/main/resources/db/migration/V4__add_solution_submitted_at_index.sql b/backend/src/main/resources/db/migration/V4__add_solution_submitted_at_index.sql new file mode 100644 index 00000000..7b1cb148 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__add_solution_submitted_at_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_solution_submitted_at ON solution(submitted_at); diff --git a/backend/src/main/resources/db/migration/V5__add_mission_id_index.sql b/backend/src/main/resources/db/migration/V5__add_mission_id_index.sql new file mode 100644 index 00000000..c8554818 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__add_mission_id_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_mission_id ON mission_hash_tag(mission_id); diff --git a/backend/src/test/java/develup/application/solution/SolutionReadServiceTest.java b/backend/src/test/java/develup/application/solution/SolutionReadServiceTest.java index d3ff7934..3daa7f4d 100644 --- a/backend/src/test/java/develup/application/solution/SolutionReadServiceTest.java +++ b/backend/src/test/java/develup/application/solution/SolutionReadServiceTest.java @@ -5,7 +5,11 @@ import static org.junit.jupiter.api.Assertions.assertAll; import java.util.List; +import java.util.stream.IntStream; +import develup.api.common.PageResponse; import develup.api.exception.DevelupException; +import develup.domain.hashtag.HashTag; +import develup.domain.hashtag.HashTagRepository; import develup.domain.member.Member; import develup.domain.member.MemberRepository; import develup.domain.mission.Mission; @@ -14,6 +18,7 @@ import develup.domain.solution.SolutionRepository; import develup.domain.solution.SolutionStatus; import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; @@ -35,6 +40,9 @@ class SolutionReadServiceTest extends IntegrationTestSupport { @Autowired private MissionRepository missionRepository; + @Autowired + private HashTagRepository hashTagRepository; + @Test @DisplayName("존재하지 않는 솔루션은 불러올 수 없다.") void getById() { @@ -100,4 +108,42 @@ void shouldNotRetrieveSolutionsThatAreNotCompleted() { assertThat(solutionReadService.getSubmittedSolutionsByMemberId(member.getId())).hasSize(1); } + + @Test + @DisplayName("솔루션 목록 조회 시 페이지네이션을 적용할 수 있다.") + void getSolutionListWithPage() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + HashTag hashTag = HashTagTestData.defaultHashTag().withName("JAVA").build(); + hashTag = hashTagRepository.save(hashTag); + Mission mission = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build()); + Solution inProgress = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.IN_PROGRESS) + .build(); + + List solutions = IntStream.range(0, 11).mapToObj(index -> { + Solution completed = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withTitle(String.valueOf(index)) + .withStatus(SolutionStatus.COMPLETED) + .build(); + return solutionRepository.save(completed); + }).toList(); + + PageResponse> response = solutionReadService.getCompletedSummaries( + "all", + "all", + 0, + 5 + ); + + List data = response.data(); + assertAll( + () -> assertThat(data.stream().map(SummarizedSolutionResponse::title)).containsExactly("10", "9", "8", "7", "6"), + () -> assertThat(response.totalPage()).isEqualTo(3), + () -> assertThat(response.currentPage()).isEqualTo(0) + ); + } } diff --git a/backend/src/test/java/develup/domain/discussion/DiscussionRepositoryCustomTest.java b/backend/src/test/java/develup/domain/discussion/DiscussionRepositoryCustomTest.java index 9299054c..9a7eb9d6 100644 --- a/backend/src/test/java/develup/domain/discussion/DiscussionRepositoryCustomTest.java +++ b/backend/src/test/java/develup/domain/discussion/DiscussionRepositoryCustomTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.function.Function; @@ -21,12 +22,15 @@ import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.transaction.annotation.Transactional; -public class DiscussionRepositoryCustomTest extends IntegrationTestSupport { +class DiscussionRepositoryCustomTest extends IntegrationTestSupport { @Autowired private DiscussionRepository discussionRepository; @@ -46,6 +50,36 @@ public class DiscussionRepositoryCustomTest extends IntegrationTestSupport { @Autowired private HashTagRepository hashTagRepository; + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("디스커션 목록 조회 시 연관관계가 모두 조회된다.") + @Transactional + void findAllDiscussionWithRelations() { + HashTag java = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); + HashTag oop = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("OOP").build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + + createDiscussion(mission, List.of(java, oop)); + createDiscussion(mission, List.of(oop, java)); + createDiscussion(null, List.of(oop)); + createDiscussion(mission, Collections.emptyList()); + createDiscussion(null, List.of(java)); + + List actual = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc( + "all", + "JAVA" + ); + + assertAll( + () -> assertThat(actual).hasSize(3), + () -> assertThat(actual.get(2).getHashTags()).containsExactly(java, oop), + () -> assertThat(actual.get(1).getHashTags()).containsExactly(oop, java), + () -> assertThat(actual.get(0).getHashTags()).containsExactly(java) + ); + } + @Test @DisplayName("디스커션 목록을 조회할 수 있다.") @Transactional @@ -59,7 +93,7 @@ void findAllDiscussion() { createDiscussion(mission, Collections.emptyList()); createDiscussion(null, Collections.emptyList()); - List actual = discussionRepositoryCustom.findAllByMissionAndHashTagName( + List actual = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc( "all", "all" ); @@ -77,7 +111,7 @@ void findAllDiscussionByHashTag() { createDiscussion(mission, List.of(hashTag1)); createDiscussion(mission, List.of(hashTag2)); - List discussions = discussionRepositoryCustom.findAllByMissionAndHashTagName( + List discussions = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc( "all", "JAVA" ); @@ -99,7 +133,7 @@ void findAllDiscussionByMission() { createDiscussion(mission1, List.of(hashTag)); createDiscussion(mission2, List.of(hashTag)); - List discussions = discussionRepositoryCustom.findAllByMissionAndHashTagName( + List discussions = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc( "루터회관 흡연단속", "all" ); @@ -109,6 +143,55 @@ void findAllDiscussionByMission() { .contains(mission1); } + @Test + @DisplayName("페이지네이션을 통해서 디스커션들을 조회할 수 있다.") + @Transactional + void findAllDiscussionWithPagination() { + HashTag hashTag1 = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); + HashTag hashTag2 = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("객체지향").build()); + HashTag hashTag3 = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("스프링").build()); + + Mission mission1 = missionRepository.save(MissionTestData.defaultMission() + .withTitle("미션 1") + .withHashTags(List.of(hashTag1, hashTag2)).build()); + Mission mission2 = missionRepository.save(MissionTestData.defaultMission() + .withTitle("미션 2") + .withHashTags(List.of(hashTag1, hashTag2, hashTag3)).build()); + Mission mission3 = missionRepository.save(MissionTestData.defaultMission() + .withTitle("미션 3") + .withHashTags(List.of(hashTag2, hashTag3)).build()); + + Discussion discussion1 = createDiscussion(mission1, List.of(hashTag1, hashTag2)); + createDiscussion(mission2, List.of(hashTag1, hashTag2, hashTag3)); + createDiscussion(mission3, List.of(hashTag2, hashTag3)); + Discussion discussion2 = createDiscussion(mission1, List.of(hashTag1, hashTag2)); + createDiscussion(mission2, List.of(hashTag3)); + createDiscussion(mission3, List.of(hashTag2, hashTag3)); + Discussion discussion3 = createDiscussion(mission1, List.of(hashTag1, hashTag2)); + createDiscussion(mission2, List.of(hashTag1, hashTag2, hashTag3)); + createDiscussion(mission3, List.of(hashTag1, hashTag3)); + createDiscussion(mission1, List.of(hashTag1, hashTag3)); + Discussion discussion4 = createDiscussion(mission1, List.of(hashTag1, hashTag2)); + createDiscussion(mission1, List.of(hashTag3)); + + Page page0 = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc( + "미션 1", + "객체지향", + PageRequest.of(0, 3) + ); + Page page1 = discussionRepositoryCustom.findAllByMissionAndHashTagNameOrderByDesc( + "미션 1", + "객체지향", + PageRequest.of(1, 3) + ); + + assertAll( + () -> assertThat(page0.getTotalPages()).isEqualTo(2), + () -> assertThat(page0.getContent()).containsExactly(discussion4, discussion3, discussion2), + () -> assertThat(page1).containsExactly(discussion1) + ); + } + @Test @DisplayName("디스커션을 식별자로 조회한다.") @Transactional @@ -260,7 +343,54 @@ void findAllDiscussionCommentCounts() { assertThat(count).isEqualTo(1); } - private void createDiscussion(Mission mission, List hashTags) { + @Test + @DisplayName("pageable 기반으로 사용자의 디스커션을 id 역순으로 조회한다.") + @Transactional + void pageDescMemberDiscussion() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build()); + + int pageSize = 5; + List expectedFirstPageIds = new ArrayList<>(); + List expectedSecondPageIds = new ArrayList<>(); + for (int i = 0; i < pageSize; i++) { + // 정렬 조건이 DESC 반대로 넣어줘야한다. + expectedSecondPageIds.add(getSavedDiscussion(mission, member, hashTag).getId()); + } + for (int i = 0; i < pageSize; i++) { + expectedFirstPageIds.add(getSavedDiscussion(mission, member, hashTag).getId()); + } + + entityManager.clear(); + + PageRequest firstPageRequest = PageRequest.of(0, pageSize); + PageRequest secondPageRequest = PageRequest.of(1, pageSize); + PageRequest thirdPageRequest = PageRequest.of(2, pageSize); + Page firstResult = discussionRepositoryCustom + .findPageByMemberIdOrderByDesc(member.getId(), firstPageRequest); + Page secondResult = discussionRepositoryCustom + .findPageByMemberIdOrderByDesc(member.getId(), secondPageRequest); + Page thirdResult = discussionRepositoryCustom + .findPageByMemberIdOrderByDesc(member.getId(), thirdPageRequest); + + List firstPageIds = firstResult + .getContent().stream() + .map(Discussion::getId) + .toList(); + List secondPageIds = secondResult + .getContent().stream() + .map(Discussion::getId) + .toList(); + + assertAll( + () -> assertThat(firstPageIds).containsAll(expectedFirstPageIds), + () -> assertThat(secondPageIds).containsAll(expectedSecondPageIds), + () -> assertThat(thirdResult.getContent()).isEmpty() + ); + } + + private Discussion createDiscussion(Mission mission, List hashTags) { Member member = memberRepository.save(MemberTestData.defaultMember().build()); Discussion discussion = DiscussionTestData.defaultDiscussion() @@ -269,7 +399,7 @@ private void createDiscussion(Mission mission, List hashTags) { .withHashTags(hashTags) .build(); - discussionRepository.save(discussion); + return discussionRepository.save(discussion); } private Discussion getSavedDiscussion(Mission mission, Member member, HashTag hashTag) { @@ -278,6 +408,7 @@ private Discussion getSavedDiscussion(Mission mission, Member member, HashTag ha .withMember(member) .withHashTags(List.of(hashTag)) .build(); + return discussionRepository.save(discussion); } diff --git a/backend/src/test/java/develup/domain/discussion/DiscussionTest.java b/backend/src/test/java/develup/domain/discussion/DiscussionTest.java index a9c0134e..73c90c29 100644 --- a/backend/src/test/java/develup/domain/discussion/DiscussionTest.java +++ b/backend/src/test/java/develup/domain/discussion/DiscussionTest.java @@ -13,7 +13,6 @@ import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/backend/src/test/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustomTest.java b/backend/src/test/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustomTest.java index be377c4b..b3ee5b7f 100644 --- a/backend/src/test/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustomTest.java +++ b/backend/src/test/java/develup/domain/discussion/comment/DiscussionCommentRepositoryCustomTest.java @@ -17,9 +17,13 @@ import develup.support.data.DiscussionTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; class DiscussionCommentRepositoryCustomTest extends IntegrationTestSupport { @@ -38,6 +42,9 @@ class DiscussionCommentRepositoryCustomTest extends IntegrationTestSupport { @Autowired private DiscussionCommentRepository discussionCommentRepository; + @Autowired + private EntityManager entityManager; + @Test @DisplayName("특정 회원이 작성한 댓글 목록을 조회한다.") void getMyComments() { @@ -59,7 +66,53 @@ void getMyComments() { .map(MyDiscussionComment::id) .containsExactly(4L, 3L, 2L, 1L) ); + } + + @Test + @DisplayName("pageable 기반으로 특정 회원이 작성한 댓글 목록을 조회한다.") + @Transactional + void getMyCommentsPage() { + Discussion discussion = createRootDiscussion(); + Member member = createRootMember(); + int pageSize = 5; + List expectedFirstPageIds = new ArrayList<>(); + List expectedSecondPageIds = new ArrayList<>(); + for (int i = 0; i < pageSize; i++) { + // 정렬 조건이 DESC 반대로 넣어줘야한다. + expectedSecondPageIds.add(createRootDiscussionComment(discussion, member).getId()); + } + + for (int i = 0; i < pageSize; i++) { + expectedFirstPageIds.add(createRootDiscussionComment(discussion, member).getId()); + } + + entityManager.clear(); + + PageRequest firstPageRequest = PageRequest.of(0, pageSize); + PageRequest secondPageRequest = PageRequest.of(1, pageSize); + PageRequest thirdPageRequest = PageRequest.of(2, pageSize); + Page firstResult = discussionCommentRepositoryCustom + .findPageMyDiscussionCommentOrderByCreatedAtDesc(member.getId(), firstPageRequest); + Page secondResult = discussionCommentRepositoryCustom + .findPageMyDiscussionCommentOrderByCreatedAtDesc(member.getId(), secondPageRequest); + Page thirdResult = discussionCommentRepositoryCustom + .findPageMyDiscussionCommentOrderByCreatedAtDesc(member.getId(), thirdPageRequest); + + List firstPageIds = firstResult + .getContent().stream() + .map(MyDiscussionComment::id) + .toList(); + List secondPageIds = secondResult + .getContent().stream() + .map(MyDiscussionComment::id) + .toList(); + + assertAll( + () -> assertThat(firstPageIds).containsAll(expectedFirstPageIds), + () -> assertThat(secondPageIds).containsAll(expectedSecondPageIds), + () -> assertThat(thirdResult.getContent()).isEmpty() + ); } @Test diff --git a/backend/src/test/java/develup/domain/solution/SolutionRepositoryCustomTest.java b/backend/src/test/java/develup/domain/solution/SolutionRepositoryCustomTest.java index 406145b4..a3c9f707 100644 --- a/backend/src/test/java/develup/domain/solution/SolutionRepositoryCustomTest.java +++ b/backend/src/test/java/develup/domain/solution/SolutionRepositoryCustomTest.java @@ -1,11 +1,15 @@ package develup.domain.solution; +import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Function; +import java.util.stream.Collectors; import develup.domain.hashtag.HashTag; import develup.domain.hashtag.HashTagRepository; import develup.domain.member.Member; @@ -21,10 +25,12 @@ import develup.support.data.MissionTestData; import develup.support.data.SolutionCommentTestData; import develup.support.data.SolutionTestData; -import org.junit.jupiter.api.Assertions; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.transaction.annotation.Transactional; public class SolutionRepositoryCustomTest extends IntegrationTestSupport { @@ -47,13 +53,63 @@ public class SolutionRepositoryCustomTest extends IntegrationTestSupport { @Autowired private HashTagRepository hashTagRepository; + @Autowired + private EntityManager entityManager; + + @Test + @DisplayName("솔루션 목록 조회 시 연관관계가 모두 조회된다.") + void findAllCompletedSolutionWithRelations() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + HashTag java = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); + HashTag oop = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("OOP").build()); + Mission mission1 = createMissionWithHashTags(java, oop); + Mission mission2 = createMissionWithHashTags(java); + Solution solution1 = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission1) + .withStatus(SolutionStatus.COMPLETED) + .withSubmittedAt(LocalDateTime.of(2024, 1, 1, 0, 0)) + .build(); + Solution solution2 = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission2) + .withStatus(SolutionStatus.COMPLETED) + .withSubmittedAt(LocalDateTime.of(2024, 1, 2, 0, 0)) + .build(); + + solutionRepository.saveAll(List.of(solution1, solution2)); + + List solutions = solutionRepositoryCustom.findAllCompletedSolutionByHashTagName("all", "JAVA"); + + assertAll( + () -> { + List hashTags = solutions.getFirst().getHashTags() + .stream() + .map(MissionHashTag::getHashTag) + .toList(); + assertThat(hashTags).containsExactly(java); + }, + () -> { + List hashTags = solutions.get(1).getHashTags() + .stream() + .map(MissionHashTag::getHashTag) + .toList(); + assertThat(hashTags).containsExactly(java, oop); + } + ); + } + + private Mission createMissionWithHashTags(HashTag... hashTags) { + return missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTags)).build()); + } + @Test @DisplayName("솔루션을 제출일자 역순으로 조회할 수 있다.") void findAllCompletedSolutionByHashTagOrderBySubmittedAtDesc() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); - Mission mission1 = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build()); - Mission mission2 = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build()); + Mission mission1 = createMissionWithHashTags(hashTag); + Mission mission2 = createMissionWithHashTags(hashTag); Solution solution1 = SolutionTestData.defaultSolution() .withMember(member) .withMission(mission1) @@ -82,8 +138,8 @@ void findAllCompletedSolutionByHashTag() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); HashTag hashTag1 = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); HashTag hashTag2 = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JS").build()); - Mission mission1 = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag1)).build()); - Mission mission2 = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag2)).build()); + Mission mission1 = createMissionWithHashTags(hashTag1); + Mission mission2 = createMissionWithHashTags(hashTag2); Solution solution1 = SolutionTestData.defaultSolution() .withMember(member) .withMission(mission1) @@ -111,7 +167,7 @@ void findAllCompletedSolutionByHashTag() { void findAllCompletedSolutionByMissionName() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("JAVA").build()); - Mission otherMission = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build()); + Mission otherMission = createMissionWithHashTags(hashTag); Mission targetMission = missionRepository.save(MissionTestData.defaultMission().withHashTags(List.of(hashTag)).withTitle("테스트 미션 제목").build()); Solution otherSolution = SolutionTestData.defaultSolution() .withMember(member) @@ -169,7 +225,7 @@ void findFetchById() { Optional hashTaggedFound = solutionRepositoryCustom.findFetchById(hashTaggedSolution.getId()); Optional noneTaggedFound = solutionRepositoryCustom.findFetchById(nonTaggedSolution.getId()); - Assertions.assertAll( + assertAll( () -> assertThat(hashTaggedFound) .isPresent() .map(it -> it.getHashTags().size()) @@ -201,6 +257,66 @@ void deleteAllComments() { assertThat(solutionCommentRepository.findById(comment.getId())).isEmpty(); } + @Test + @DisplayName("pageable 기반으로 사용자가 제출한 솔루션을 제출일자 역순으로 조회한다.") + void pageDescMemberSolution() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + + int pageSize = 5; + List expectedFirstPageIds = new ArrayList<>(); + List expectedSecondPageIds = new ArrayList<>(); + + LocalDateTime current = LocalDateTime.of(2024,10, 16, 0, 0, 0); + + for (int i = 0; i < pageSize; i++) { + expectedFirstPageIds.add(getSavedSolution(member, current).getId()); + current = current.minusDays(1L); + } + for (int i = 0; i < pageSize; i++) { + expectedSecondPageIds.add(getSavedSolution(member, current).getId()); + current = current.minusDays(1L); + } + + PageRequest firstPageRequest = PageRequest.of(0, pageSize); + PageRequest secondPageRequest = PageRequest.of(1, pageSize); + PageRequest thirdPageRequest = PageRequest.of(2, pageSize); + + Page firstResult = solutionRepositoryCustom.findPageByMemberIdOrderByDesc(member.getId(), firstPageRequest); + Page secondResult = solutionRepositoryCustom.findPageByMemberIdOrderByDesc(member.getId(), secondPageRequest); + Page thirdResult = solutionRepositoryCustom.findPageByMemberIdOrderByDesc(member.getId(), thirdPageRequest); + + List firstPageIds = firstResult + .getContent().stream() + .map(Solution::getId) + .toList(); + + List secondPageIds = secondResult + .getContent().stream() + .map(Solution::getId) + .toList(); + + assertAll( + () -> assertThat(firstPageIds).containsAll(expectedFirstPageIds), + () -> assertThat(secondPageIds).containsAll(secondPageIds), + () -> assertThat(thirdResult.getContent()).isEmpty() + ); + } + + private Solution getSavedSolution(Member member, LocalDateTime submittedAt) { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission mission = MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build(); + missionRepository.save(mission); + + Solution solution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.COMPLETED) + .withSubmittedAt(submittedAt) + .build(); + + return solutionRepository.save(solution); + } + private void createSolution(SolutionStatus status) { HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("A").build()); Member member = memberRepository.save(MemberTestData.defaultMember().build()); diff --git a/backend/src/test/java/develup/domain/solution/comment/SolutionCommentRepositoryCustomTest.java b/backend/src/test/java/develup/domain/solution/comment/SolutionCommentRepositoryCustomTest.java index 759235a9..bf73e598 100644 --- a/backend/src/test/java/develup/domain/solution/comment/SolutionCommentRepositoryCustomTest.java +++ b/backend/src/test/java/develup/domain/solution/comment/SolutionCommentRepositoryCustomTest.java @@ -20,6 +20,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; class SolutionCommentRepositoryCustomTest extends IntegrationTestSupport { @@ -98,6 +101,56 @@ void getMyCommentsWithDelete() { .hasSize(solutionComments.size()); } + @Test + @DisplayName("특정 회원이 작성한 댓글 목록을 작성일자 역순으로 페이지네이션 기반 조회한다.") + @Transactional + void getMyCommentsPage() { + Solution solution = createSolution(); + Member member = createMember(); + List solutionComments = new ArrayList<>(); + int pageSize = 2; + + List expectedFirstPageIds = new ArrayList<>(); + List expectedSecondPageIds = new ArrayList<>(); + + for (int i = 0; i < pageSize; i++) { + SolutionComment solutionComment = createSolutionComment(solution, member); + solutionComments.add(solutionComment); + expectedSecondPageIds.add(0, solutionComment.getId()); + } + + for (int i = 0; i < pageSize; i++) { + SolutionComment solutionComment = createSolutionComment(solution, member); + solutionComments.add(solutionComment); + expectedFirstPageIds.add(0, solutionComment.getId()); + } + + PageRequest firstPage = PageRequest.of(0, pageSize); + PageRequest secondPage = PageRequest.of(1, pageSize); + Page firstResult = solutionCommentRepositoryCustom.findAllMySolutionCommentOrderByDesc( + member.getId(), + firstPage + ); + Page secondResult = solutionCommentRepositoryCustom.findAllMySolutionCommentOrderByDesc( + member.getId(), + secondPage + ); + + List firstPageIds = firstResult.getContent().stream() + .map(MySolutionComment::id) + .toList(); + List secondPageIds = secondResult.getContent().stream() + .map(MySolutionComment::id) + .toList(); + + assertAll( + () -> assertThat(firstPageIds).containsExactlyElementsOf(expectedFirstPageIds), + () -> assertThat(secondPageIds).containsExactlyElementsOf(expectedSecondPageIds), + () -> assertThat(firstResult.getTotalElements()).isEqualTo(solutionComments.size()), + () -> assertThat(firstResult.getTotalPages()).isEqualTo(2) + ); + } + private Solution createSolution() { Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); Member member = memberRepository.save(MemberTestData.defaultMember().build()); diff --git a/frontend/src/App.styled.ts b/frontend/src/App.styled.ts new file mode 100644 index 00000000..2b1c08e9 --- /dev/null +++ b/frontend/src/App.styled.ts @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 100%; +`; + +export const SkipTag = styled.a` + opacity: 0; + + &:focus { + opacity: 1; + position: absolute; + top: 0; + left: 0; + width: 10rem; + height: 8rem; + padding: 0; + overflow: hidden; + white-space: nowrap; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + background: ${(props) => props.theme.colors.primary500}; + color: ${(props) => props.theme.colors.white}; + border: none; + border-radius: 0.5rem; + cursor: pointer; + } +`; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a3549e1a..91052eed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,16 +3,20 @@ import { GlobalLayout } from './styles/GlobalLayout'; import type { PropsWithChildren } from 'react'; import Footer from './components/Footer'; import { ScrollToTopButton } from './components/common/ScrollToTopButton'; +import * as S from './App.styled'; export default function App({ children }: PropsWithChildren) { return ( - <> + + + 본문으로 바로가기 +
- {children} +
{children}