diff --git a/backend/src/main/java/develup/api/MissionApi.java b/backend/src/main/java/develup/api/MissionApi.java index 641b8961..c0df343f 100644 --- a/backend/src/main/java/develup/api/MissionApi.java +++ b/backend/src/main/java/develup/api/MissionApi.java @@ -32,6 +32,14 @@ public ResponseEntity>> getMissions() { return ResponseEntity.ok(new ApiResponse<>(responses)); } + @GetMapping("/missions/in-progress") + @Operation(summary = "사용자가 시작한 미션 목록 조회 API", description = "사용자가 시작한 미션 목록을 조회합니다.") + public ResponseEntity>> getInProgressMissions(@Auth Accessor accessor) { + List responses = missionService.getInProgressMissions(accessor.id()); + + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + @GetMapping("/missions/{missionId}") @Operation( summary = "미션 조회 API", diff --git a/backend/src/main/java/develup/api/SolutionApi.java b/backend/src/main/java/develup/api/SolutionApi.java index 8f489716..6ebdcb78 100644 --- a/backend/src/main/java/develup/api/SolutionApi.java +++ b/backend/src/main/java/develup/api/SolutionApi.java @@ -4,11 +4,12 @@ import develup.api.auth.Auth; import develup.api.common.ApiResponse; import develup.application.auth.Accessor; +import develup.application.solution.MySolutionResponse; import develup.application.solution.SolutionResponse; import develup.application.solution.SolutionService; import develup.application.solution.StartSolutionRequest; import develup.application.solution.SubmitSolutionRequest; -import develup.domain.solution.SolutionSummary; +import develup.application.solution.SummarizedSolutionResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -53,17 +54,25 @@ public ResponseEntity> submitSolution( @GetMapping("/solutions") @Operation(summary = "솔루션 조회 목록 API", description = "솔루션 목록을 조회합니다.") - public ResponseEntity>> getSolutions() { - List summaries = solutionService.getCompletedSummaries(); + public ResponseEntity>> getSolutions() { + List responses = solutionService.getCompletedSummaries(); - return ResponseEntity.ok(new ApiResponse<>(summaries)); + return ResponseEntity.ok(new ApiResponse<>(responses)); } @GetMapping("/solutions/{id}") @Operation(summary = "솔루션 조회 API", description = "솔루션을 조회합니다.") public ResponseEntity> getSolution(@PathVariable Long id) { - SolutionResponse solutionResponse = solutionService.getById(id); + SolutionResponse response = solutionService.getById(id); - return ResponseEntity.ok(new ApiResponse<>(solutionResponse)); + return ResponseEntity.ok(new ApiResponse<>(response)); + } + + @GetMapping("/solutions/mine") + @Operation(summary = "나의 솔루션 목록 조회 API", description = "내가 제출한 솔루션 목록을 조회합니다.") + public ResponseEntity>> getMySolutions(@Auth Accessor accessor) { + List response = solutionService.getSubmittedSolutionsByMemberId(accessor.id()); + + return ResponseEntity.ok(new ApiResponse<>(response)); } } diff --git a/backend/src/main/java/develup/api/SolutionCommentApi.java b/backend/src/main/java/develup/api/SolutionCommentApi.java new file mode 100644 index 00000000..837e378e --- /dev/null +++ b/backend/src/main/java/develup/api/SolutionCommentApi.java @@ -0,0 +1,67 @@ +package develup.api; + +import java.net.URI; +import java.util.List; +import develup.api.auth.Auth; +import develup.api.common.ApiResponse; +import develup.application.auth.Accessor; +import develup.application.solution.comment.CreateSolutionCommentResponse; +import develup.application.solution.comment.SolutionCommentRequest; +import develup.application.solution.comment.SolutionCommentService; +import develup.application.solution.comment.SolutionCommentRepliesResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "솔루션 댓글 API") +public class SolutionCommentApi { + + private final SolutionCommentService solutionCommentService; + + public SolutionCommentApi(SolutionCommentService solutionCommentService) { + this.solutionCommentService = solutionCommentService; + } + + @GetMapping("/solutions/{solutionId}/comments") + @Operation(summary = "솔루션 댓글 조회 API", description = "솔루션의 댓글 목록을 조회합니다. 댓글들과 댓글들에 대한 답글을 조회합니다.") + public ResponseEntity>> getComments( + @PathVariable Long solutionId + ) { + List responses = solutionCommentService.getCommentsWithReplies(solutionId); + + return ResponseEntity.ok(new ApiResponse<>(responses)); + } + + @PostMapping("/solutions/{solutionId}/comments") + @Operation(summary = "솔루션 댓글 추가 API", description = "솔루션에 댓글을 추가합니다. 부모 댓글 식별자로 답글을 추가할 수 있습니다.") + public ResponseEntity> addComment( + @PathVariable Long solutionId, + @Valid @RequestBody SolutionCommentRequest request, + @Auth Accessor accessor + ) { + CreateSolutionCommentResponse response = solutionCommentService.addComment(solutionId, request, accessor.id()); + + URI location = URI.create("/solutions/" + response.solutionId() + "/comments/" + response.id()); + + return ResponseEntity.created(location).body(new ApiResponse<>(response)); + } + + @DeleteMapping("/solutions/comments/{commentId}") + @Operation(summary = "솔루션 댓글 삭제 API", description = "솔루션의 댓글을 삭제합니다.") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @Auth Accessor accessor + ) { + solutionCommentService.deleteComment(commentId, accessor.id()); + + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/develup/api/exception/ExceptionType.java b/backend/src/main/java/develup/api/exception/ExceptionType.java index 81191abe..184097a6 100644 --- a/backend/src/main/java/develup/api/exception/ExceptionType.java +++ b/backend/src/main/java/develup/api/exception/ExceptionType.java @@ -17,6 +17,12 @@ public enum ExceptionType { SOLUTION_ALREADY_SUBMITTED(HttpStatus.BAD_REQUEST, "이미 제출한 미션입니다."), INVALID_URL(HttpStatus.BAD_REQUEST, "올바르지 않은 주소입니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "올바르지 않은 제목입니다."), + COMMENT_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 댓글입니다."), + CANNOT_REPLY_TO_REPLY(HttpStatus.BAD_REQUEST, "답글에는 답글을 작성할 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + COMMENT_NOT_WRITTEN_BY_MEMBER(HttpStatus.FORBIDDEN, "작성자만 댓글을 삭제할 수 있습니다."), + DUPLICATED_HASHTAG(HttpStatus.BAD_REQUEST, "중복된 해시태그입니다."), + ; private final HttpStatus status; diff --git a/backend/src/main/java/develup/application/auth/OAuthUserInfo.java b/backend/src/main/java/develup/application/auth/OAuthUserInfo.java index 210168dc..5b79ec84 100644 --- a/backend/src/main/java/develup/application/auth/OAuthUserInfo.java +++ b/backend/src/main/java/develup/application/auth/OAuthUserInfo.java @@ -12,6 +12,12 @@ public record OAuthUserInfo( String name ) { + public OAuthUserInfo { + if (name == null || name.isBlank()) { + name = login; + } + } + public Member toMember(Provider provider) { return new Member( email, diff --git a/backend/src/main/java/develup/application/hashtag/HashTagResponse.java b/backend/src/main/java/develup/application/hashtag/HashTagResponse.java new file mode 100644 index 00000000..442d797d --- /dev/null +++ b/backend/src/main/java/develup/application/hashtag/HashTagResponse.java @@ -0,0 +1,13 @@ +package develup.application.hashtag; + +import develup.domain.hashtag.HashTag; + +public record HashTagResponse(Long id, String name) { + + public static HashTagResponse from(HashTag hashTag) { + return new HashTagResponse( + hashTag.getId(), + hashTag.getName() + ); + } +} diff --git a/backend/src/main/java/develup/application/mission/MissionResponse.java b/backend/src/main/java/develup/application/mission/MissionResponse.java index 7067fb1e..1f48959f 100644 --- a/backend/src/main/java/develup/application/mission/MissionResponse.java +++ b/backend/src/main/java/develup/application/mission/MissionResponse.java @@ -1,22 +1,33 @@ package develup.application.mission; +import java.util.List; +import develup.application.hashtag.HashTagResponse; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; public record MissionResponse( Long id, String title, String thumbnail, String summary, - String url + String url, + List hashTags ) { public static MissionResponse from(Mission mission) { + List hashTagResponses = mission.getHashTags() + .stream() + .map(MissionHashTag::getHashTag) + .map(HashTagResponse::from) + .toList(); + return new MissionResponse( mission.getId(), mission.getTitle(), mission.getThumbnail(), mission.getSummary(), - mission.getUrl() + mission.getUrl(), + hashTagResponses ); } } diff --git a/backend/src/main/java/develup/application/mission/MissionService.java b/backend/src/main/java/develup/application/mission/MissionService.java index 61f52acc..3e97894c 100644 --- a/backend/src/main/java/develup/application/mission/MissionService.java +++ b/backend/src/main/java/develup/application/mission/MissionService.java @@ -6,11 +6,14 @@ import develup.application.auth.Accessor; import develup.domain.mission.Mission; import develup.domain.mission.MissionRepository; +import develup.domain.solution.Solution; import develup.domain.solution.SolutionRepository; import develup.domain.solution.SolutionStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Transactional public class MissionService { private final MissionRepository missionRepository; @@ -22,13 +25,22 @@ public MissionService(MissionRepository missionRepository, SolutionRepository so } public List getMissions() { - return missionRepository.findAll().stream() + return missionRepository.findAllHashTaggedMission().stream() + .map(MissionResponse::from) + .toList(); + } + + public List getInProgressMissions(Long memberId) { + return solutionRepository.findAllByMember_IdAndStatus(memberId, SolutionStatus.IN_PROGRESS) + .stream() + .map(Solution::getMission) + .distinct() .map(MissionResponse::from) .toList(); } public MissionWithStartedResponse getMission(Accessor accessor, Long missionId) { - Mission mission = missionRepository.findById(missionId) + Mission mission = missionRepository.findHashTaggedMissionById(missionId) .orElseThrow(() -> new DevelupException(ExceptionType.MISSION_NOT_FOUND)); if (accessor.isGuest()) { diff --git a/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java b/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java index 43ac2b83..a5b2814e 100644 --- a/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java +++ b/backend/src/main/java/develup/application/mission/MissionWithStartedResponse.java @@ -1,6 +1,9 @@ package develup.application.mission; +import java.util.List; +import develup.application.hashtag.HashTagResponse; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; public record MissionWithStartedResponse( Long id, @@ -8,17 +11,25 @@ public record MissionWithStartedResponse( String descriptionUrl, String thumbnail, String url, - boolean isStarted + boolean isStarted, + List hashTags ) { public static MissionWithStartedResponse of(Mission mission, boolean isStarted) { + List hashTagResponses = mission.getHashTags() + .stream() + .map(MissionHashTag::getHashTag) + .map(HashTagResponse::from) + .toList(); + return new MissionWithStartedResponse( mission.getId(), mission.getTitle(), mission.getDescriptionUrl(), mission.getThumbnail(), mission.getUrl(), - isStarted + isStarted, + hashTagResponses ); } diff --git a/backend/src/main/java/develup/application/solution/MySolutionResponse.java b/backend/src/main/java/develup/application/solution/MySolutionResponse.java new file mode 100644 index 00000000..2851be5e --- /dev/null +++ b/backend/src/main/java/develup/application/solution/MySolutionResponse.java @@ -0,0 +1,10 @@ +package develup.application.solution; + +import develup.domain.solution.Solution; + +public record MySolutionResponse(Long id, String thumbnail, String title) { + + public static MySolutionResponse from(Solution solution) { + return new MySolutionResponse(solution.getId(), solution.getMissionThumbnail(), solution.getTitle()); + } +} diff --git a/backend/src/main/java/develup/application/solution/SolutionService.java b/backend/src/main/java/develup/application/solution/SolutionService.java index 701f4d40..57639fbf 100644 --- a/backend/src/main/java/develup/application/solution/SolutionService.java +++ b/backend/src/main/java/develup/application/solution/SolutionService.java @@ -13,7 +13,6 @@ import develup.domain.solution.SolutionRepository; import develup.domain.solution.SolutionStatus; import develup.domain.solution.SolutionSubmit; -import develup.domain.solution.SolutionSummary; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -104,7 +103,16 @@ public SolutionResponse getById(Long id) { return SolutionResponse.from(solution); } - public List getCompletedSummaries() { - return solutionRepository.findCompletedSummaries(); + public List getCompletedSummaries() { + return solutionRepository.findAllCompletedSolution().stream() + .map(SummarizedSolutionResponse::from) + .toList(); + } + + public List getSubmittedSolutionsByMemberId(Long memberId) { + List mySolutions = solutionRepository.findAllByMember_IdAndStatus(memberId, SolutionStatus.COMPLETED); + return mySolutions.stream() + .map(MySolutionResponse::from) + .toList(); } } diff --git a/backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java b/backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java new file mode 100644 index 00000000..b8122650 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/SummarizedSolutionResponse.java @@ -0,0 +1,30 @@ +package develup.application.solution; + +import java.util.List; +import develup.application.hashtag.HashTagResponse; +import develup.domain.mission.MissionHashTag; +import develup.domain.solution.Solution; + +public record SummarizedSolutionResponse( + Long id, + String title, + String thumbnail, + String description, + List hashTags +) { + + public static SummarizedSolutionResponse from(Solution solution) { + List hashTagResponses = solution.getHashTags().stream() + .map(MissionHashTag::getHashTag) + .map(HashTagResponse::from) + .toList(); + + return new SummarizedSolutionResponse( + solution.getId(), + solution.getTitle(), + solution.getMissionThumbnail(), + solution.getDescription(), + hashTagResponses + ); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java b/backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java new file mode 100644 index 00000000..f1fa9d8e --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/CommentGroupingService.java @@ -0,0 +1,57 @@ +package develup.application.solution.comment; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import develup.domain.solution.comment.SolutionComment; +import org.springframework.stereotype.Service; + +@Service +public class CommentGroupingService { + + public List groupReplies(List comments) { + List rootComments = filterRootComments(comments); + + Map> repliesMap = createRepliesMapByRootCommentId(comments); + List commentWithReplies = attachRepliesToRootComments(rootComments, repliesMap); + + return commentWithReplies.stream() + .filter(this::isRootCommentNotDeletedOrHasReplies) + .toList(); + } + + private List filterRootComments(List comments) { + return comments.stream() + .filter(SolutionComment::isRootComment) + .toList(); + } + + private Map> createRepliesMapByRootCommentId(List comments) { + return comments.stream() + .filter(SolutionComment::isReply) + .filter(SolutionComment::isNotDeleted) + .collect(Collectors.groupingBy(SolutionComment::getParentCommentId)); + } + + private List attachRepliesToRootComments( + List rootComments, + Map> repliesMap + ) { + return rootComments.stream() + .map(rootComment -> createSolutionCommentRepliesResponse(rootComment, repliesMap)) + .toList(); + } + + private SolutionCommentRepliesResponse createSolutionCommentRepliesResponse( + SolutionComment rootComment, + Map> repliesMap + ) { + List replies = repliesMap.getOrDefault(rootComment.getId(), List.of()); + + return SolutionCommentRepliesResponse.of(rootComment, replies); + } + + private boolean isRootCommentNotDeletedOrHasReplies(SolutionCommentRepliesResponse rootCommentResponse) { + return !rootCommentResponse.isDeleted() || !rootCommentResponse.replies().isEmpty(); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java b/backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java new file mode 100644 index 00000000..82867cfa --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/CreateSolutionCommentResponse.java @@ -0,0 +1,31 @@ +package develup.application.solution.comment; + +import java.time.LocalDateTime; +import java.util.Optional; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; + +public record CreateSolutionCommentResponse( + Long id, + Long solutionId, + Long parentCommentId, + String content, + MemberResponse member, + LocalDateTime createdAt +) { + + public static CreateSolutionCommentResponse from(SolutionComment comment) { + Long parentCommentId = Optional.ofNullable(comment.getParentComment()) + .map(SolutionComment::getId) + .orElse(null); + + return new CreateSolutionCommentResponse( + comment.getId(), + comment.getSolutionId(), + parentCommentId, + comment.getContent(), + MemberResponse.from(comment.getMember()), + comment.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java new file mode 100644 index 00000000..dcf3aa40 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRepliesResponse.java @@ -0,0 +1,60 @@ +package develup.application.solution.comment; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; + +public record SolutionCommentRepliesResponse( + Long id, + Long solutionId, + String content, + MemberResponse member, + List replies, + LocalDateTime createdAt, + boolean isDeleted +) { + + private static final LocalDateTime EPOCH_TIME = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC); + private static final MemberResponse EMPTY_MEMBER = new MemberResponse(0L, "", "", ""); + + public static SolutionCommentRepliesResponse of( + SolutionComment rootComment, + List replies + ) { + List replyResponses = replies.stream() + .map(SolutionReplyResponse::from) + .toList(); + + + if (rootComment.isDeleted()) { + return ofDeleted(rootComment, replyResponses); + } + + return new SolutionCommentRepliesResponse( + rootComment.getId(), + rootComment.getSolutionId(), + rootComment.getContent(), + MemberResponse.from(rootComment.getMember()), + replyResponses, + rootComment.getCreatedAt(), + false + ); + } + + private static SolutionCommentRepliesResponse ofDeleted( + SolutionComment rootComment, + List replyResponses + ) { + return new SolutionCommentRepliesResponse( + rootComment.getId(), + rootComment.getSolutionId(), + "", + EMPTY_MEMBER, + replyResponses, + EPOCH_TIME, + true + ); + } +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java new file mode 100644 index 00000000..a38d3898 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentRequest.java @@ -0,0 +1,9 @@ +package develup.application.solution.comment; + +import jakarta.validation.constraints.NotBlank; + +public record SolutionCommentRequest( + @NotBlank String content, + Long parentCommentId +) { +} diff --git a/backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java b/backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java new file mode 100644 index 00000000..4db638cd --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionCommentService.java @@ -0,0 +1,99 @@ +package develup.application.solution.comment; + +import java.util.List; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; +import develup.domain.member.Member; +import develup.domain.member.MemberRepository; +import develup.domain.solution.Solution; +import develup.domain.solution.SolutionRepository; +import develup.domain.solution.comment.SolutionComment; +import develup.domain.solution.comment.SolutionCommentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class SolutionCommentService { + + private final CommentGroupingService commentGroupingService; + private final SolutionCommentRepository solutionCommentRepository; + private final MemberRepository memberRepository; + private final SolutionRepository solutionRepository; + + public SolutionCommentService( + CommentGroupingService commentGroupingService, + SolutionCommentRepository solutionCommentRepository, + MemberRepository memberRepository, + SolutionRepository solutionRepository + ) { + this.commentGroupingService = commentGroupingService; + this.solutionCommentRepository = solutionCommentRepository; + this.memberRepository = memberRepository; + this.solutionRepository = solutionRepository; + } + + public SolutionComment getComment(Long commentId) { + SolutionComment comment = solutionCommentRepository.findById(commentId) + .orElseThrow(() -> new DevelupException(ExceptionType.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new DevelupException(ExceptionType.COMMENT_NOT_FOUND); + } + + return comment; + } + + public List getCommentsWithReplies(Long solutionId) { + List comments = solutionCommentRepository.findAllBySolution_IdOrderByCreatedAtAsc(solutionId); + + return commentGroupingService.groupReplies(comments); + } + + public CreateSolutionCommentResponse addComment(Long solutionId, SolutionCommentRequest request, Long memberId) { + Member member = getMember(memberId); + Solution solution = getSolution(solutionId); + + boolean isReply = request.parentCommentId() != null; + if (isReply) { + SolutionComment reply = createReply(request, member); + return CreateSolutionCommentResponse.from(reply); + } + + SolutionComment rootComment = createRootComment(request, solution, member); + return CreateSolutionCommentResponse.from(rootComment); + } + + private SolutionComment createReply(SolutionCommentRequest request, Member member) { + SolutionComment parentComment = getComment(request.parentCommentId()); + SolutionComment reply = parentComment.reply(request.content(), member); + + return solutionCommentRepository.save(reply); + } + + private SolutionComment createRootComment(SolutionCommentRequest request, Solution solution, Member member) { + SolutionComment rootComment = SolutionComment.create(request.content(), solution, member); + + return solutionCommentRepository.save(rootComment); + } + + public void deleteComment(Long commentId, Long memberId) { + SolutionComment comment = getComment(commentId); + + if (comment.isNotWrittenBy(memberId)) { + throw new DevelupException(ExceptionType.COMMENT_NOT_WRITTEN_BY_MEMBER); + } + + comment.delete(); + } + + private Member getMember(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new DevelupException(ExceptionType.MEMBER_NOT_FOUND)); + } + + private 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/SolutionReplyResponse.java b/backend/src/main/java/develup/application/solution/comment/SolutionReplyResponse.java new file mode 100644 index 00000000..935cca65 --- /dev/null +++ b/backend/src/main/java/develup/application/solution/comment/SolutionReplyResponse.java @@ -0,0 +1,26 @@ +package develup.application.solution.comment; + +import java.time.LocalDateTime; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; + +public record SolutionReplyResponse( + Long id, + Long solutionId, + Long parentCommentId, + String content, + MemberResponse member, + LocalDateTime createdAt +) { + + public static SolutionReplyResponse from(SolutionComment reply) { + return new SolutionReplyResponse( + reply.getId(), + reply.getSolutionId(), + reply.getParentCommentId(), + reply.getContent(), + MemberResponse.from(reply.getMember()), + reply.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java b/backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java new file mode 100644 index 00000000..1673c24d --- /dev/null +++ b/backend/src/main/java/develup/domain/CreatedAtAuditableEntity.java @@ -0,0 +1,28 @@ +package develup.domain; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class CreatedAtAuditableEntity extends IdentifiableEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + protected LocalDateTime createdAt; + + protected CreatedAtAuditableEntity() { + } + + protected CreatedAtAuditableEntity(Long id) { + super(id); + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/develup/domain/IdentifiableEntity.java b/backend/src/main/java/develup/domain/IdentifiableEntity.java new file mode 100644 index 00000000..05610583 --- /dev/null +++ b/backend/src/main/java/develup/domain/IdentifiableEntity.java @@ -0,0 +1,45 @@ +package develup.domain; + +import java.util.Objects; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import org.hibernate.Hibernate; + +@MappedSuperclass +public abstract class IdentifiableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + protected IdentifiableEntity() { + this(null); + } + + protected IdentifiableEntity(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) { + return false; + } + IdentifiableEntity that = (IdentifiableEntity) o; + return Objects.equals(getId(), that.getId()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getId()); + } +} diff --git a/backend/src/main/java/develup/domain/hashtag/HashTag.java b/backend/src/main/java/develup/domain/hashtag/HashTag.java new file mode 100644 index 00000000..ee3a5ef6 --- /dev/null +++ b/backend/src/main/java/develup/domain/hashtag/HashTag.java @@ -0,0 +1,56 @@ +package develup.domain.hashtag; + +import java.util.Objects; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class HashTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + protected HashTag() { + } + + public HashTag(String name) { + this(null, name); + } + + public HashTag(Long id, String name) { + this.id = id; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HashTag hashTag)) { + return false; + } + + return this.getId() != null && Objects.equals(getId(), hashTag.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(getId()); + } +} diff --git a/backend/src/main/java/develup/domain/hashtag/HashTagRepository.java b/backend/src/main/java/develup/domain/hashtag/HashTagRepository.java new file mode 100644 index 00000000..9fb8256c --- /dev/null +++ b/backend/src/main/java/develup/domain/hashtag/HashTagRepository.java @@ -0,0 +1,6 @@ +package develup.domain.hashtag; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HashTagRepository extends JpaRepository { +} diff --git a/backend/src/main/java/develup/domain/mission/Mission.java b/backend/src/main/java/develup/domain/mission/Mission.java index 88e2a05b..59bf29b1 100644 --- a/backend/src/main/java/develup/domain/mission/Mission.java +++ b/backend/src/main/java/develup/domain/mission/Mission.java @@ -1,6 +1,10 @@ package develup.domain.mission; +import java.util.List; +import java.util.Set; +import develup.domain.hashtag.HashTag; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -28,19 +32,33 @@ public class Mission { @Column(nullable = false) private String url; + @Embedded + private MissionHashTags missionHashTags; + protected Mission() { } - public Mission(String title, String thumbnail, String summary, String url) { - this(null, title, thumbnail, summary, url); + public Mission(String title, String thumbnail, String summary, String url, List hashTags) { + this(null, title, thumbnail, summary, url, hashTags); } - public Mission(Long id, String title, String thumbnail, String summary, String url) { + public Mission(Long id, String title, String thumbnail, String summary, String url, List hashTags) { this.id = id; this.title = title; this.thumbnail = thumbnail; this.summary = summary; this.url = url; + this.missionHashTags = new MissionHashTags(this, hashTags); + } + + public void tagAll(List tags) { + missionHashTags.addAll(this, tags); + } + + public String getDescriptionUrl() { + String[] split = url.split("/"); + + return DESCRIPTION_BASE_URL_PREFIX + split[split.length - 1] + DESCRIPTION_BASE_URL_SUFFIX; } public Long getId() { @@ -63,9 +81,7 @@ public String getUrl() { return url; } - public String getDescriptionUrl() { - String[] split = url.split("/"); - - return DESCRIPTION_BASE_URL_PREFIX + split[split.length - 1] + DESCRIPTION_BASE_URL_SUFFIX; + public Set getHashTags() { + return missionHashTags.getHashTags(); } } diff --git a/backend/src/main/java/develup/domain/mission/MissionHashTag.java b/backend/src/main/java/develup/domain/mission/MissionHashTag.java new file mode 100644 index 00000000..2aa623c9 --- /dev/null +++ b/backend/src/main/java/develup/domain/mission/MissionHashTag.java @@ -0,0 +1,51 @@ +package develup.domain.mission; + +import develup.domain.hashtag.HashTag; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +@Entity +public class MissionHashTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private Mission mission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private HashTag hashTag; + + protected MissionHashTag() { + } + + public MissionHashTag(Mission mission, HashTag hashTag) { + this(null, mission, hashTag); + } + + public MissionHashTag(Long id, Mission mission, HashTag hashTag) { + this.id = id; + this.mission = mission; + this.hashTag = hashTag; + } + + public Long getId() { + return id; + } + + public Mission getMission() { + return mission; + } + + public HashTag getHashTag() { + return hashTag; + } +} diff --git a/backend/src/main/java/develup/domain/mission/MissionHashTags.java b/backend/src/main/java/develup/domain/mission/MissionHashTags.java new file mode 100644 index 00000000..d077cb8c --- /dev/null +++ b/backend/src/main/java/develup/domain/mission/MissionHashTags.java @@ -0,0 +1,69 @@ +package develup.domain.mission; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; +import develup.domain.hashtag.HashTag; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; + +@Embeddable +class MissionHashTags { + + @OrderBy(value = "id ASC") + @OneToMany(mappedBy = "mission", cascade = CascadeType.PERSIST) + private Set hashTags = new LinkedHashSet<>(); + + protected MissionHashTags() { + } + + public MissionHashTags(Mission mission, List hashTags) { + validateDuplicated(hashTags); + + this.hashTags = mapToMissionHashTag(mission, hashTags); + } + + public void addAll(Mission target, List hashTags) { + validateDuplicated(hashTags); + validateAlreadyTagged(hashTags); + + this.hashTags.addAll(mapToMissionHashTag(target, hashTags)); + } + + private void validateDuplicated(List hashTags) { + int uniqueSize = hashTags.stream() + .distinct() + .toList() + .size(); + + if (uniqueSize != hashTags.size()) { + throw new DevelupException(ExceptionType.DUPLICATED_HASHTAG); + } + } + + private void validateAlreadyTagged(List hashTags) { + boolean alreadyTagged = this.hashTags.stream() + .map(MissionHashTag::getHashTag) + .anyMatch(hashTags::contains); + + if (alreadyTagged) { + throw new DevelupException(ExceptionType.DUPLICATED_HASHTAG); + } + } + + private Set mapToMissionHashTag(Mission target, List hashTags) { + return hashTags.stream() + .map(it -> new MissionHashTag(target, it)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + public Set getHashTags() { + return Collections.unmodifiableSet(hashTags); + } +} diff --git a/backend/src/main/java/develup/domain/mission/MissionRepository.java b/backend/src/main/java/develup/domain/mission/MissionRepository.java index ea1d68e4..d30d8d15 100644 --- a/backend/src/main/java/develup/domain/mission/MissionRepository.java +++ b/backend/src/main/java/develup/domain/mission/MissionRepository.java @@ -1,6 +1,7 @@ package develup.domain.mission; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -8,4 +9,21 @@ public interface MissionRepository extends JpaRepository { @Query("SELECT m.url FROM Mission m") List findUrl(); + + @Query(""" + SELECT DISTINCT m + FROM Mission m + JOIN FETCH m.missionHashTags.hashTags mhts + JOIN FETCH mhts.hashTag ht + WHERE m.id = :id + """) + Optional findHashTaggedMissionById(Long id); + + @Query(""" + SELECT DISTINCT m + FROM Mission m + JOIN FETCH m.missionHashTags.hashTags mhts + JOIN FETCH mhts.hashTag ht + """) + List findAllHashTaggedMission(); } diff --git a/backend/src/main/java/develup/domain/solution/Solution.java b/backend/src/main/java/develup/domain/solution/Solution.java index 2fe05d77..fe3a6228 100644 --- a/backend/src/main/java/develup/domain/solution/Solution.java +++ b/backend/src/main/java/develup/domain/solution/Solution.java @@ -1,9 +1,11 @@ package develup.domain.solution; +import java.util.Set; import develup.api.exception.DevelupException; import develup.api.exception.ExceptionType; import develup.domain.member.Member; import develup.domain.mission.Mission; +import develup.domain.mission.MissionHashTag; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -122,4 +124,12 @@ public String getUrl() { public SolutionStatus getStatus() { return status; } + + public String getMissionThumbnail() { + return mission.getThumbnail(); + } + + public Set getHashTags() { + return mission.getHashTags(); + } } diff --git a/backend/src/main/java/develup/domain/solution/SolutionRepository.java b/backend/src/main/java/develup/domain/solution/SolutionRepository.java index cd3214e3..3cf6e76e 100644 --- a/backend/src/main/java/develup/domain/solution/SolutionRepository.java +++ b/backend/src/main/java/develup/domain/solution/SolutionRepository.java @@ -10,12 +10,17 @@ public interface SolutionRepository extends JpaRepository { boolean existsByMember_IdAndMission_IdAndStatus(Long memberId, Long missionId, SolutionStatus status); @Query(""" - SELECT new develup.domain.solution.SolutionSummary(s.id, m.thumbnail, s.title.value, s.description) + SELECT DISTINCT s FROM Solution s - JOIN s.mission m + JOIN FETCH s.mission m + JOIN FETCH m.missionHashTags.hashTags mhts + JOIN FETCH mhts.hashTag ht WHERE s.status = 'COMPLETED' + ORDER BY s.id DESC """) - List findCompletedSummaries(); + List findAllCompletedSolution(); + + List findAllByMember_IdAndStatus(Long memberId, SolutionStatus status); Optional findByMember_IdAndMission_IdAndStatus(Long memberId, Long missionId, SolutionStatus status); } diff --git a/backend/src/main/java/develup/domain/solution/SolutionSummary.java b/backend/src/main/java/develup/domain/solution/SolutionSummary.java deleted file mode 100644 index 90c008ca..00000000 --- a/backend/src/main/java/develup/domain/solution/SolutionSummary.java +++ /dev/null @@ -1,9 +0,0 @@ -package develup.domain.solution; - -public record SolutionSummary( - Long id, - String thumbnail, - String title, - String description -) { -} diff --git a/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java b/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java new file mode 100644 index 00000000..2c469370 --- /dev/null +++ b/backend/src/main/java/develup/domain/solution/comment/SolutionComment.java @@ -0,0 +1,142 @@ +package develup.domain.solution.comment; + +import java.time.LocalDateTime; +import develup.api.exception.DevelupException; +import develup.api.exception.ExceptionType; +import develup.domain.CreatedAtAuditableEntity; +import develup.domain.member.Member; +import develup.domain.solution.Solution; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; + +@Entity +public class SolutionComment extends CreatedAtAuditableEntity { + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "solution_id", nullable = false) + private Solution solution; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private SolutionComment parentComment; + + @Column + private LocalDateTime deletedAt; + + protected SolutionComment() { + } + + public SolutionComment( + String content, + Solution solution, + Member member, + SolutionComment parentComment, + LocalDateTime deletedAt + ) { + this(null, content, solution, member, parentComment, deletedAt); + } + + public SolutionComment( + Long id, + String content, + Solution solution, + Member member, + SolutionComment parentComment, + LocalDateTime deletedAt + ) { + super(id); + this.content = content; + this.solution = solution; + this.member = member; + this.parentComment = parentComment; + this.deletedAt = deletedAt; + } + + public static SolutionComment create(String content, Solution solution, Member member) { + return new SolutionComment(content, solution, member, null, null); + } + + public SolutionComment reply(String content, Member member) { + if (this.isDeleted()) { + throw new DevelupException(ExceptionType.COMMENT_ALREADY_DELETED); + } + + if (this.isReply()) { + throw new DevelupException(ExceptionType.CANNOT_REPLY_TO_REPLY); + } + + SolutionComment reply = new SolutionComment(); + reply.content = content; + reply.solution = this.solution; + reply.member = member; + reply.parentComment = this; + + return reply; + } + + public void delete() { + if (this.isDeleted()) { + throw new DevelupException(ExceptionType.COMMENT_ALREADY_DELETED); + } + + this.deletedAt = LocalDateTime.now(); + } + + public String getContent() { + return content; + } + + public Solution getSolution() { + return solution; + } + + public Long getSolutionId() { + return solution.getId(); + } + + public Member getMember() { + return member; + } + + public boolean isNotWrittenBy(Long memberId) { + return !member.getId().equals(memberId); + } + + public SolutionComment getParentComment() { + return parentComment; + } + + public Long getParentCommentId() { + return parentComment.getId(); + } + + public boolean isRootComment() { + return parentComment == null; + } + + public boolean isReply() { + return parentComment != null; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public boolean isNotDeleted() { + return deletedAt == null; + } + + public boolean isDeleted() { + return deletedAt != null; + } +} diff --git a/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java b/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java new file mode 100644 index 00000000..7cdc0b65 --- /dev/null +++ b/backend/src/main/java/develup/domain/solution/comment/SolutionCommentRepository.java @@ -0,0 +1,9 @@ +package develup.domain.solution.comment; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SolutionCommentRepository extends JpaRepository { + + List findAllBySolution_IdOrderByCreatedAtAsc(Long solutionId); +} diff --git a/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java b/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java index 81bc17f8..302da52f 100644 --- a/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java +++ b/backend/src/main/java/develup/infra/auth/github/GithubUserInfo.java @@ -11,7 +11,7 @@ public record GithubUserInfo( String login, String avatarUrl, @Nullable String email, - String name + @Nullable String name ) { public OAuthUserInfo toOAuthUserInfo() { diff --git a/backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java b/backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java new file mode 100644 index 00000000..377e20d5 --- /dev/null +++ b/backend/src/main/java/develup/infra/jpa/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package develup.infra.jpa; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 63b3441a..17636648 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -8,13 +8,60 @@ INSERT INTO member (email, provider, social_id, name, image_url) VALUES ('test1@gmail.com', 'GITHUB', '1234', '아톰', 'www.naver.com'); INSERT INTO mission (title, thumbnail, summary, url) -VALUES ('루터회관 흡연 단속', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-smoking.png', '담배피다 걸린 행성이를 위한 벌금 계산 미션', 'https://github.com/develup-mission/java-smoking'); +VALUES ('루터회관 흡연 단속', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-smoking.png', + '담배피다 걸린 행성이를 위한 벌금 계산 미션', 'https://github.com/develup-mission/java-smoking'); INSERT INTO mission (title, thumbnail, summary, url) -VALUES ('java-guessing-number', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', '숫자를 맞춰보자', 'https://github.com/develup-mission/java-guessing-number'); +VALUES ('숫자 맞추기 게임', 'https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.png', + '숫자를 맞춰보자', 'https://github.com/develup-mission/java-guessing-number'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) VALUES (1, 1, '릴리 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) VALUES (1, 2, '아톰 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) VALUES (1, 3, '라이언 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) VALUES (2, 1, '아톰 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) VALUES (2, 2, '릴리 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); -INSERT INTO solution (mission_id, member_id, title, description, url, status) VALUES (2, 3, '아톰 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO hash_tag (name) VALUES ('JAVA'); +INSERT INTO hash_tag (name) VALUES ('객체지향'); +INSERT INTO hash_tag (name) VALUES ('TDD'); +INSERT INTO hash_tag (name) VALUES ('클린코드'); +INSERT INTO hash_tag (name) VALUES ('레벨1'); + +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 1); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 2); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 3); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 4); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (1, 5); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 1); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 2); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 3); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 4); +INSERT INTO mission_hash_tag (mission_id, hash_tag_id) VALUES (2, 5); + +INSERT INTO solution (mission_id, member_id, title, description, url, status) +VALUES (1, 1, '릴리 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO solution (mission_id, member_id, title, description, url, status) +VALUES (1, 2, '아톰 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO solution (mission_id, member_id, title, description, url, status) +VALUES (1, 3, '라이언 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO solution (mission_id, member_id, title, description, url, status) +VALUES (2, 1, '아톰 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO solution (mission_id, member_id, title, description, url, status) +VALUES (2, 2, '릴리 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); +INSERT INTO solution (mission_id, member_id, title, description, url, status) +VALUES (2, 3, '아톰 미션 제출합니다.', '안녕하세요. 잘 부탁 드립니다.', 'https://github.com/develup/mission/pull/1', 'COMPLETED'); + +-- root-1 +-- ㄴ root-1-1 +-- ㄴ root-1-2 +-- root-2 (deleted, view o) +-- ㄴ root-2-1 +-- ㄴ root-2-2 (deleted, view x) +-- root-3 (deleted, view x) +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '1', NULL, NULL, '2021-08-01 00:00:00'); +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '2', NULL, '2021-08-01 00:00:00', '2021-08-01 00:00:00'); +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '3', NULL, '2021-08-01 00:00:00', '2021-08-01 00:00:00'); +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '2-1', 2, NULL, '2021-08-01 00:00:00'); +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '1-1', 1, NULL, '2021-08-01 00:00:00'); +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '2-2', 2, '2021-08-01 00:00:00', '2021-08-01 00:00:00'); +INSERT INTO solution_comment (solution_id, member_id, content, parent_comment_id, deleted_at, created_at) +VALUES (1, 1, '1-2', 1, NULL, '2021-08-01 00:00:00'); diff --git a/backend/src/test/java/develup/api/ApiTestSupport.java b/backend/src/test/java/develup/api/ApiTestSupport.java index f4c4ba00..86441786 100644 --- a/backend/src/test/java/develup/api/ApiTestSupport.java +++ b/backend/src/test/java/develup/api/ApiTestSupport.java @@ -1,11 +1,13 @@ package develup.api; +import com.fasterxml.jackson.databind.ObjectMapper; import develup.api.auth.AuthArgumentResolver; import develup.api.auth.CookieAuthorizationExtractor; import develup.application.auth.AuthService; import develup.application.member.MemberService; import develup.application.mission.MissionService; import develup.application.solution.SolutionService; +import develup.application.solution.comment.SolutionCommentService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -17,6 +19,9 @@ public class ApiTestSupport { @Autowired protected MockMvc mockMvc; + @Autowired + protected ObjectMapper objectMapper; + @MockBean protected AuthService authService; @@ -29,6 +34,9 @@ public class ApiTestSupport { @MockBean protected SolutionService solutionService; + @MockBean + protected SolutionCommentService solutionCommentService; + @MockBean protected CookieAuthorizationExtractor cookieAuthorizationExtractor; diff --git a/backend/src/test/java/develup/api/MissionApiTest.java b/backend/src/test/java/develup/api/MissionApiTest.java index a5064366..28b91ece 100644 --- a/backend/src/test/java/develup/api/MissionApiTest.java +++ b/backend/src/test/java/develup/api/MissionApiTest.java @@ -3,7 +3,6 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -12,7 +11,9 @@ import java.util.List; import develup.application.mission.MissionResponse; import develup.application.mission.MissionWithStartedResponse; +import develup.domain.hashtag.HashTag; import develup.domain.mission.Mission; +import develup.support.data.HashTagTestData; import develup.support.data.MissionTestData; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,9 +24,10 @@ class MissionApiTest extends ApiTestSupport { @Test @DisplayName("미션 목록을 조회한다.") void getMissions() throws Exception { + Mission mission = createMission(); List responses = List.of( - MissionResponse.from(MissionTestData.defaultMission().build()), - MissionResponse.from(MissionTestData.defaultMission().build()) + MissionResponse.from(mission), + MissionResponse.from(mission) ); BDDMockito.given(missionService.getMissions()) .willReturn(responses); @@ -36,18 +38,25 @@ void getMissions() throws Exception { .andExpect(jsonPath("$.data[0].title", equalTo("루터회관 흡연단속"))) .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data[0].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[0].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data[0].hashTags[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[0].hashTags[0].name", equalTo("JAVA"))) .andExpect(jsonPath("$.data[1].title", equalTo("루터회관 흡연단속"))) .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data[1].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[1].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[1].hashTags[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[1].hashTags[0].name", equalTo("JAVA"))) .andExpect(jsonPath("$.data.length()", is(2))); } @Test @DisplayName("미션을 조회한다.") void getMission() throws Exception { - Mission mission = MissionTestData.defaultMission().withId(1L).build(); + Mission mission = createMission(); MissionWithStartedResponse response = MissionWithStartedResponse.of(mission, false); - BDDMockito.given(missionService.getMission(any(), anyLong())) + BDDMockito.given(missionService.getMission(any(), any())) .willReturn(response); mockMvc.perform(get("/missions/1")) @@ -59,6 +68,43 @@ void getMission() throws Exception { .andExpect(jsonPath("$.data.url", equalTo("https://github.com/develup-mission/java-smoking"))) .andExpect(jsonPath("$.data.descriptionUrl", equalTo("https://raw.githubusercontent.com/develup-mission/java-smoking/main/README.md"))) - .andExpect(jsonPath("$.data.isStarted", is(false))); + .andExpect(jsonPath("$.data.isStarted", is(false))) + .andExpect(jsonPath("$.data.hashTags[0].id", equalTo(1))) + .andExpect(jsonPath("$.data.hashTags[0].name", equalTo("JAVA"))); + } + + @Test + @DisplayName("사용자가 시작한 미션 목록을 조회한다.") + void getInProgressMissions() throws Exception { + List responses = List.of( + MissionResponse.from(MissionTestData.defaultMission().build()), + MissionResponse.from(MissionTestData.defaultMission().build()) + ); + BDDMockito.given(missionService.getInProgressMissions(any())) + .willReturn(responses); + + mockMvc.perform(get("/missions/in-progress")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[0].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[0].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data[1].title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data[1].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[1].url", equalTo("https://github.com/develup-mission/java-smoking"))) + .andExpect(jsonPath("$.data[1].summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) + .andExpect(jsonPath("$.data.length()", is(2))); + } + + private Mission createMission() { + HashTag hashTag = HashTagTestData.defaultHashTag() + .withId(1L) + .build(); + + return MissionTestData.defaultMission() + .withId(1L) + .withHashTags(List.of(hashTag)) + .build(); } } diff --git a/backend/src/test/java/develup/api/SolutionApiTest.java b/backend/src/test/java/develup/api/SolutionApiTest.java index 840078ba..5bb1eef2 100644 --- a/backend/src/test/java/develup/api/SolutionApiTest.java +++ b/backend/src/test/java/develup/api/SolutionApiTest.java @@ -10,58 +10,52 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; -import com.fasterxml.jackson.databind.ObjectMapper; +import develup.application.solution.MySolutionResponse; import develup.application.solution.SolutionResponse; import develup.application.solution.StartSolutionRequest; import develup.application.solution.SubmitSolutionRequest; +import develup.application.solution.SummarizedSolutionResponse; +import develup.domain.hashtag.HashTag; +import develup.domain.member.Member; +import develup.domain.mission.Mission; import develup.domain.solution.Solution; -import develup.domain.solution.SolutionSummary; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.BDDMockito; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; class SolutionApiTest extends ApiTestSupport { - @Autowired - private ObjectMapper objectMapper; - @Test @DisplayName("솔루션 목록을 조회한다.") void getSolutions() throws Exception { - List summaries = List.of( - new SolutionSummary(1L, "thumbnail", "value", "description"), - new SolutionSummary(2L, "thumbnail", "value", "description") + List responses = List.of( + SummarizedSolutionResponse.from(createSolution()), + SummarizedSolutionResponse.from(createSolution()) ); BDDMockito.given(solutionService.getCompletedSummaries()) - .willReturn(summaries); + .willReturn(responses); mockMvc.perform(get("/solutions")) .andDo(print()) .andExpect(status().isOk()) .andExpect(jsonPath("$.data[0].id", equalTo(1))) - .andExpect(jsonPath("$.data[0].title", equalTo("value"))) - .andExpect(jsonPath("$.data[0].thumbnail", equalTo("thumbnail"))) - .andExpect(jsonPath("$.data[0].description", equalTo("description"))) - .andExpect(jsonPath("$.data[1].id", equalTo(2))) - .andExpect(jsonPath("$.data[1].title", equalTo("value"))) - .andExpect(jsonPath("$.data[1].thumbnail", equalTo("thumbnail"))) - .andExpect(jsonPath("$.data[1].description", equalTo("description"))) + .andExpect(jsonPath("$.data[0].title", equalTo("루터회관 흡연단속 제출합니다."))) + .andExpect(jsonPath("$.data[0].thumbnail", equalTo("https://thumbnail.com/1.png"))) + .andExpect(jsonPath("$.data[0].description", equalTo("안녕하세요. 피드백 잘 부탁 드려요."))) + .andExpect(jsonPath("$.data[0].hashTags[0].id", is(1))) + .andExpect(jsonPath("$.data[0].hashTags[0].name", equalTo("JAVA"))) .andExpect(jsonPath("$.data.length()", is(2))); } @Test @DisplayName("솔루션을 조회한다.") void getSolution() throws Exception { - SolutionResponse response = SolutionResponse.from(SolutionTestData.defaultSolution() - .withMission(MissionTestData.defaultMission().withId(1L).build()) - .withMember(MemberTestData.defaultMember().withId(1L).build()) - .withId(1L) - .build()); + SolutionResponse response = SolutionResponse.from(createSolution()); BDDMockito.given(solutionService.getById(any())) .willReturn(response); @@ -78,19 +72,15 @@ void getSolution() throws Exception { .andExpect(jsonPath("$.data.member.imageUrl", equalTo("image.com/1.jpg"))) .andExpect(jsonPath("$.data.mission.id", equalTo(1))) .andExpect(jsonPath("$.data.mission.title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data.mission.summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } @Test @DisplayName("솔루션을 제출한다.") - void createSolution() throws Exception { - Solution solution = SolutionTestData.defaultSolution() - .withMission(MissionTestData.defaultMission().withId(1L).build()) - .withMember(MemberTestData.defaultMember().withId(1L).build()) - .withId(1L) - .build(); - SolutionResponse response = SolutionResponse.from(solution); + void submitSolution() throws Exception { + SolutionResponse response = SolutionResponse.from(createSolution()); SubmitSolutionRequest request = new SubmitSolutionRequest( 1L, "value", @@ -114,6 +104,7 @@ void createSolution() throws Exception { .andExpect(jsonPath("$.data.member.imageUrl", equalTo("image.com/1.jpg"))) .andExpect(jsonPath("$.data.mission.id", equalTo(1))) .andExpect(jsonPath("$.data.mission.title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data.mission.summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } @@ -121,12 +112,7 @@ void createSolution() throws Exception { @Test @DisplayName("솔루션을 시작한다.") void startSolution() throws Exception { - Solution solution = SolutionTestData.defaultSolution() - .withMission(MissionTestData.defaultMission().withId(1L).build()) - .withMember(MemberTestData.defaultMember().withId(1L).build()) - .withId(1L) - .build(); - SolutionResponse response = SolutionResponse.start(solution); + SolutionResponse response = SolutionResponse.start(createSolution()); BDDMockito.given(solutionService.startMission(any(), any())) .willReturn(response); StartSolutionRequest request = new StartSolutionRequest(1L); @@ -148,7 +134,48 @@ void startSolution() throws Exception { .andExpect(jsonPath("$.data.member.imageUrl", equalTo("image.com/1.jpg"))) .andExpect(jsonPath("$.data.mission.id", equalTo(1))) .andExpect(jsonPath("$.data.mission.title", equalTo("루터회관 흡연단속"))) + .andExpect(jsonPath("$.data.mission.summary", equalTo("담배피다 걸린 행성이를 위한 벌금 계산 미션"))) .andExpect(jsonPath("$.data.mission.thumbnail", equalTo("https://thumbnail.com/1.png"))) .andExpect(jsonPath("$.data.mission.url", equalTo("https://github.com/develup-mission/java-smoking"))); } + + @Test + @DisplayName("나의 솔루션 목록을 조회한다.") + void getMySolutions() throws Exception { + List mySolutions = List.of( + new MySolutionResponse(1L, "thumbnail", "title"), + new MySolutionResponse(2L, "thumbnail", "title") + ); + BDDMockito.given(solutionService.getSubmittedSolutionsByMemberId(any())) + .willReturn(mySolutions); + + mockMvc.perform(get("/solutions/mine")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[0].thumbnail", equalTo("thumbnail"))) + .andExpect(jsonPath("$.data[0].title", equalTo("title"))) + .andExpect(jsonPath("$.data[1].id", equalTo(2))) + .andExpect(jsonPath("$.data[1].thumbnail", equalTo("thumbnail"))) + .andExpect(jsonPath("$.data[1].title", equalTo("title"))); + } + + private Solution createSolution() { + HashTag hashTag = HashTagTestData.defaultHashTag() + .withId(1L) + .build(); + Member member = MemberTestData.defaultMember() + .withId(1L) + .build(); + Mission mission = MissionTestData.defaultMission() + .withId(1L) + .withHashTags(List.of(hashTag)) + .build(); + + return SolutionTestData.defaultSolution() + .withId(1L) + .withMission(mission) + .withMember(member) + .build(); + } } diff --git a/backend/src/test/java/develup/api/SolutionCommentApiTest.java b/backend/src/test/java/develup/api/SolutionCommentApiTest.java new file mode 100644 index 00000000..e457f9ed --- /dev/null +++ b/backend/src/test/java/develup/api/SolutionCommentApiTest.java @@ -0,0 +1,117 @@ +package develup.api; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import develup.application.member.MemberResponse; +import develup.application.solution.comment.CreateSolutionCommentResponse; +import develup.application.solution.comment.SolutionCommentRepliesResponse; +import develup.application.solution.comment.SolutionCommentRequest; +import develup.application.solution.comment.SolutionReplyResponse; +import develup.domain.member.Member; +import develup.support.data.MemberTestData; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.http.MediaType; + +class SolutionCommentApiTest extends ApiTestSupport { + + @Test + @DisplayName("댓글 목록을 조회한다.") + void getComments() throws Exception { + SolutionReplyResponse replyResponse = createReplyResponse(); + List replyResponses = List.of(replyResponse); + List responses = List.of(createRootCommentResponse(replyResponses)); + + BDDMockito.given(solutionCommentService.getCommentsWithReplies(any())) + .willReturn(responses); + + mockMvc.perform( + get("/solutions/1/comments")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id", equalTo(1))) + .andExpect(jsonPath("$.data[0].content", equalTo("content"))) + .andExpect(jsonPath("$.data[0].replies", hasSize(1))) + .andExpect(jsonPath("$.data[0].replies[0].id", equalTo(2))); + } + + @Test + @DisplayName("댓글을 추가한다.") + void addComment() throws Exception { + Member member = MemberTestData.defaultMember().withId(1L).build(); + MemberResponse memberResponse = MemberResponse.from(member); + CreateSolutionCommentResponse response = new CreateSolutionCommentResponse( + 1L, + 1L, + null, + "content", + memberResponse, + LocalDateTime.now() + ); + BDDMockito.given(solutionCommentService.addComment(any(), any(), any())) + .willReturn(response); + + SolutionCommentRequest request = new SolutionCommentRequest("content", null); + mockMvc.perform( + post("/solutions/1/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id", equalTo(1))) + .andExpect(jsonPath("$.data.content", equalTo("content"))) + .andExpect(jsonPath("$.data.parentCommentId", equalTo(null))) + .andExpect(jsonPath("$.data.member.id", equalTo(1))) + .andExpect(jsonPath("$.data.createdAt").exists()); + } + + @Test + @DisplayName("댓글을 삭제한다.") + void deleteComment() throws Exception { + BDDMockito.doNothing() + .when(solutionCommentService) + .deleteComment(any(), any()); + + mockMvc.perform( + delete("/solutions/comments/1") + .cookie(new Cookie("token", "mock_token")) + ) + .andDo(print()) + .andExpect(status().isNoContent()); + } + + private SolutionCommentRepliesResponse createRootCommentResponse(List replyResponses) { + return new SolutionCommentRepliesResponse( + 1L, + 1L, + "content", + MemberResponse.from(MemberTestData.defaultMember().withId(1L).build()), + replyResponses, + LocalDateTime.now(), + false + ); + } + + private SolutionReplyResponse createReplyResponse() { + return new SolutionReplyResponse( + 2L, + 1L, + 1L, + "reply", + MemberResponse.from(MemberTestData.defaultMember().withId(1L).build()), + LocalDateTime.now() + ); + } +} diff --git a/backend/src/test/java/develup/application/mission/MissionServiceTest.java b/backend/src/test/java/develup/application/mission/MissionServiceTest.java index b32e9af0..43a14eb1 100644 --- a/backend/src/test/java/develup/application/mission/MissionServiceTest.java +++ b/backend/src/test/java/develup/application/mission/MissionServiceTest.java @@ -7,12 +7,16 @@ import java.util.List; import develup.api.exception.DevelupException; import develup.application.auth.Accessor; +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; import develup.domain.mission.MissionRepository; +import develup.domain.solution.Solution; import develup.domain.solution.SolutionRepository; import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; @@ -34,11 +38,14 @@ class MissionServiceTest extends IntegrationTestSupport { @Autowired private SolutionRepository solutionRepository; + @Autowired + private HashTagRepository hashTagRepository; + @Test @DisplayName("미션 목록을 조회한다.") void getMissions() { - missionRepository.save(MissionTestData.defaultMission().build()); - missionRepository.save(MissionTestData.defaultMission().build()); + createMission(); + createMission(); List responses = missionService.getMissions(); @@ -56,7 +63,8 @@ void getMissionFailWhenInvalidMissionId() { @Test @DisplayName("비로그인 사용자가 미션 조회 시 시작 상태는 false이다.") void getMission_guest() { - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Mission mission = createMission(); + MissionWithStartedResponse response = missionService.getMission(Accessor.GUEST, mission.getId()); assertThat(response.isStarted()).isFalse(); @@ -65,7 +73,7 @@ void getMission_guest() { @Test @DisplayName("미션을 시작하지 않은 로그인 사용자가 미션 조회 시 시작 상태는 false이다.") void getMission_notStarted() { - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Mission mission = createMission(); Member member = memberRepository.save(MemberTestData.defaultMember().build()); Accessor accessor = new Accessor(member.getId()); @@ -78,16 +86,51 @@ void getMission_notStarted() { @DisplayName("미션을 시작한 로그인 사용자가 미션 조회 시 시작 상태는 true이다.") void getMission_started() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); - Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); - solutionRepository.save(SolutionTestData.defaultSolution() + Mission mission = createMission(); + Solution solution = SolutionTestData.defaultSolution() .withMember(member) .withMission(mission) .withStatus(IN_PROGRESS) - .build()); + .build(); + solutionRepository.save(solution); Accessor accessor = new Accessor(member.getId()); MissionWithStartedResponse response = missionService.getMission(accessor, mission.getId()); assertThat(response.isStarted()).isTrue(); } + + @Test + @DisplayName("사용자가 시작한 미션 목록을 조회한다.") + void getInProgressMissions() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Solution solution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(IN_PROGRESS) + .build(); + + Mission otherMission = missionRepository.save(MissionTestData.defaultMission().withTitle("다른 미션").build()); + Solution otherSolution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(otherMission) + .withStatus(IN_PROGRESS) + .build(); + + solutionRepository.saveAll(List.of(solution, otherSolution)); + List inProgressMissions = missionService.getInProgressMissions(member.getId()); + + assertThat(inProgressMissions).hasSize(2); + } + + private Mission createMission() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission mission = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + + return missionRepository.save(mission); + } } diff --git a/backend/src/test/java/develup/application/solution/SolutionServiceTest.java b/backend/src/test/java/develup/application/solution/SolutionServiceTest.java index 97ba9710..386167bb 100644 --- a/backend/src/test/java/develup/application/solution/SolutionServiceTest.java +++ b/backend/src/test/java/develup/application/solution/SolutionServiceTest.java @@ -7,7 +7,6 @@ import java.util.Optional; import develup.api.exception.DevelupException; -import develup.application.auth.Accessor; import develup.domain.member.Member; import develup.domain.member.MemberRepository; import develup.domain.mission.Mission; @@ -118,10 +117,9 @@ void create() { .build(); solutionRepository.save(solution); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = getSolutionRequest(); - SolutionResponse solutionResponse = solutionService.submit(accessor.id(), submitSolutionRequest); + SolutionResponse solutionResponse = solutionService.submit(member.getId(), submitSolutionRequest); assertAll( () -> assertEquals(solutionResponse.id(), 1L), @@ -137,7 +135,6 @@ void create() { @DisplayName("미션 제출 시 value 이 비어있으면 예외가 발생한다.") void createFailWhenTitleIsBlank() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = new SubmitSolutionRequest( 1L, "", @@ -145,7 +142,7 @@ void createFailWhenTitleIsBlank() { "https://github.com/develup-mission/java-smoking/pull/1" ); - assertThatThrownBy(() -> solutionService.submit(accessor.id(), submitSolutionRequest)) + assertThatThrownBy(() -> solutionService.submit(member.getId(), submitSolutionRequest)) .isInstanceOf(RuntimeException.class); } @@ -162,7 +159,6 @@ void createFailWhenWrongPRUrl() { solutionRepository.save(solution); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = new SubmitSolutionRequest( mission.getId(), "value", @@ -170,7 +166,7 @@ void createFailWhenWrongPRUrl() { "url" ); - assertThatThrownBy(() -> solutionService.submit(accessor.id(), submitSolutionRequest)) + assertThatThrownBy(() -> solutionService.submit(member.getId(), submitSolutionRequest)) .isInstanceOf(DevelupException.class) .hasMessage("올바르지 않은 주소입니다."); } @@ -188,7 +184,6 @@ void createFailWhenWrongPRUrlRepository() { solutionRepository.save(solution); - Accessor accessor = new Accessor(member.getId()); SubmitSolutionRequest submitSolutionRequest = new SubmitSolutionRequest( mission.getId(), "value", @@ -196,11 +191,50 @@ void createFailWhenWrongPRUrlRepository() { "https://github.com/develup-mission/java-undefinedMission/pull/1" ); - assertThatThrownBy(() -> solutionService.submit(accessor.id(), submitSolutionRequest)) + assertThatThrownBy(() -> solutionService.submit(member.getId(), submitSolutionRequest)) .isInstanceOf(DevelupException.class) .hasMessage("올바르지 않은 주소입니다."); } + @Test + @DisplayName("나의 솔루션 리스트를 조회한다.") + void getSubmittedSolutionsByMemberId() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Solution solution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.COMPLETED) + .build(); + + solutionRepository.save(solution); + + assertThat(solutionService.getSubmittedSolutionsByMemberId(member.getId())).hasSize(1); + } + + @Test + @DisplayName("나의 솔루션 리스트 조회 시, 제출 완료 상태가 아닌 솔루션은 조회되지 않는다.") + void shouldNotRetrieveSolutionsThatAreNotCompleted() { + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Solution inProgress = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.IN_PROGRESS) + .build(); + + Solution completed = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(SolutionStatus.COMPLETED) + .build(); + + solutionRepository.save(inProgress); + solutionRepository.save(completed); + + assertThat(solutionService.getSubmittedSolutionsByMemberId(member.getId())).hasSize(1); + } + private SubmitSolutionRequest getSolutionRequest() { return new SubmitSolutionRequest( 1L, diff --git a/backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java b/backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java new file mode 100644 index 00000000..989b11f2 --- /dev/null +++ b/backend/src/test/java/develup/application/solution/comment/CommentGroupingServiceTest.java @@ -0,0 +1,75 @@ +package develup.application.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import java.util.List; +import develup.domain.solution.comment.SolutionComment; +import develup.support.IntegrationTestSupport; +import develup.support.data.SolutionCommentTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class CommentGroupingServiceTest extends IntegrationTestSupport { + + @Autowired + private CommentGroupingService commentGroupingService; + + /** + * [계층 구조] + * root1 + * - root1reply1 (삭제됨 - 안보임) + * - root1reply2 + * root2 (삭제됨 - 보임) + * - root2reply1 + * - root2reply2 + */ + @Test + @DisplayName("댓글을 그룹화한다.") + void groupReplies() { + List comments = createComments(); + + List rootCommentResponses = commentGroupingService.groupReplies(comments); + + assertAll( + () -> assertThat(rootCommentResponses).hasSize(2), + () -> assertThat(rootCommentResponses.get(0).replies()).hasSize(1), + () -> assertThat(rootCommentResponses.get(1).replies()).hasSize(2), + () -> assertThat(rootCommentResponses.get(0).replies().get(0)).isNotNull(), + () -> assertThat(rootCommentResponses.get(1).isDeleted()).isTrue(), + () -> assertThat(rootCommentResponses.get(1).replies().get(0)).isNotNull(), + () -> assertThat(rootCommentResponses.get(1).replies().get(1)).isNotNull() + ); + } + + private List createComments() { + SolutionComment root1 = SolutionCommentTestData.defaultSolutionComment() + .withId(1L) + .build(); + SolutionComment root2 = SolutionCommentTestData.defaultSolutionComment() + .withId(2L) + .withDeletedAt(LocalDateTime.now()) + .build(); + SolutionComment root1reply1 = SolutionCommentTestData.defaultSolutionComment() + .withId(3L) + .withDeletedAt(LocalDateTime.now()) + .withParentComment(root1) + .build(); + SolutionComment root1reply2 = SolutionCommentTestData.defaultSolutionComment() + .withId(4L) + .withParentComment(root1) + .build(); + SolutionComment root2reply1 = SolutionCommentTestData.defaultSolutionComment() + .withId(5L) + .withParentComment(root2) + .build(); + SolutionComment root2reply2 = SolutionCommentTestData.defaultSolutionComment() + .withId(6L) + .withParentComment(root2) + .build(); + + return List.of(root1, root2, root1reply1, root1reply2, root2reply1, root2reply2); + } +} diff --git a/backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java b/backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java new file mode 100644 index 00000000..4f05702c --- /dev/null +++ b/backend/src/test/java/develup/application/solution/comment/SolutionCommentRepliesResponseTest.java @@ -0,0 +1,66 @@ +package develup.application.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import java.util.List; +import develup.application.member.MemberResponse; +import develup.domain.solution.comment.SolutionComment; +import develup.support.data.SolutionCommentTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SolutionCommentRepliesResponseTest { + + public static final MemberResponse EMPTY_MEMBER = new MemberResponse(0L, "", "", ""); + + @Test + @DisplayName("SolutionCommentRepliesResponse로 변환한다.") + void toSolutionCommentRepliesResponse() { + SolutionComment rootComment = SolutionCommentTestData.defaultSolutionComment() + .withId(1L) + .build(); + SolutionComment reply = SolutionCommentTestData.defaultSolutionComment() + .withId(2L) + .withParentComment(rootComment) + .build(); + + SolutionCommentRepliesResponse rootCommentResponse = SolutionCommentRepliesResponse.of( + rootComment, + List.of(reply) + ); + + SolutionReplyResponse replyResponse = SolutionReplyResponse.from(reply); + assertAll( + () -> assertThat(rootCommentResponse.replies()).containsExactly(replyResponse), + () -> assertThat(rootCommentResponse.isDeleted()).isFalse() + ); + } + + @Test + @DisplayName("SolutionCommentRepliesResponse로 변환 시 삭제된 댓글인 경우 내용 숨김 처리한다.") + void hideContentWhenDeleted() { + SolutionComment rootComment = SolutionCommentTestData.defaultSolutionComment() + .withId(1L) + .withDeletedAt(LocalDateTime.now()) + .build(); + SolutionComment reply = SolutionCommentTestData.defaultSolutionComment() + .withId(2L) + .withParentComment(rootComment) + .build(); + + SolutionCommentRepliesResponse rootCommentResponse = SolutionCommentRepliesResponse.of( + rootComment, + List.of(reply) + ); + + SolutionReplyResponse replyResponse = SolutionReplyResponse.from(reply); + assertAll( + () -> assertThat(rootCommentResponse.replies()).containsExactly(replyResponse), + () -> assertThat(rootCommentResponse.isDeleted()).isTrue(), + () -> assertThat(rootCommentResponse.content()).isEmpty(), + () -> assertThat(rootCommentResponse.member()).isEqualTo(EMPTY_MEMBER) + ); + } +} diff --git a/backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java b/backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java new file mode 100644 index 00000000..150e51ad --- /dev/null +++ b/backend/src/test/java/develup/application/solution/comment/SolutionCommentServiceTest.java @@ -0,0 +1,170 @@ +package develup.application.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import develup.api.exception.DevelupException; +import develup.domain.member.Member; +import develup.domain.member.MemberRepository; +import develup.domain.mission.Mission; +import develup.domain.mission.MissionRepository; +import develup.domain.solution.Solution; +import develup.domain.solution.SolutionRepository; +import develup.domain.solution.comment.SolutionComment; +import develup.domain.solution.comment.SolutionCommentRepository; +import develup.support.IntegrationTestSupport; +import develup.support.data.MemberTestData; +import develup.support.data.MissionTestData; +import develup.support.data.SolutionCommentTestData; +import develup.support.data.SolutionTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class SolutionCommentServiceTest extends IntegrationTestSupport { + + @Autowired + private SolutionCommentService solutionCommentService; + + @Autowired + private SolutionCommentRepository solutionCommentRepository; + + @Autowired + private SolutionRepository solutionRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private MissionRepository missionRepository; + + @Test + @DisplayName("댓글을 조회한다.") + void getComment() { + SolutionComment solutionComment = createSolutionComment(); + + SolutionComment foundSolutionComment = solutionCommentService.getComment(solutionComment.getId()); + + assertThat(foundSolutionComment).isEqualTo(solutionComment); + } + + @Test + @DisplayName("댓글 조회 시 존재하지 않는 경우 예외가 발생한다.") + void getComment_notFound() { + Long unknownId = -1L; + + assertThatThrownBy(() -> solutionCommentService.getComment(unknownId)) + .isInstanceOf(DevelupException.class) + .hasMessage("존재하지 않는 댓글입니다."); + } + + @Test + @DisplayName("댓글 조회 시 삭제된 댓글일 경우 예외가 발생한다.") + void getCommentFailedWhenDeleted() { + Solution solution = createSolution(); + Member member = solution.getMember(); + SolutionComment deletedComment = SolutionCommentTestData.defaultSolutionComment() + .withSolution(solution) + .withMember(member) + .withDeletedAt(LocalDateTime.now()) + .build(); + solutionCommentRepository.save(deletedComment); + + Long commentId = deletedComment.getId(); + assertThatThrownBy(() -> solutionCommentService.getComment(commentId)) + .isInstanceOf(DevelupException.class) + .hasMessage("존재하지 않는 댓글입니다."); + } + + @Test + @DisplayName("댓글을 추가한다.") + void addComment() { + Solution solution = createSolution(); + Member member = solution.getMember(); + + Long solutionId = solution.getId(); + Long memberId = member.getId(); + SolutionCommentRequest request = new SolutionCommentRequest( + "댓글입니다.", + null + ); + CreateSolutionCommentResponse response = solutionCommentService.addComment(solutionId, request, memberId); + + assertAll( + () -> assertThat(solutionCommentRepository.findAll()).hasSize(1), + () -> assertThat(response.parentCommentId()).isNull() + ); + } + + @Test + @DisplayName("답글을 추가한다.") + void addReply() { + SolutionComment solutionComment = createSolutionComment(); + Member member = solutionComment.getMember(); + Solution solution = solutionComment.getSolution(); + + Long solutionId = solution.getId(); + Long memberId = member.getId(); + SolutionCommentRequest request = new SolutionCommentRequest( + "답글입니다.", + solutionComment.getId() + ); + CreateSolutionCommentResponse response = solutionCommentService.addComment(solutionId, request, memberId); + + assertAll( + () -> assertThat(solutionCommentRepository.findAll()).hasSize(2), + () -> assertThat(response.parentCommentId()).isEqualTo(solutionComment.getId()) + ); + } + + @Test + @DisplayName("댓글을 삭제한다.") + void deleteComment() { + SolutionComment solutionComment = createSolutionComment(); + + Long memberId = solutionComment.getMember().getId(); + Long commentId = solutionComment.getId(); + solutionCommentService.deleteComment(commentId, memberId); + + assertThat(solutionCommentRepository.findById(commentId)) + .map(SolutionComment::isDeleted) + .hasValue(true); + } + + @Test + @DisplayName("댓글을 삭제 시 작성자가 아닌 경우 예외가 발생한다.") + void deleteComment_notWrittenBy() { + SolutionComment solutionComment = createSolutionComment(); + + Long nonWriterId = -1L; + Long commentId = solutionComment.getId(); + + assertThatThrownBy(() -> solutionCommentService.deleteComment(commentId, nonWriterId)) + .isInstanceOf(DevelupException.class) + .hasMessage("작성자만 댓글을 삭제할 수 있습니다."); + } + + private Solution createSolution() { + Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Solution solution = SolutionTestData.defaultSolution() + .withMission(mission) + .withMember(member) + .build(); + + return solutionRepository.save(solution); + } + + private SolutionComment createSolutionComment() { + Solution solution = createSolution(); + SolutionComment solutionComment = SolutionCommentTestData.defaultSolutionComment() + .withSolution(solution) + .withMember(solution.getMember()) + .build(); + solutionCommentRepository.save(solutionComment); + + return solutionComment; + } +} diff --git a/backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java b/backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java new file mode 100644 index 00000000..c1239692 --- /dev/null +++ b/backend/src/test/java/develup/domain/mission/MissionHashTagsTest.java @@ -0,0 +1,78 @@ +package develup.domain.mission; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Collections; +import java.util.List; +import develup.api.exception.DevelupException; +import develup.domain.hashtag.HashTag; +import develup.support.data.HashTagTestData; +import develup.support.data.MissionTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MissionHashTagsTest { + + @Test + @DisplayName("중복된 해시태그로 생성할 수 없다.") + void cantCreateWithDuplicatedHashTags() { + HashTag java = HashTagTestData.defaultHashTag() + .withId(1L) + .withName("JAVA") + .build(); + List duplicatedHashTags = List.of(java, java); + Mission mission = MissionTestData.defaultMission().build(); + + assertThatThrownBy(() -> new MissionHashTags(mission, duplicatedHashTags)) + .isInstanceOf(DevelupException.class) + .hasMessage("중복된 해시태그입니다."); + } + + @Test + @DisplayName("해시 태깅을 할 수 있다.") + void addAll() { + List tags = List.of( + HashTagTestData.defaultHashTag().withName("JAVA").build(), + HashTagTestData.defaultHashTag().withName("JAVASCRIPT").build() + ); + Mission mission = MissionTestData.defaultMission().build(); + MissionHashTags missionHashTags = new MissionHashTags(mission, Collections.emptyList()); + + missionHashTags.addAll(mission, tags); + + assertThat(missionHashTags.getHashTags()).hasSize(2); + } + + @Test + @DisplayName("중복으로 들어온 해시태그를 등록할 수 없다.") + void duplicatedTag() { + HashTag java = HashTagTestData.defaultHashTag() + .withId(1L) + .withName("JAVA") + .build(); + List duplicatedHashTags = List.of(java, java); + Mission mission = MissionTestData.defaultMission().build(); + MissionHashTags missionHashTags = new MissionHashTags(mission, Collections.emptyList()); + + assertThatThrownBy(() -> missionHashTags.addAll(mission, duplicatedHashTags)) + .isInstanceOf(DevelupException.class) + .hasMessage("중복된 해시태그입니다."); + } + + @Test + @DisplayName("이미 존재하는 태그는 등록할 수 없다.") + void alreadyExistTag() { + HashTag java = HashTagTestData.defaultHashTag() + .withId(1L) + .withName("JAVA") + .build(); + Mission mission = MissionTestData.defaultMission().build(); + MissionHashTags missionHashTags = new MissionHashTags(mission, Collections.emptyList()); + missionHashTags.addAll(mission, List.of(java)); + + assertThatThrownBy(() -> missionHashTags.addAll(mission, List.of(java))) + .isInstanceOf(DevelupException.class) + .hasMessage("중복된 해시태그입니다."); + } +} diff --git a/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java b/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java new file mode 100644 index 00000000..e5201a7e --- /dev/null +++ b/backend/src/test/java/develup/domain/mission/MissionRepositoryTest.java @@ -0,0 +1,63 @@ +package develup.domain.mission; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; +import develup.domain.hashtag.HashTag; +import develup.domain.hashtag.HashTagRepository; +import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; +import develup.support.data.MissionTestData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class MissionRepositoryTest extends IntegrationTestSupport { + + @Autowired + private MissionRepository missionRepository; + + @Autowired + private HashTagRepository hashTagRepository; + + @Test + @DisplayName("주어진 식별자에 해당하고, 해시태그가 존재하는 미션을 찾는다. ") + void findHashTaggedMissionById() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission hashTaggedMission = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + Mission nonTaggedMission = MissionTestData.defaultMission().build(); + missionRepository.saveAll(List.of(hashTaggedMission, nonTaggedMission)); + + Optional hashTaggedFound = missionRepository.findHashTaggedMissionById(hashTaggedMission.getId()); + Optional noneTaggedFound = missionRepository.findHashTaggedMissionById(nonTaggedMission.getId()); + + Assertions.assertAll( + () -> assertThat(hashTaggedFound) + .isPresent() + .map(it -> it.getHashTags().size()) + .hasValue(1), + () -> assertThat(noneTaggedFound).isEmpty() + ); + } + + @Test + @DisplayName("해시태그가 존재하는 모든 미션을 조회한다.") + void findAllHashTaggedMission() { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().build()); + Mission mission1 = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + Mission mission2 = MissionTestData.defaultMission() + .withHashTags(List.of(hashTag)) + .build(); + missionRepository.saveAll(List.of(mission1, mission2)); + + List missions = missionRepository.findAllHashTaggedMission(); + + assertThat(missions).hasSize(2); + } +} diff --git a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java index 4057d56c..60044a41 100644 --- a/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java +++ b/backend/src/test/java/develup/domain/solution/SolutionRepositoryTest.java @@ -5,11 +5,14 @@ import java.util.List; import java.util.Optional; +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; import develup.domain.mission.MissionRepository; import develup.support.IntegrationTestSupport; +import develup.support.data.HashTagTestData; import develup.support.data.MemberTestData; import develup.support.data.MissionTestData; import develup.support.data.SolutionTestData; @@ -28,6 +31,9 @@ class SolutionRepositoryTest extends IntegrationTestSupport { @Autowired private MissionRepository missionRepository; + @Autowired + private HashTagRepository hashTagRepository; + @Test @DisplayName("멤버 식별자와 미션 식별자와 특정 상태에 해당하는 솔루션이 존재하는지 확인한다. ") void exists() { @@ -61,13 +67,13 @@ void exists() { } @Test - @DisplayName("완료된 솔루션 요약 데이터를 조회할 수 있다.") - void findCompletedSummaries() { + @DisplayName("완료된 솔루션을 조회할 수 있다.") + void findAllCompletedSolution() { createSolution(SolutionStatus.COMPLETED); createSolution(SolutionStatus.COMPLETED); createSolution(SolutionStatus.IN_PROGRESS); - List actual = solutionRepository.findCompletedSummaries(); + List actual = solutionRepository.findAllCompletedSolution(); assertThat(actual).hasSize(2); } @@ -114,9 +120,40 @@ void findByMember_IdAndMission_IdAndStatus() { ); } - private void createSolution(SolutionStatus status) { + @Test + @DisplayName("멤버 식별자와 특정 상태에 해당하는 솔루션을 조회한다.") + void findByMember_IdAndStatus() { Member member = memberRepository.save(MemberTestData.defaultMember().build()); Mission mission = missionRepository.save(MissionTestData.defaultMission().build()); + SolutionStatus inProgress = SolutionStatus.IN_PROGRESS; + SolutionStatus completed = SolutionStatus.COMPLETED; + Solution inProgressSolution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(inProgress) + .build(); + Solution completeSolution = SolutionTestData.defaultSolution() + .withMember(member) + .withMission(mission) + .withStatus(completed) + .build(); + solutionRepository.save(inProgressSolution); + solutionRepository.save(completeSolution); + + List solutionInProgress = solutionRepository.findAllByMember_IdAndStatus(member.getId(), inProgress); + List solutionCompleted = solutionRepository.findAllByMember_IdAndStatus(member.getId(), completed); + + assertAll( + () -> assertThat(solutionInProgress).hasSize(1), + () -> assertThat(solutionCompleted).hasSize(1) + ); + } + + private void createSolution(SolutionStatus status) { + HashTag hashTag = hashTagRepository.save(HashTagTestData.defaultHashTag().withName("A").build()); + Member member = memberRepository.save(MemberTestData.defaultMember().build()); + Mission mission = MissionTestData.defaultMission().withHashTags(List.of(hashTag)).build(); + missionRepository.save(mission); Solution solution = SolutionTestData.defaultSolution() .withMember(member) diff --git a/backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java b/backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java new file mode 100644 index 00000000..0095dba6 --- /dev/null +++ b/backend/src/test/java/develup/domain/solution/comment/SolutionCommentTest.java @@ -0,0 +1,100 @@ +package develup.domain.solution.comment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import develup.api.exception.DevelupException; +import develup.domain.member.Member; +import develup.support.data.MemberTestData; +import develup.support.data.SolutionCommentTestData; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SolutionCommentTest { + + @Test + @DisplayName("댓글을 생성할 수 있다.") + void create() { + String content = "댓글입니다."; + SolutionComment comment = SolutionCommentTestData.defaultSolutionComment() + .withContent(content) + .build(); + + assertAll( + () -> assertThat(comment.getContent()).isEqualTo(content), + () -> assertThat(comment.getParentComment()).isNull(), + () -> assertThat(comment.getDeletedAt()).isNull() + ); + } + + @Test + @DisplayName("댓글을 삭제할 수 있다.") + void delete() { + SolutionComment comment = SolutionCommentTestData.defaultSolutionComment().build(); + + comment.delete(); + + assertThat(comment.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("삭제된 댓글은 삭제할 수 없다.") + void deleteFailedWhenAlreadyDeleted() { + SolutionComment comment = SolutionCommentTestData.defaultSolutionComment() + .withDeletedAt(LocalDateTime.now()) + .build(); + + assertThatThrownBy(comment::delete) + .isInstanceOf(DevelupException.class) + .hasMessage("이미 삭제된 댓글입니다."); + } + + @Test + @DisplayName("댓글에 답글을 달 수 있다.") + void reply() { + SolutionComment parentComment = SolutionCommentTestData.defaultSolutionComment().build(); + String content = "답글입니다."; + Member member = MemberTestData.defaultMember().build(); + + SolutionComment reply = parentComment.reply(content, member); + + assertAll( + () -> assertThat(reply.getContent()).isEqualTo(content), + () -> assertThat(reply.getSolution()).isEqualTo(parentComment.getSolution()), + () -> assertThat(reply.getMember()).isEqualTo(member), + () -> assertThat(reply.getParentComment()).isEqualTo(parentComment), + () -> assertThat(reply.getDeletedAt()).isNull() + ); + } + + @Test + @DisplayName("삭제된 댓글에는 답글을 달 수 없다.") + void replyFailedWhenAlreadyDeleted() { + SolutionComment parentComment = SolutionCommentTestData.defaultSolutionComment() + .withDeletedAt(LocalDateTime.now()) + .build(); + String content = "답글입니다."; + Member member = MemberTestData.defaultMember().build(); + + assertThatThrownBy(() -> parentComment.reply(content, member)) + .isInstanceOf(DevelupException.class) + .hasMessage("이미 삭제된 댓글입니다."); + } + + @Test + @DisplayName("답글에는 답글을 달 수 없다.") + void replyFailedWhenAlreadyReply() { + SolutionComment rootComment = SolutionCommentTestData.defaultSolutionComment().build(); + SolutionComment reply = SolutionCommentTestData.defaultSolutionComment() + .withParentComment(rootComment) + .build(); + String content = "답글에 대한 답글입니다."; + Member member = MemberTestData.defaultMember().build(); + + assertThatThrownBy(() -> reply.reply(content, member)) + .isInstanceOf(DevelupException.class) + .hasMessage("답글에는 답글을 작성할 수 없습니다."); + } +} diff --git a/backend/src/test/java/develup/support/data/HashTagTestData.java b/backend/src/test/java/develup/support/data/HashTagTestData.java new file mode 100644 index 00000000..d28b165c --- /dev/null +++ b/backend/src/test/java/develup/support/data/HashTagTestData.java @@ -0,0 +1,30 @@ +package develup.support.data; + +import develup.domain.hashtag.HashTag; + +public class HashTagTestData { + + public static HashTagBuilder defaultHashTag() { + return new HashTagBuilder().withName("JAVA"); + } + + public static class HashTagBuilder { + + private Long id; + private String name; + + public HashTagBuilder withId(Long id) { + this.id = id; + return this; + } + + public HashTagBuilder withName(String name) { + this.name = name; + return this; + } + + public HashTag build() { + return new HashTag(id, name); + } + } +} diff --git a/backend/src/test/java/develup/support/data/MissionTestData.java b/backend/src/test/java/develup/support/data/MissionTestData.java index 75ae5b82..1e9e522f 100644 --- a/backend/src/test/java/develup/support/data/MissionTestData.java +++ b/backend/src/test/java/develup/support/data/MissionTestData.java @@ -1,5 +1,8 @@ package develup.support.data; +import java.util.Collections; +import java.util.List; +import develup.domain.hashtag.HashTag; import develup.domain.mission.Mission; public class MissionTestData { @@ -9,7 +12,8 @@ public static MissionBuilder defaultMission() { .withTitle("루터회관 흡연단속") .withThumbnail("https://thumbnail.com/1.png") .withSummary("담배피다 걸린 행성이를 위한 벌금 계산 미션") - .withUrl("https://github.com/develup-mission/java-smoking"); + .withUrl("https://github.com/develup-mission/java-smoking") + .withHashTags(Collections.emptyList()); } public static class MissionBuilder { @@ -19,6 +23,7 @@ public static class MissionBuilder { private String thumbnail; private String summary; private String url; + private List hashTags; public MissionBuilder withId(Long id) { this.id = id; @@ -45,13 +50,19 @@ public MissionBuilder withUrl(String url) { return this; } + public MissionBuilder withHashTags(List hashTags) { + this.hashTags = hashTags; + return this; + } + public Mission build() { return new Mission( id, title, thumbnail, summary, - url + url, + hashTags ); } } diff --git a/backend/src/test/java/develup/support/data/SolutionCommentTestData.java b/backend/src/test/java/develup/support/data/SolutionCommentTestData.java new file mode 100644 index 00000000..0505c4b3 --- /dev/null +++ b/backend/src/test/java/develup/support/data/SolutionCommentTestData.java @@ -0,0 +1,70 @@ +package develup.support.data; + +import java.time.LocalDateTime; +import develup.domain.member.Member; +import develup.domain.solution.Solution; +import develup.domain.solution.comment.SolutionComment; + +public class SolutionCommentTestData { + + public static SolutionCommentBuilder defaultSolutionComment() { + return new SolutionCommentBuilder() + .withId(null) + .withContent("안녕하세요. 피드백 잘 부탁 드려요.") + .withSolution(SolutionTestData.defaultSolution().build()) + .withMember(MemberTestData.defaultMember().build()) + .withParentComment(null) + .withDeletedAt(null); + } + + public static class SolutionCommentBuilder { + + private Long id; + private String content; + private Solution solution; + private Member member; + private SolutionComment parentComment; + private LocalDateTime deletedAt; + + public SolutionCommentBuilder withId(Long id) { + this.id = id; + return this; + } + + public SolutionCommentBuilder withContent(String content) { + this.content = content; + return this; + } + + public SolutionCommentBuilder withSolution(Solution solution) { + this.solution = solution; + return this; + } + + public SolutionCommentBuilder withMember(Member member) { + this.member = member; + return this; + } + + public SolutionCommentBuilder withParentComment(SolutionComment parentComment) { + this.parentComment = parentComment; + return this; + } + + public SolutionCommentBuilder withDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + return this; + } + + public SolutionComment build() { + return new SolutionComment( + id, + content, + solution, + member, + parentComment, + deletedAt + ); + } + } +} diff --git a/frontend/src/apis/solutions.ts b/frontend/src/apis/solutions.ts index adc29702..c482173a 100644 --- a/frontend/src/apis/solutions.ts +++ b/frontend/src/apis/solutions.ts @@ -1,7 +1,8 @@ import { develupAPIClient } from '@/apis/clients/develupClient'; import { PATH } from '@/apis/paths'; -import type { Solution, SubmittedSolution } from '@/types/solution'; import SubmittedSolutions from '@/mocks/SubmittedSolutions.json'; +import { HashTag } from '@/types/mission'; +import type { Solution, SubmittedSolution } from '@/types/solution'; export interface SolutionSummary { // solution 리스트에 필요한 필드만 포함한 데이터 (solution 원본 데이터와는 다름) @@ -9,6 +10,7 @@ export interface SolutionSummary { thumbnail: string; description: string; title: string; + hashTags: HashTag[]; } interface GetSolutionSummariesResponse { diff --git a/frontend/src/components/MissionDetail/MissionDetail.styled.ts b/frontend/src/components/MissionDetail/MissionDetail.styled.ts index a31c4d18..da5c3718 100644 --- a/frontend/src/components/MissionDetail/MissionDetail.styled.ts +++ b/frontend/src/components/MissionDetail/MissionDetail.styled.ts @@ -32,6 +32,7 @@ export const GradientOverlay = styled.div` position: absolute; inset: 0; background: linear-gradient(rgba(0, 0, 0, 0), var(--black-color)); + opacity: 0.5; pointer-events: none; // 그라데이션이 클릭 이벤트를 방지하지 않도록 설정 `; @@ -45,28 +46,19 @@ export const Title = styled.h1` color: var(--white-color); `; -export const LangBadgeWrapper = styled.div` - width: 5rem; - height: 5rem; +export const JavaIcon = styled(javaIcon)``; +export const HashTagWrapper = styled.ul` display: flex; align-items: center; justify-content: center; - - padding: 0.4rem; - box-sizing: border-box; - border-radius: 10rem; - overflow: hidden; + gap: 1.1rem; position: absolute; right: 2.1rem; bottom: 2.4rem; - - background-color: var(--white-color); `; -export const JavaIcon = styled(javaIcon)``; - // MissionDetailButtons export const MissionDetailButtonsContainer = styled.div` diff --git a/frontend/src/components/MissionDetail/MissionDetailButtons.tsx b/frontend/src/components/MissionDetail/MissionDetailButtons.tsx index f8af3e35..afe9e6ca 100644 --- a/frontend/src/components/MissionDetail/MissionDetailButtons.tsx +++ b/frontend/src/components/MissionDetail/MissionDetailButtons.tsx @@ -6,9 +6,8 @@ import useModal from '@/hooks/useModal'; import Modal from '../common/Modal/Modal'; import MissionProcess from '../ModalContent/MissionProcess'; import useMissionStartMutation from '@/hooks/useMissionStartMutation'; -import Button from '../common/Button/Button'; -import { GithubIcon } from './MissionDetail.styled'; import { useState } from 'react'; +import Button from '../common/Button/Button'; interface MissionDetailButtonsProps { id: number; @@ -38,10 +37,6 @@ export default function MissionDetailButtons({ onSuccessCallback: handleStartMission, }); - // const handleNavigateToMyPr = () => { - // window.open('', '_blank'); // 추후 구현 예정입니다 @프룬 - // }; - const handleMissionStart = () => { startMissionMutation({ missionId: id }); }; @@ -52,42 +47,21 @@ export default function MissionDetailButtons({ return ( - {userInfo && !isMissionStarted && ( - + )} {userInfo && isMissionStarted && ( - + )} - - {/* ) : ( <> - + Prev - - + + + Next - + )} diff --git a/frontend/src/components/common/Badge/Badge.styled.ts b/frontend/src/components/common/Badge/Badge.styled.ts index e81d97f4..1e7cea9b 100644 --- a/frontend/src/components/common/Badge/Badge.styled.ts +++ b/frontend/src/components/common/Badge/Badge.styled.ts @@ -12,4 +12,5 @@ export const BadgeContainer = styled.div` padding: 0.4rem 0.8rem; border-radius: 0.4rem; font-size: 1.2rem; + white-space: nowrap; `; diff --git a/frontend/src/components/common/Button/Button.styled.ts b/frontend/src/components/common/Button/Button.styled.ts index 61d21145..e31bfebf 100644 --- a/frontend/src/components/common/Button/Button.styled.ts +++ b/frontend/src/components/common/Button/Button.styled.ts @@ -1,33 +1,64 @@ import styled from 'styled-components'; +import type { ButtonSize, ButtonVariant } from './Button'; +import { BUTTON_SIZE, BUTTON_VARIANTS } from '@/constants/variants'; interface CommonButtonProps { - $bgColor: string; - $fontColor: string; - $hoverColor: string; + $size: ButtonSize; + $variant: ButtonVariant; } +const buttonSize = (size: ButtonSize) => { + switch (size) { + case BUTTON_SIZE.half: + return '27.1rem'; + + case BUTTON_SIZE.full: + return '100%'; + + default: + return 'fit-content'; + } +}; + +const color = (variant: ButtonVariant) => { + switch (variant) { + case BUTTON_VARIANTS.primary: + return `background-color: var(--primary-500); + color: var(--white-color); + &:hover { + background-color: var(--primary-600); + } + `; + + default: + return `background-color: var(--grey-200); + color: var(--black-color); + &:hover { + background-color: var(--grey-300); + } + `; + } +}; + export const CommonButton = styled.button` - background-color: var(${(props) => props.$bgColor}); - color: var(${(props) => props.$fontColor}); + ${(props) => color(props.$variant)} - width: fit-content; - padding: 1.2rem 1.8rem; + width: ${(props) => buttonSize(props.$size)}; + min-height: 4.2rem; + padding: 1rem 1.4rem; border-radius: 0.8rem; display: flex; gap: 0.3rem; justify-content: center; align-items: center; + transition: 0.2s; white-space: nowrap; font-size: 1.4rem; font-weight: 500; font-family: inherit; - &:hover { - background-color: var(${(props) => props.$hoverColor}); - } - &:disabled { cursor: default; } diff --git a/frontend/src/components/common/Button/Button.tsx b/frontend/src/components/common/Button/Button.tsx index ba948a27..a98a1224 100644 --- a/frontend/src/components/common/Button/Button.tsx +++ b/frontend/src/components/common/Button/Button.tsx @@ -1,39 +1,24 @@ -import type { PropsWithChildren } from 'react'; +import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'; import * as S from './Button.styled'; +import type { BUTTON_VARIANTS, BUTTON_SIZE } from '@/constants/variants'; -interface ButtonProps { - content: string; - type?: 'default' | 'icon'; - $bgColor?: string; - $hoverColor?: string; - $fontColor?: string; - disabled?: boolean; - onHandleClick?: (e: React.MouseEvent) => void; +export type ButtonVariant = keyof typeof BUTTON_VARIANTS; +export type ButtonSize = keyof typeof BUTTON_SIZE; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; } export default function Button({ - content, - type = 'default', - $bgColor = '--primary-500', - $hoverColor = '--primary-600', - $fontColor = '--white-color', - disabled = false, - onHandleClick, + variant = 'default', + size = 'default', children, -}: ButtonProps & PropsWithChildren) { - type; - $bgColor; - disabled; + ...props +}: PropsWithChildren) { return ( - - {type === 'icon' && children} - {content} + + {children} ); } diff --git a/frontend/src/components/common/Card/index.tsx b/frontend/src/components/common/Card/index.tsx index 37839270..dcaf3bc3 100644 --- a/frontend/src/components/common/Card/index.tsx +++ b/frontend/src/components/common/Card/index.tsx @@ -22,7 +22,12 @@ export default function Card({ return ( - + {contentElement} ); diff --git a/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts b/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts new file mode 100644 index 00000000..b4a1281a --- /dev/null +++ b/frontend/src/components/common/HashTagButton/HashTagButton.styled.ts @@ -0,0 +1,16 @@ +import styled from 'styled-components'; + +export const Button = styled.div` + background-color: var(--primary-50); + color: var(--black-color); + display: flex; + justify-content: center; + align-items: center; + + font-size: 1.2rem; + font-weight: 500; + font-family: inherit; + + padding: 1rem 1.6rem; + border-radius: 2rem; +`; diff --git a/frontend/src/components/common/HashTagButton/index.tsx b/frontend/src/components/common/HashTagButton/index.tsx new file mode 100644 index 00000000..2dbcae0b --- /dev/null +++ b/frontend/src/components/common/HashTagButton/index.tsx @@ -0,0 +1,6 @@ +import type { PropsWithChildren } from 'react'; +import * as S from './HashTagButton.styled'; + +export default function HashTagButton({ children }: PropsWithChildren) { + return {children}; +} diff --git a/frontend/src/components/common/InfoCard/InfoCard.styled.ts b/frontend/src/components/common/InfoCard/InfoCard.styled.ts index bdcb2c22..2a579766 100644 --- a/frontend/src/components/common/InfoCard/InfoCard.styled.ts +++ b/frontend/src/components/common/InfoCard/InfoCard.styled.ts @@ -1,5 +1,18 @@ import styled from 'styled-components'; +export const InfoCardContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +`; + +export const TitleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.3rem; +`; + export const Title = styled.div` font-size: 1.6rem; font-weight: 600; @@ -11,3 +24,11 @@ export const Description = styled.div` color: var(--grey-500); margin-top: 0.5rem; `; + +export const TagWrapper = styled.ul` + display: flex; + gap: 0.8rem; + overflow: scroll; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +`; diff --git a/frontend/src/components/common/InfoCard/index.tsx b/frontend/src/components/common/InfoCard/index.tsx index 353c958d..475957af 100644 --- a/frontend/src/components/common/InfoCard/index.tsx +++ b/frontend/src/components/common/InfoCard/index.tsx @@ -1,5 +1,9 @@ +import type { HashTag } from '@/types'; +import Badge from '../Badge'; import Card from '../Card'; import * as S from './InfoCard.styled'; +import useDragScroll from '@/hooks/useDragScroll'; +import React from 'react'; interface InfoCardProps { id: number; @@ -7,6 +11,7 @@ interface InfoCardProps { title: string; thumbnailFallbackText: string; description?: string; + hashTags: HashTag[]; } export default function InfoCard({ @@ -14,18 +19,45 @@ export default function InfoCard({ thumbnailSrc, title, description, + hashTags, thumbnailFallbackText, }: InfoCardProps) { + const { onMouseDown, onMouseMove, onMouseUp, inActive, isDragging } = + useDragScroll(); + + const handleClick = (e: React.MouseEvent) => { + if (isDragging) { + e.preventDefault(); + } + }; + return ( - {title} - {description} - + + + {title} + {description} + + + {hashTags && + hashTags.map((tag) => { + return ( +
  • + +
  • + ); + })} +
    +
    } /> ); diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 6f41365d..c60d2a0b 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -1,12 +1,10 @@ export const ROUTES = { main: '/', - submission: '/submission', missionDetail: '/missions', missionList: '/missions', profile: '/profile', guide: '/guide', submitSolution: '/submit/solution', - submissions: '/submissions', error: '/error', solutions: '/solutions', dashboardHome: '/dashboard', diff --git a/frontend/src/constants/variants.ts b/frontend/src/constants/variants.ts new file mode 100644 index 00000000..5453f3c2 --- /dev/null +++ b/frontend/src/constants/variants.ts @@ -0,0 +1,10 @@ +export const BUTTON_VARIANTS = { + default: 'default', + primary: 'primary', +} as const; + +export const BUTTON_SIZE = { + default: 'default', + half: 'half', + full: 'full', +} as const; diff --git a/frontend/src/hooks/queries/keys.ts b/frontend/src/hooks/queries/keys.ts index 9f021758..b0ae0f0b 100644 --- a/frontend/src/hooks/queries/keys.ts +++ b/frontend/src/hooks/queries/keys.ts @@ -7,8 +7,6 @@ export const missionKeys = { all: ['missions'], detail: (id: number) => [...missionKeys.all, id], - inProgress: ['inProgress'], - completed: ['completed'], } as const; export const solutionKeys = { diff --git a/frontend/src/hooks/useDragScroll.ts b/frontend/src/hooks/useDragScroll.ts new file mode 100644 index 00000000..578458da --- /dev/null +++ b/frontend/src/hooks/useDragScroll.ts @@ -0,0 +1,39 @@ +import type React from 'react'; +import { useState } from 'react'; + +const useDragScroll = () => { + const [isActive, setIsActive] = useState(false); + const [prevPositionX, setPrevPositionX] = useState(0); + const [mouseDownClientX, setMouseDownClientX] = useState(0); + const [isDragging, setIsDragging] = useState(false); + + const inActive = () => { + setIsActive(false); + setIsDragging(false); + }; + + const onMouseMove: React.MouseEventHandler = (e) => { + if (isActive) { + setIsDragging(true); + const moveX = e.clientX - mouseDownClientX; + e.currentTarget.scrollTo(prevPositionX - moveX, 0); + } + }; + + const onMouseDown: React.MouseEventHandler = (e) => { + setIsActive(true); + setIsDragging(false); + setMouseDownClientX(e.clientX); + setPrevPositionX(e.currentTarget.scrollLeft); + e.currentTarget.style.cursor = 'grabbing'; + }; + + const onMouseUp: React.MouseEventHandler = (e) => { + setIsActive(false); + e.currentTarget.style.cursor = 'grab'; + }; + + return { inActive, onMouseDown, onMouseMove, onMouseUp, isDragging }; +}; + +export default useDragScroll; diff --git a/frontend/src/hooks/useMissionInProgress.ts b/frontend/src/hooks/useMissionInProgress.ts deleted file mode 100644 index d4018302..00000000 --- a/frontend/src/hooks/useMissionInProgress.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getMissionInProgress } from '@/apis/missionAPI'; -import { useSuspenseQuery } from '@tanstack/react-query'; -import { missionKeys } from './queries/keys'; - -const useMissionInProgress = () => { - return useSuspenseQuery({ - queryKey: missionKeys.inProgress, - queryFn: getMissionInProgress, - }); -}; - -export default useMissionInProgress; diff --git a/frontend/src/mocks/hashTag.json b/frontend/src/mocks/hashTag.json new file mode 100644 index 00000000..35090803 --- /dev/null +++ b/frontend/src/mocks/hashTag.json @@ -0,0 +1,10 @@ +[ + { "id": 1, "name": "111" }, + { "id": 2, "name": "222" }, + { "id": 3, "name": "333" }, + { "id": 4, "name": "444" }, + { "id": 5, "name": "555" }, + { "id": 6, "name": "666" }, + { "id": 7, "name": "777" }, + { "id": 8, "name": "888" } +] diff --git a/frontend/src/mocks/missions.json b/frontend/src/mocks/missions.json index 7c4a287b..0b50e9d5 100644 --- a/frontend/src/mocks/missions.json +++ b/frontend/src/mocks/missions.json @@ -6,7 +6,13 @@ "description": "루터회관 흡연 벌칙 프로그램을 구현한다.", "descriptionUrl": "루터회관 흡연 벌칙 프로그램을 구현한다.", "thumbnail": "https://file.notion.so/f/f/d5a9d2d0-0fab-48ee-9e8a-13a13de1ac48/38a7f41b-80d7-48ca-97c9-99ceda5c4dbd/smoking.png?id=60756a7a-c50f-4946-ab6e-4177598b926b&table=block&spaceId=d5a9d2d0-0fab-48ee-9e8a-13a13de1ac48&expirationTimestamp=1721174400000&signature=todzUdb5cUyzW4ZQNaHvL-uiCngfMJJAl94RpE1TGEA&downloadName=smoking.png", - "url": "https://github.com/develup-mission/java-smoking" + "url": "https://github.com/develup-mission/java-smoking", + "hashTags": [ + { + "id": 1, + "name": "111" + } + ] }, { @@ -16,7 +22,13 @@ "description": "루터회관 흡연 벌칙 프로그램을 구현한다.", "descriptionUrl": "루터회관 흡연 벌칙 프로그램을 구현한다.", "thumbnail": "https://file.notion.so/f/f/d5a9d2d0-0fab-48ee-9e8a-13a13de1ac48/42c240fa-3581-44fe-98ee-88f809996158/word-puzzle.png?id=7bd82f41-0c5b-4880-ac3f-265442c2e428&table=block&spaceId=d5a9d2d0-0fab-48ee-9e8a-13a13de1ac48&expirationTimestamp=1721145600000&signature=UzH3S8xJFU43GXYKhvmmwp5wIrNE6Nss8vIRmbKO8N4&downloadName=word-puzzle.png", - "url": "https://github.com/develup-mission/java-word-puzzle" + "url": "https://github.com/develup-mission/java-word-puzzle", + "hashTags": [ + { + "id": 1, + "name": "111" + } + ] }, { "id": 3, @@ -25,6 +37,12 @@ "description": "루터회관 흡연 벌칙 프로그램을 구현한다.", "descriptionUrl": "루터회관 흡연 벌칙 프로그램을 구현한다.", "thumbnail": "https://file.notion.so/f/f/d5a9d2d0-0fab-48ee-9e8a-13a13de1ac48/ced90f97-7878-4666-96ce-0f39aca1a01e/guessing-number.png?id=c4fe206e-8aa2-4c5a-9895-1630030fc146&table=block&spaceId=d5a9d2d0-0fab-48ee-9e8a-13a13de1ac48&expirationTimestamp=1721145600000&signature=9lPNuetC73atEeb__y3f8ewS8wXvEQEMCfbFZhzYATo&downloadName=guessing-number.png", - "url": "https://github.com/develup-mission/java-guessing-number" + "url": "https://github.com/develup-mission/java-guessing-number", + "hashTags": [ + { + "id": 1, + "name": "111" + } + ] } ] diff --git a/frontend/src/pages/MissionDetailPage.tsx b/frontend/src/pages/MissionDetailPage.tsx index 13e028c8..ee767aed 100644 --- a/frontend/src/pages/MissionDetailPage.tsx +++ b/frontend/src/pages/MissionDetailPage.tsx @@ -15,6 +15,7 @@ export default function MissionDetailPage() { title={missionData.title} thumbnail={missionData.thumbnail} language={missionData.language} + hashTags={missionData.hashTags} /> 💡 Solutions - {solutionSummaries.map(({ id, thumbnail, title, description }) => ( + {solutionSummaries.map(({ id, thumbnail, title, description, hashTags }) => ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6a5f5348..2d96fa4e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,16 +5,25 @@ export interface TabInfo { content: ReactNode; } -export interface MissionWithDescription extends Mission { - description: string; +export interface HashTag { + id: number; + name: string; } -export interface MissionSubmission { +//TODO 백엔드에서 내려주는 language 타입이 string이라서 일단 string으로 수정해놓았습니다! +export interface Mission { id: number; - mission: Mission; - myPrLink: string; - pairPrLink: string; - status: string; + title: string; + language: string; + descriptionUrl: string; + thumbnail: string; + url: string; + isStarted?: boolean; + hashTags: HashTag[]; +} + +export interface MissionWithDescription extends Mission { + description: string; } export interface Mission { @@ -26,7 +35,6 @@ export interface Mission { url: string; isStarted?: boolean; } - // postSubmission에 관련된 타입 선언 export interface SubmissionPayload { missionId: number;