Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 게시물 댓글 CRUD 구현 #257

Merged
merged 13 commits into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.codiary.backend.domain.comment.controller;

import com.codiary.backend.domain.comment.converter.CommentConverter;
import com.codiary.backend.domain.comment.dto.request.CommentRequestDTO.CommentDTO;
import com.codiary.backend.domain.comment.dto.response.CommentResponseDTO;
import com.codiary.backend.domain.comment.entity.Comment;
import com.codiary.backend.domain.comment.service.CommentService;
import com.codiary.backend.domain.member.security.CustomMemberDetails;
import com.codiary.backend.global.apiPayload.ApiResponse;
import com.codiary.backend.global.apiPayload.code.status.SuccessStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v2/posts")
@Tag(name = "댓글 API", description = "댓글 관련 API 입니다.")
public class CommentController {

private final CommentService commentService;

@Operation(summary = "댓글 달기")
@PostMapping("/posts/{post_id}/comments")
public ApiResponse<CommentResponseDTO.CommentDTO> commentOnPost(
@PathVariable("post_id") Long postId,
@RequestBody CommentDTO request,
@AuthenticationPrincipal CustomMemberDetails memberDetails
) {
Long commenterId = memberDetails.getId();
Comment newComment = commentService.commentOnPost(postId, commenterId, request);
return ApiResponse.onSuccess(SuccessStatus.COMMENT_OK, CommentConverter.toCommentResponseDto(newComment));
}

@Operation(summary = "댓글 삭제")
@DeleteMapping("comments/{comment_id}")
public ApiResponse<String> deleteComment(
@PathVariable("comment_id") Long commentId,
@AuthenticationPrincipal CustomMemberDetails memberDetails
) {
Long memberId = memberDetails.getId();
String response = commentService.deleteComment(commentId, memberId);
return ApiResponse.onSuccess(SuccessStatus.COMMENT_OK, response);
}

@Operation(summary = "댓글 수정")
@PatchMapping("comments/{comment_id}")
public ApiResponse<CommentResponseDTO.CommentDTO> updateComment(
@PathVariable("comment_id") Long commentId,
@RequestBody CommentDTO request,
@AuthenticationPrincipal CustomMemberDetails memberDetails
) {
Long memberId = memberDetails.getId();
Comment updatedComment = commentService.updateComment(commentId, memberId, request);
return ApiResponse.onSuccess(SuccessStatus.COMMENT_OK, CommentConverter.toCommentResponseDto(updatedComment));
}

@Operation(summary = "댓글 조회", description = "기본적으로 10개씩 페이지네이션하여 제공됩니다.")
@GetMapping("/posts/{post_id}/comments")
public ApiResponse<List<CommentResponseDTO.CommentDTO>> getComments(
@PathVariable("post_id") Long postId,
@AuthenticationPrincipal CustomMemberDetails memberDetails,
@PageableDefault(size = 10) Pageable pageable
) {
Long memberId = memberDetails.getId();
List<Comment> comments = commentService.getComments(postId, memberId, pageable);
return ApiResponse.onSuccess(SuccessStatus.COMMENT_OK, CommentConverter.toCommentResponseListDto(comments));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.codiary.backend.domain.comment.converter;

import com.codiary.backend.domain.comment.dto.response.CommentResponseDTO;
import com.codiary.backend.domain.comment.entity.Comment;
import java.util.List;
import java.util.stream.Collectors;

public class CommentConverter {

public static CommentResponseDTO.CommentDTO toCommentResponseDto(Comment comment) {
return CommentResponseDTO.CommentDTO.builder()
.commentId(comment.getCommentId())
.commentBody(comment.getCommentBody())
.postId(comment.getPost().getPostId())
.commenterId(comment.getMember().getMemberId())
.commenterProfileImageUrl(
(comment.getMember().getImage() != null) ? (comment.getMember().getImage().getImageUrl()) : "")
.commenterNickname(comment.getMember().getNickname())
.createdAt(comment.getCreatedAt())
.updatedAt(comment.getUpdatedAt())
.build();
}

public static List<CommentResponseDTO.CommentDTO> toCommentResponseListDto(List<Comment> comments) {
return comments.stream()
.map(CommentConverter::toCommentResponseDto)
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.codiary.backend.domain.comment.dto.request;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.Builder;

public class CommentRequestDTO {

// Comment 생성 DTO
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Builder
public record CommentDTO(
String commentBody
) {
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.codiary.backend.domain.comment.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import java.time.LocalDateTime;
import lombok.Builder;

public class CommentResponseDTO {

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@Builder
public record CommentDTO(
Long commentId,
String commentBody,
Long postId,
Long commenterId,
String commenterProfileImageUrl,
String commenterNickname,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
package com.codiary.backend.domain.comment.entity;

import com.codiary.backend.domain.member.entity.Member;
import com.codiary.backend.global.common.BaseEntity;
import com.codiary.backend.domain.post.entity.Post;
import jakarta.persistence.*;
import lombok.*;

import com.codiary.backend.global.common.BaseEntity;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
Expand Down Expand Up @@ -37,6 +48,13 @@ public class Comment extends BaseEntity {
@OneToMany(mappedBy = "parentId", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> childComments = new ArrayList<>();

@Builder
public Comment(String commentBody, Member member, Post post) {
this.commentBody = commentBody;
this.member = member;
this.post = post;
}

public void setMember(Member member) {
if (this.member != null) {
member.getCommentList().remove(this);
Expand All @@ -56,4 +74,8 @@ public void setPost(Post post) {

post.getCommentList().add(this);
}

public void setCommentBody(String commentBody) {
this.commentBody = commentBody;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.codiary.backend.domain.comment.repository;

import com.codiary.backend.domain.comment.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CommentRepository extends JpaRepository<Comment, Long>, CommentRepositoryCustom {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.codiary.backend.domain.comment.repository;

import com.codiary.backend.domain.comment.entity.Comment;
import java.util.List;
import org.springframework.data.domain.Pageable;

public interface CommentRepositoryCustom {

List<Comment> findByPostWithMemberInfoOrderByCreatedAtDesc(Long postId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.codiary.backend.domain.comment.repository;

import static com.codiary.backend.domain.comment.entity.QComment.comment;
import static com.codiary.backend.domain.member.entity.QMember.member;
import static com.codiary.backend.domain.post.entity.QPost.post;

import com.codiary.backend.domain.comment.entity.Comment;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;

@RequiredArgsConstructor
public class CommentRepositoryImpl implements CommentRepositoryCustom {

private final JPAQueryFactory queryFactory;

@Override
public List<Comment> findByPostWithMemberInfoOrderByCreatedAtDesc(Long postId, Pageable pageable) {
List<Comment> comments = queryFactory
.selectFrom(comment)
.leftJoin(comment.member, member)
.leftJoin(comment.post, post)
.where(comment.post.postId.eq(postId))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();

return comments;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.codiary.backend.domain.comment.service;

import com.codiary.backend.domain.comment.dto.request.CommentRequestDTO;
import com.codiary.backend.domain.comment.dto.request.CommentRequestDTO.CommentDTO;
import com.codiary.backend.domain.comment.entity.Comment;
import com.codiary.backend.domain.comment.repository.CommentRepository;
import com.codiary.backend.domain.member.entity.Member;
import com.codiary.backend.domain.member.repository.MemberRepository;
import com.codiary.backend.domain.post.entity.Post;
import com.codiary.backend.domain.post.enumerate.PostAccess;
import com.codiary.backend.domain.post.repository.PostRepository;
import com.codiary.backend.domain.team.entity.Team;
import com.codiary.backend.domain.team.repository.TeamRepository;
import com.codiary.backend.global.apiPayload.code.status.ErrorStatus;
import com.codiary.backend.global.apiPayload.exception.GeneralException;
import com.codiary.backend.global.apiPayload.exception.handler.MemberHandler;
import com.codiary.backend.global.apiPayload.exception.handler.PostHandler;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class CommentService {

private final CommentRepository commentRepository;
private final MemberRepository memberRepository;
private final PostRepository postRepository;
private final TeamRepository teamRepository;

public Comment commentOnPost(Long postId, Long commenterId, CommentDTO request) {
// validation: 사용자, post 유무 확인
Member commenter = memberRepository.findById(commenterId)
.orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND));
Post post = postRepository.findById(postId).orElseThrow(() -> new PostHandler(ErrorStatus.POST_NOT_FOUND));

// validation: 사용자가 해당 게시물에 대한 권한 있는지
if (post.getPostAccess().equals(PostAccess.MEMBER) && post.getMember() != commenter) {
throw new GeneralException(ErrorStatus.COMMENT_CREATE_UNAUTHORIZED);
} else if (post.getPostStatus().equals(PostAccess.TEAM)) {
Team teamOfPost = post.getTeam();
if (!teamRepository.isTeamMember(teamOfPost, commenter)) {
throw new GeneralException((ErrorStatus.COMMENT_CREATE_UNAUTHORIZED));
}
}

// business logic: 댓글 생성
Comment comment = Comment.builder()
.commentBody(request.commentBody())
.member(commenter)
.post(post)
.build();

// response: 댓글 반환
return commentRepository.save(comment);
}

public String deleteComment(Long commentId, Long memberId) {
// validation: 사용자, comment 유무 확인
// + 사용자가 해당 댓글에 대한 권한 있는지
Member requester = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND));
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND));
if (comment.getMember() != requester) {
throw new GeneralException(ErrorStatus.COMMENT_DELETE_UNAUTHORIZED);
}

// business logic: 댓글 삭제
commentRepository.delete(comment);

// response: 삭제 성공 반환
return "성공적으로 삭제되었습니다!";
}

@Transactional
public Comment updateComment(Long commentId, Long memberId, CommentRequestDTO.CommentDTO request) {
// validation: 사용자, comment 유무 확인
// + 사용자가 해당 댓글에 대한 권한 있는지
Member requester = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND));
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND));
if (comment.getMember() != requester) {
throw new GeneralException(ErrorStatus.COMMENT_UPDATE_UNAUTHORIZED);
}

// business logic: 댓글 수정
comment.setCommentBody(request.commentBody());

// response
return commentRepository.save(comment);
}

@Transactional(readOnly = true)
public List<Comment> getComments(Long postId, Long memberId, Pageable pageable) {
// validation: 사용자, post 유무 확인
Member requester = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberHandler(ErrorStatus.MEMBER_NOT_FOUND));
Post post = postRepository.findById(postId).orElseThrow(() -> new PostHandler(ErrorStatus.POST_NOT_FOUND));

// validation: 사용자가 해당 게시물에 대한 권한 있는지
if (post.getPostAccess().equals(PostAccess.MEMBER) && post.getMember() != requester) {
throw new GeneralException(ErrorStatus.COMMENT_CREATE_UNAUTHORIZED);
} else if (post.getPostStatus().equals(PostAccess.TEAM)) {
Team teamOfPost = post.getTeam();
if (!teamRepository.isTeamMember(teamOfPost, requester)) {
throw new GeneralException((ErrorStatus.COMMENT_CREATE_UNAUTHORIZED));
}
}

// business logic: 댓글 조회
List<Comment> comments = commentRepository.findByPostWithMemberInfoOrderByCreatedAtDesc(postId, pageable);

// response: comment list 반환
return comments;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ public enum ErrorStatus implements BaseErrorCode {

// 코멘트 관련 에러 4000
COMMENT_NOT_FOUND(HttpStatus.BAD_REQUEST, "COMMENT_4005", "댓글이 없습니다."),
COMMENT_CREATE_UNAUTHORIZED(HttpStatus.BAD_REQUEST, "COMMENT_4006", "댓글 생성 권한이 없습니다."),
COMMENT_UPDATE_UNAUTHORIZED(HttpStatus.BAD_REQUEST, "COMMENT_4007", "댓글 수정 권한이 없습니다."),
COMMENT_DELETE_UNAUTHORIZED(HttpStatus.BAD_REQUEST, "COMMENT_4008", "댓글 삭제 권한이 없습니다."),
COMMENT_READ_UNAUTHORIZED(HttpStatus.BAD_REQUEST, "COMMENT_4009", "댓글 읽기 권한이 없습니다."),


// 북마크 관련 에러 6000
Expand Down