diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java index 35d093f..6fe9d61 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java @@ -1,4 +1,70 @@ package com.teamEWSN.gitdeun.codereference.controller; +import com.teamEWSN.gitdeun.codereference.dto.CodeReferenceResponseDtos.*; +import com.teamEWSN.gitdeun.codereference.dto.*; +import com.teamEWSN.gitdeun.codereference.service.CodeReferenceService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/mindmaps/{mapId}") +@RequiredArgsConstructor public class CodeReferenceController { + + private final CodeReferenceService codeReferenceService; + + // 특정 노드에 코드 참조 생성 + @PostMapping("/nodes/{nodeId}/code-references") + public ResponseEntity createCodeReference( + @PathVariable Long mapId, + @PathVariable String nodeId, + @Valid @RequestBody CodeReferenceRequestDtos.CreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED).body(codeReferenceService.createReference(mapId, nodeId, userDetails.getId(), request)); + } + + // 특정 코드 참조 상세 조회 + @GetMapping("/code-references/{refId}") + public ResponseEntity getCodeReference( + @PathVariable Long mapId, + @PathVariable Long refId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(codeReferenceService.getReference(mapId, refId, userDetails.getId())); + } + + // 특정 노드에 연결된 모든 코드 참조 목록 조회 + @GetMapping("/nodes/{nodeId}/code-references") + public ResponseEntity> getNodeCodeReferences( + @PathVariable Long mapId, + @PathVariable String nodeId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(codeReferenceService.getReferencesForNode(mapId, nodeId, userDetails.getId())); + } + + // 코드 참조 정보 수정 + @PatchMapping("/code-references/{refId}") + public ResponseEntity updateCodeReference( + @PathVariable Long mapId, + @PathVariable Long refId, + @Valid @RequestBody CodeReferenceRequestDtos.CreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(codeReferenceService.updateReference(mapId, refId, userDetails.getId(), request)); + } + + // 코드 참조 삭제 + @DeleteMapping("/code-references/{refId}") + public ResponseEntity deleteCodeReference( + @PathVariable Long mapId, + @PathVariable Long refId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + codeReferenceService.deleteReference(mapId, refId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceDto.java b/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceDto.java deleted file mode 100644 index 6143486..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.codereference.dto; - -public class CodeReferenceDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceRequestDtos.java b/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceRequestDtos.java new file mode 100644 index 0000000..d354338 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceRequestDtos.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.codereference.dto; + + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class CodeReferenceRequestDtos { + + @Getter + @NoArgsConstructor + public static class CreateRequest { + @NotEmpty(message = "파일 경로는 비워둘 수 없습니다.") + private String filePath; + private Integer startLine; + private Integer endLine; + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceResponseDtos.java b/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceResponseDtos.java new file mode 100644 index 0000000..bdd183a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/dto/CodeReferenceResponseDtos.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.codereference.dto; + +import lombok.*; + +public class CodeReferenceResponseDtos { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor(access = AccessLevel.PROTECTED) + public static class ReferenceResponse { + private Long referenceId; + private String nodeId; + private String filePath; + private Integer startLine; + private Integer endLine; + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java index 69773b8..d13df82 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java @@ -1,16 +1,21 @@ package com.teamEWSN.gitdeun.codereference.entity; +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@NoArgsConstructor +@AllArgsConstructor @Table(name = "code_reference") -public class CodeReference { +public class CodeReference extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -20,12 +25,26 @@ public class CodeReference { @JoinColumn(name = "mindmap_id", nullable = false) private Mindmap mindmap; + @Column(name = "node_id", nullable = false) + private String nodeId; + @Column(name = "file_path", columnDefinition = "TEXT", nullable = false) private String filePath; - @Column(name = "start_line", length = 255) - private String startLine; + @Column(name = "start_line") + private Integer startLine; + + @Column(name = "end_line") + private Integer endLine; + + @Builder.Default + @OneToMany(mappedBy = "codeReference", cascade = CascadeType.ALL, orphanRemoval = true) + private List codeReviews = new ArrayList<>(); + - @Column(name = "end_line", length = 255) - private String endLine; + public void update(String filePath, Integer startLine, Integer endLine) { + this.filePath = filePath; + this.startLine = startLine; + this.endLine = endLine; + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/mapper/CodeReferenceMapper.java b/src/main/java/com/teamEWSN/gitdeun/codereference/mapper/CodeReferenceMapper.java new file mode 100644 index 0000000..8239ddb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/mapper/CodeReferenceMapper.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.codereference.mapper; + +import com.teamEWSN.gitdeun.codereference.dto.CodeReferenceResponseDtos.*; +import com.teamEWSN.gitdeun.codereference.entity.CodeReference; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface CodeReferenceMapper { + + @Mapping(target = "referenceId", source = "id") + ReferenceResponse toReferenceResponse(CodeReference codeReference); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java b/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java index 86213f3..1475b3b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java @@ -1,5 +1,18 @@ package com.teamEWSN.gitdeun.codereference.repository; -public class CodeReferenceRepository { - +import com.teamEWSN.gitdeun.codereference.entity.CodeReference; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface CodeReferenceRepository extends JpaRepository { + + List findByMindmapIdAndNodeId(Long mindmapId, String nodeId); + + Optional findByMindmapIdAndId(Long mindmapId, Long id); + + boolean existsByMindmapIdAndId(Long mindmapId, Long id); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java b/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java index 0833eda..1ed4f59 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java @@ -1,5 +1,120 @@ package com.teamEWSN.gitdeun.codereference.service; + +import com.teamEWSN.gitdeun.codereference.dto.CodeReferenceRequestDtos.*; +import com.teamEWSN.gitdeun.codereference.dto.CodeReferenceResponseDtos.*; +import com.teamEWSN.gitdeun.codereference.entity.CodeReference; +import com.teamEWSN.gitdeun.codereference.mapper.CodeReferenceMapper; +import com.teamEWSN.gitdeun.codereference.repository.CodeReferenceRepository; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class CodeReferenceService { - -} \ No newline at end of file + + private final CodeReferenceRepository codeReferenceRepository; + private final MindmapRepository mindmapRepository; + private final MindmapAuthService mindmapAuthService; + private final CodeReferenceMapper codeReferenceMapper; + + @Transactional + public ReferenceResponse createReference(Long mapId, String nodeId, Long userId, CreateRequest request) { + // 1. 권한 확인 + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 2. 마인드맵 존재 여부 확인 + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + // 3. CodeReference 엔티티 생성 및 저장 + CodeReference codeReference = CodeReference.builder() + .mindmap(mindmap) + .nodeId(nodeId) + .filePath(request.getFilePath()) + .startLine(request.getStartLine()) + .endLine(request.getEndLine()) + .build(); + + CodeReference savedReference = codeReferenceRepository.save(codeReference); + + // 4. DTO로 변환하여 반환 + return codeReferenceMapper.toReferenceResponse(savedReference); + } + + @Transactional(readOnly = true) + public ReferenceResponse getReference(Long mapId, Long refId, Long userId) { + // 1. 권한 확인 + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 2. 해당 마인드맵에 속한 코드 참조인지 확인 + CodeReference codeReference = codeReferenceRepository.findByMindmapIdAndId(mapId, refId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND)); + + // 3. DTO로 변환하여 반환 + return codeReferenceMapper.toReferenceResponse(codeReference); + } + + @Transactional(readOnly = true) + public List getReferencesForNode(Long mapId, String nodeId, Long userId) { + // 1. 권한 확인 (읽기) + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 2. 특정 노드에 속한 모든 코드 참조 조회 + List references = codeReferenceRepository.findByMindmapIdAndNodeId(mapId, nodeId); + + // 3. DTO 리스트로 변환하여 반환 + return references.stream() + .map(codeReferenceMapper::toReferenceResponse) + .collect(Collectors.toList()); + } + + @Transactional + public ReferenceResponse updateReference(Long mapId, Long refId, Long userId, CreateRequest request) { + // 1. 권한 확인 (쓰기) + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 2. 해당 마인드맵에 속한 코드 참조인지 확인 후 조회 + CodeReference codeReference = codeReferenceRepository.findByMindmapIdAndId(mapId, refId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND)); + + // 3. 엔티티 정보 업데이트 + codeReference.update(request.getFilePath(), request.getStartLine(), request.getEndLine()); + + // 4. DTO로 변환하여 반환 (JPA의 Dirty Checking에 의해 자동 저장됨) + return codeReferenceMapper.toReferenceResponse(codeReference); + } + + @Transactional + public void deleteReference(Long mapId, Long refId, Long userId) { + // 1. 권한 확인 + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 2. 해당 마인드맵에 코드 참조가 존재하는지 확인 + if (!codeReferenceRepository.existsByMindmapIdAndId(mapId, refId)) { + throw new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND); + } + + // 3. 코드 참조 삭제 + codeReferenceRepository.deleteById(refId); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java b/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java index 6668fed..cdf3c01 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/controller/CodeReviewController.java @@ -1,5 +1,77 @@ package com.teamEWSN.gitdeun.codereview.controller; + +import com.teamEWSN.gitdeun.codereview.dto.CodeReviewRequestDtos.*; +import com.teamEWSN.gitdeun.codereview.dto.CodeReviewResponseDtos.*; +import com.teamEWSN.gitdeun.codereview.service.CodeReviewService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor public class CodeReviewController { - + + private final CodeReviewService codeReviewService; + + // 노드에 대한 코드 리뷰 생성 + @PostMapping(value = "/mindmaps/{mapId}/nodes/{nodeId}/code-reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createNodeCodeReview( + @PathVariable Long mapId, + @PathVariable String nodeId, + @Valid @RequestPart("request") CreateRequest request, + @RequestPart(value = "files", required = false) List files, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED).body(codeReviewService.createNodeReview(mapId, nodeId, userDetails.getId(), request, files)); + } + + // 코드 참조에 대한 코드 리뷰 생성 + @PostMapping(value = "/references/{refId}/code-reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createReferenceCodeReview( + @PathVariable Long refId, + @Valid @RequestPart("request") CreateRequest request, + @RequestPart(value = "files", required = false) List files, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED).body(codeReviewService.createReferenceReview(refId, userDetails.getId(), request, files)); + } + + // 특정 코드 리뷰 상세 정보 조회 + @GetMapping("/code-reviews/{reviewId}") + public ResponseEntity getCodeReview( + @PathVariable Long reviewId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(codeReviewService.getReview(reviewId, userDetails.getId())); + } + + // 코드 리뷰 상태 변경 (e.g., PENDING -> RESOLVED) + @PatchMapping("/code-reviews/{reviewId}/status") + public ResponseEntity updateCodeReviewStatus( + @PathVariable Long reviewId, + @RequestBody StatusRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + codeReviewService.updateReviewStatus(reviewId, userDetails.getId(), request.getStatus()); + return ResponseEntity.ok().build(); + } + + // 특정 노드에 달린 코드 리뷰 목록 조회 + @GetMapping("/mindmaps/{mapId}/nodes/{nodeId}/code-reviews") + public ResponseEntity> getNodeCodeReviews( + @PathVariable Long mapId, + @PathVariable String nodeId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10) Pageable pageable) { + return ResponseEntity.ok(codeReviewService.getReviewsForNode(mapId, nodeId, userDetails.getId(), pageable)); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewDto.java b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewDto.java deleted file mode 100644 index c2681fd..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.codereview.dto; - -public class CodeReviewDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java new file mode 100644 index 0000000..ca67e8e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewRequestDtos.java @@ -0,0 +1,26 @@ +package com.teamEWSN.gitdeun.codereview.dto; + +import com.teamEWSN.gitdeun.codereview.entity.CodeReviewStatus; +import com.teamEWSN.gitdeun.comment.entity.EmojiType; +import jakarta.validation.constraints.NotEmpty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class CodeReviewRequestDtos { + @Getter + @NoArgsConstructor + public static class CreateRequest { + @NotEmpty(message = "첫 댓글 내용은 비워둘 수 없습니다.") + private String content; + private EmojiType emojiType; + } + + @Getter + @AllArgsConstructor + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class StatusRequest { + private CodeReviewStatus status; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewResponseDtos.java b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewResponseDtos.java new file mode 100644 index 0000000..b036dc2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/dto/CodeReviewResponseDtos.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.codereview.dto; + +import com.teamEWSN.gitdeun.codereview.entity.CodeReviewStatus; +import com.teamEWSN.gitdeun.comment.dto.CommentResponseDtos.CommentResponse; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class CodeReviewResponseDtos { + + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class ReviewResponse { + private Long reviewId; + private String nodeId; + private Long codeReferenceId; + private CodeReviewStatus status; + private String authorNickname; + private String authorProfileImage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List comments; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class ReviewListResponse { + private Long reviewId; + private CodeReviewStatus status; + private String authorNickname; + private String firstCommentContent; + private int commentCount; + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java index afd47e2..aad1710 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java @@ -1,17 +1,21 @@ package com.teamEWSN.gitdeun.codereview.entity; +import com.teamEWSN.gitdeun.codereference.entity.CodeReference; +import com.teamEWSN.gitdeun.comment.entity.Comment; import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnDefault; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@NoArgsConstructor +@AllArgsConstructor @Table(name = "code_review") public class CodeReview extends AuditedEntity { @@ -23,24 +27,27 @@ public class CodeReview extends AuditedEntity { @JoinColumn(name = "author_id", nullable = false) private User author; - @OneToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "mindmap_id", nullable = false) private Mindmap mindmap; - @Column(name = "ref_id") - private Long refId; + // 리뷰 대상: 노드 ID 또는 코드 참조 + @Column(name = "node_id") + private String nodeId; // 마인드맵 그래프의 노드 ID + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "code_reference_id") + private CodeReference codeReference; @Enumerated(EnumType.STRING) @Column(nullable = false) - @ColumnDefault("'PENDING'") private CodeReviewStatus status; - @Column(name = "comment_cnt") - @ColumnDefault("0") - private Integer commentCount; - - @Column(name = "unresolved_thread_cnt") - @ColumnDefault("0") - private Integer unresolvedThreadCount; + @Builder.Default + @OneToMany(mappedBy = "codeReview", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments = new ArrayList<>(); + public void changeStatus(CodeReviewStatus status) { + this.status = status; + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/mapper/CodeReviewMapper.java b/src/main/java/com/teamEWSN/gitdeun/codereview/mapper/CodeReviewMapper.java new file mode 100644 index 0000000..22baa36 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/mapper/CodeReviewMapper.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.codereview.mapper; + +import com.teamEWSN.gitdeun.codereview.dto.CodeReviewResponseDtos.*; +import com.teamEWSN.gitdeun.codereview.dto.CodeReviewRequestDtos.*; +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import com.teamEWSN.gitdeun.comment.entity.Comment; +import com.teamEWSN.gitdeun.comment.mapper.CommentMapper; +import org.mapstruct.*; + +import java.util.Comparator; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE, + uses = {CommentMapper.class} +) +public interface CodeReviewMapper { + + @Mapping(source = "id", target = "reviewId") + @Mapping(source = "author.nickname", target = "authorNickname") + @Mapping(source = "author.profileImage", target = "authorProfileImage") + @Mapping(source = "codeReference.id", target = "codeReferenceId") + ReviewResponse toReviewResponse(CodeReview codeReview); + + @Mapping(source = "id", target = "reviewId") + @Mapping(source = "author.nickname", target = "authorNickname") + ReviewListResponse toReviewListResponse(CodeReview codeReview); + + @AfterMapping + default void afterMapping(@MappingTarget ReviewListResponse.ReviewListResponseBuilder builder, CodeReview review) { + // 첫 번째 댓글(최상위 댓글)을 찾아 내용 설정 + review.getComments().stream() + .filter(Comment::isTopLevel) + .min(Comparator.comparing(Comment::getCreatedAt)) + .ifPresent(comment -> builder.firstCommentContent(comment.getContent())); + + // 전체 댓글 수 설정 + builder.commentCount(review.getComments().size()); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/repository/CodeReviewRepository.java b/src/main/java/com/teamEWSN/gitdeun/codereview/repository/CodeReviewRepository.java index e207466..50d7ede 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/repository/CodeReviewRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/repository/CodeReviewRepository.java @@ -1,5 +1,12 @@ package com.teamEWSN.gitdeun.codereview.repository; -public class CodeReviewRepository { - +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CodeReviewRepository extends JpaRepository { + Page findByMindmapIdAndNodeId(Long mindmapId, String nodeId, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/service/CodeReviewService.java b/src/main/java/com/teamEWSN/gitdeun/codereview/service/CodeReviewService.java new file mode 100644 index 0000000..56a3d88 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/service/CodeReviewService.java @@ -0,0 +1,136 @@ +package com.teamEWSN.gitdeun.codereview.service; + +import com.teamEWSN.gitdeun.codereference.entity.CodeReference; +import com.teamEWSN.gitdeun.codereference.repository.CodeReferenceRepository; +import com.teamEWSN.gitdeun.codereview.dto.CodeReviewResponseDtos.*; +import com.teamEWSN.gitdeun.codereview.dto.CodeReviewRequestDtos.*; +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import com.teamEWSN.gitdeun.codereview.entity.CodeReviewStatus; +import com.teamEWSN.gitdeun.codereview.mapper.CodeReviewMapper; +import com.teamEWSN.gitdeun.codereview.repository.CodeReviewRepository; +import com.teamEWSN.gitdeun.comment.dto.CommentRequestDtos; +import com.teamEWSN.gitdeun.comment.service.CommentService; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CodeReviewService { + private final CodeReviewRepository codeReviewRepository; + private final MindmapRepository mindmapRepository; + private final UserRepository userRepository; + private final CodeReferenceRepository codeReferenceRepository; + private final MindmapAuthService mindmapAuthService; + private final CommentService commentService; + private final CodeReviewMapper codeReviewMapper; + + @Transactional + public ReviewResponse createNodeReview(Long mapId, String nodeId, Long userId, CreateRequest request, List files) { + // 리뷰 작성 권한 확인 + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + User author = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + CodeReview codeReview = CodeReview.builder() + .mindmap(mindmap) + .author(author) + .nodeId(nodeId) + .status(CodeReviewStatus.PENDING) + .build(); + CodeReview savedReview = codeReviewRepository.save(codeReview); + + // CommentService를 통해 첫 댓글 생성 + CommentRequestDtos.CreateRequest commentRequest = new CommentRequestDtos.CreateRequest(request.getContent(), null, request.getEmojiType()); + commentService.createComment(savedReview.getId(), userId, commentRequest, files); + + return getReview(savedReview.getId(), userId); + } + + @Transactional + public ReviewResponse createReferenceReview(Long refId, Long userId, CreateRequest request, List files) { + CodeReference codeReference = codeReferenceRepository.findById(refId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND)); + + Long mapId = codeReference.getMindmap().getId(); + // 리뷰 작성 권한 확인 + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + User author = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + CodeReview codeReview = CodeReview.builder() + .mindmap(mindmap) + .author(author) + .codeReference(codeReference) + .status(CodeReviewStatus.PENDING) + .build(); + CodeReview savedReview = codeReviewRepository.save(codeReview); + + // CommentService를 통해 첫 댓글과 첨부파일을 함께 생성 + CommentRequestDtos.CreateRequest commentRequest = new CommentRequestDtos.CreateRequest(request.getContent(), null, request.getEmojiType()); + commentService.createComment(savedReview.getId(), userId, commentRequest, files); + + return getReview(savedReview.getId(), userId); + } + + @Transactional(readOnly = true) + public ReviewResponse getReview(Long reviewId, Long userId) { + CodeReview review = codeReviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REVIEW_NOT_FOUND)); + + if (!mindmapAuthService.hasView(review.getMindmap().getId(), userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + ReviewResponse response = codeReviewMapper.toReviewResponse(review); + // CommentService를 통해 계층 구조의 댓글 목록을 가져와 설정 + response.setComments(commentService.getCommentsAsTree(review.getId())); + return response; + } + + @Transactional + public void updateReviewStatus(Long reviewId, Long userId, CodeReviewStatus status) { + CodeReview review = codeReviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REVIEW_NOT_FOUND)); + + if (!mindmapAuthService.hasEdit(review.getMindmap().getId(), userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + review.changeStatus(status); + } + + @Transactional(readOnly = true) + public Page getReviewsForNode(Long mapId, String nodeId, Long userId, Pageable pageable) { + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Page reviews = codeReviewRepository.findByMindmapIdAndNodeId(mapId, nodeId, pageable); + return reviews.map(codeReviewMapper::toReviewListResponse); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java index 154605b..b42af1a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java @@ -1,4 +1,90 @@ package com.teamEWSN.gitdeun.comment.controller; +import com.teamEWSN.gitdeun.comment.dto.CommentRequestDtos.*; +import com.teamEWSN.gitdeun.comment.dto.CommentResponseDtos.*; +import com.teamEWSN.gitdeun.comment.dto.CommentAttachmentResponseDtos.*; +import com.teamEWSN.gitdeun.comment.service.CommentService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor public class CommentController { + + private final CommentService commentService; + + // 특정 코드 리뷰에 댓글 또는 대댓글 작성 + @PostMapping(value = "/code-reviews/{reviewId}/comments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createComment( + @PathVariable Long reviewId, + @Valid @RequestPart("request") CreateRequest request, + @RequestPart(value = "files", required = false) List files, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED).body(commentService.createComment(reviewId, userDetails.getId(), request, files)); + } + + // 댓글 내용 수정 + @PatchMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody UpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.ok(commentService.updateComment(commentId, userDetails.getId(), request)); + } + + // 댓글 삭제 + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + commentService.deleteComment(commentId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + // 댓글 스레드 해결 상태 변경 (토글) + @PatchMapping("/comments/{commentId}/resolve") + public ResponseEntity toggleResolveComment( + @PathVariable Long commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + commentService.toggleResolveThread(commentId, userDetails.getId()); + return ResponseEntity.ok().build(); + } + + // 특정 댓글에 파일 첨부 + @PostMapping("/comments/{commentId}/attachments") + public ResponseEntity> uploadAttachments( + @PathVariable Long commentId, + @RequestParam("files") List files, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED).body(commentService.addAttachmentsToComment(commentId, userDetails.getId(), files)); + } + + // 첨부파일 개별 삭제 + @DeleteMapping("/comments/attachments/{attachmentId}") + public ResponseEntity deleteAttachment( + @PathVariable Long attachmentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + commentService.deleteAttachment(attachmentId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + // 최상위 댓글에 이모지 추가/변경/삭제 + @PatchMapping("/comments/{commentId}/emoji") + public ResponseEntity updateEmoji( + @PathVariable Long commentId, + @Valid @RequestBody EmojiRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + commentService.updateEmoji(commentId, userDetails.getId(), request.getEmojiType()); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentAttachmentResponseDtos.java b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentAttachmentResponseDtos.java new file mode 100644 index 0000000..f5aa017 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentAttachmentResponseDtos.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.comment.dto; + +import com.teamEWSN.gitdeun.comment.entity.AttachmentType; +import lombok.*; + +public class CommentAttachmentResponseDtos { + + @Getter + @Builder + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + public static class AttachmentResponse { + private Long attachmentId; + private String url; + private String fileName; + private AttachmentType attachmentType; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentRequestDtos.java b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentRequestDtos.java new file mode 100644 index 0000000..438ef9d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentRequestDtos.java @@ -0,0 +1,33 @@ +package com.teamEWSN.gitdeun.comment.dto; + +import com.teamEWSN.gitdeun.comment.entity.EmojiType; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class CommentRequestDtos { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class CreateRequest { + @NotEmpty(message = "댓글 내용은 비워둘 수 없습니다.") + private String content; + private Long parentId; + private EmojiType emojiType; // 최상위 댓글(리뷰의 첫 댓글)에만 사용 + } + + @Getter + @NoArgsConstructor + public static class UpdateRequest { + @NotEmpty(message = "댓글 내용은 비워둘 수 없습니다.") + private String content; + } + + @Getter + @NoArgsConstructor + public static class EmojiRequest { + private EmojiType emojiType; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java deleted file mode 100644 index e306b6e..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.comment.dto; - -public class CommentResponseDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDtos.java b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDtos.java new file mode 100644 index 0000000..2a09886 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDtos.java @@ -0,0 +1,32 @@ +package com.teamEWSN.gitdeun.comment.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.teamEWSN.gitdeun.comment.entity.EmojiType; +import com.teamEWSN.gitdeun.comment.dto.CommentAttachmentResponseDtos.AttachmentResponse; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class CommentResponseDtos { + + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CommentResponse { + private Long commentId; + private Long parentId; + private String content; + private String authorNickname; + private String authorProfileImage; + private EmojiType emojiType; // 최상위 댓글(리뷰의 첫 댓글)에만 사용 + private boolean isResolved; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List replies; + private List attachments; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java index fc833b5..976efaf 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java @@ -4,15 +4,21 @@ import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.user.entity.User; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@SQLDelete(sql = "UPDATE comment SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@Where(clause = "deleted_at IS NULL") +@NoArgsConstructor +@AllArgsConstructor @Table(name = "comment") public class Comment extends AuditedEntity { @@ -32,13 +38,58 @@ public class Comment extends AuditedEntity { @JoinColumn(name = "parent_comment_id") private Comment parentComment; + @Builder.Default + @OneToMany(mappedBy = "parentComment") + private List replies = new ArrayList<>(); + + @Builder.Default + @OneToMany(mappedBy = "comment", cascade = CascadeType.ALL, orphanRemoval = true) + private List attachments = new ArrayList<>(); + @Column(columnDefinition = "TEXT", nullable = false) private String content; + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Enumerated(EnumType.STRING) @Column(name = "emoji_type") private EmojiType emojiType; - @Column(name = "resolved_at") - private LocalDateTime resolvedAt; + + public void updateContent(String content) { + this.content = content; + } + + public void toggleResolve() { + if (this.resolvedAt == null) { + this.resolvedAt = LocalDateTime.now(); + } else { + this.resolvedAt = null; + } + } + + public void updateEmoji(EmojiType emojiType) { + // 같은 이모지를 다시 선택하면 제거, 다른 이모지를 선택하면 변경 + if (this.emojiType == emojiType) { + this.emojiType = null; + } else { + this.emojiType = emojiType; + } + } + + public boolean isResolved() { + return this.resolvedAt != null; + } + + public boolean isTopLevel() { + return this.parentComment == null; + } + + public boolean isReply() { + return this.parentComment != null; + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java index 61985d9..82fb8f2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java @@ -1,13 +1,17 @@ package com.teamEWSN.gitdeun.comment.entity; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import java.time.LocalDateTime; @Entity @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@NoArgsConstructor +@AllArgsConstructor @Table(name = "comment_attachment") public class CommentAttachment { @@ -34,4 +38,11 @@ public class CommentAttachment { @Enumerated(EnumType.STRING) @Column(name = "attachment_type", nullable = false) private AttachmentType attachmentType; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/mapper/CommentMapper.java b/src/main/java/com/teamEWSN/gitdeun/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..f8ab7bc --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/mapper/CommentMapper.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.comment.mapper; + +import com.teamEWSN.gitdeun.comment.dto.CommentResponseDtos.CommentResponse; +import com.teamEWSN.gitdeun.comment.dto.CommentAttachmentResponseDtos.AttachmentResponse; +import com.teamEWSN.gitdeun.comment.entity.Comment; +import com.teamEWSN.gitdeun.comment.entity.CommentAttachment; +import org.mapstruct.*; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface CommentMapper { + + @Mapping(source = "id", target = "commentId") + @Mapping(source = "parentComment.id", target = "parentId") + @Mapping(source = "user.nickname", target = "authorNickname") + @Mapping(source = "user.profileImage", target = "authorProfileImage") + CommentResponse toResponseDto(Comment comment); + + @Mapping(source = "id", target = "attachmentId") + AttachmentResponse toAttachmentResponseDto(CommentAttachment attachment); + + List toAttachmentResponseDtoList(List attachments); + + @AfterMapping + default void afterMapping(Comment comment, @MappingTarget CommentResponse.CommentResponseBuilder builder) { + // 삭제된 댓글일 경우, 내용을 고정된 메시지로 변경 + if (comment.getDeletedAt() != null) { + builder.content("삭제된 댓글입니다."); + builder.authorNickname("알 수 없음"); // 작성자 정보도 가림 + builder.authorProfileImage(null); + builder.attachments(null); // 첨부파일도 숨김 + } + + // 대댓글 목록이 있는 경우, 재귀적으로 DTO로 변환하여 설정 + if (comment.getReplies() != null && !comment.getReplies().isEmpty()) { + builder.replies(comment.getReplies().stream() + .map(this::toResponseDto) // 각 대댓글에 대해 toResponseDto를 재귀적으로 호출 + .collect(Collectors.toList())); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentAttachmentRepository.java b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentAttachmentRepository.java new file mode 100644 index 0000000..bbba849 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentAttachmentRepository.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.comment.repository; + +import com.teamEWSN.gitdeun.comment.entity.CommentAttachment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentAttachmentRepository extends JpaRepository { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java index e9ce416..29508d8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java @@ -1,5 +1,15 @@ package com.teamEWSN.gitdeun.comment.repository; -public class CommentRepository { - +import com.teamEWSN.gitdeun.comment.entity.Comment; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"user", "replies", "attachments"}) + List findByCodeReviewId(Long reviewId); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java new file mode 100644 index 0000000..1d67f96 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentAttachmentService.java @@ -0,0 +1,79 @@ +package com.teamEWSN.gitdeun.comment.service; + +import com.teamEWSN.gitdeun.comment.entity.AttachmentType; +import com.teamEWSN.gitdeun.comment.entity.Comment; +import com.teamEWSN.gitdeun.comment.entity.CommentAttachment; +import com.teamEWSN.gitdeun.comment.repository.CommentAttachmentRepository; +import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +public class CommentAttachmentService { + + private final S3BucketService s3BucketService; + private final CommentAttachmentRepository attachmentRepository; + + @Transactional + public List saveAttachments(Comment comment, List files) { + if (CollectionUtils.isEmpty(files)) { + return Collections.emptyList(); + } + + String s3Path = String.format("comments/%d/attachments", comment.getId()); + List uploadedUrls = s3BucketService.upload(files, s3Path); + + List attachments = IntStream.range(0, files.size()) + .mapToObj(i -> { + MultipartFile file = files.get(i); + String url = uploadedUrls.get(i); + return CommentAttachment.builder() + .comment(comment) + .url(url) + .fileName(file.getOriginalFilename()) + .mimeType(file.getContentType()) + .size(file.getSize()) + .attachmentType(determineAttachmentType(file.getContentType())) + .build(); + }) + .collect(Collectors.toList()); + + return attachmentRepository.saveAll(attachments); + } + + private AttachmentType determineAttachmentType(String mimeType) { + if (mimeType != null && mimeType.startsWith("image/")) { + return AttachmentType.IMAGE; + } + return AttachmentType.FILE; + } + + /** + * 첨부파일 목록을 S3와 DB에서 모두 삭제 (Hard Delete) + */ + @Transactional + public void deleteAttachments(List attachments) { + if (CollectionUtils.isEmpty(attachments)) { + return; + } + + // 1. S3에서 파일 삭제 + List urlsToDelete = attachments.stream() + .map(CommentAttachment::getUrl) + .collect(Collectors.toList()); + s3BucketService.remove(urlsToDelete); + + // 2. DB에서 첨부파일 정보 삭제 + attachmentRepository.deleteAll(attachments); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java index 1bbffe9..61cc2b5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java @@ -1,5 +1,210 @@ package com.teamEWSN.gitdeun.comment.service; +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import com.teamEWSN.gitdeun.codereview.repository.CodeReviewRepository; +import com.teamEWSN.gitdeun.comment.dto.CommentRequestDtos.*; +import com.teamEWSN.gitdeun.comment.dto.CommentResponseDtos.*; +import com.teamEWSN.gitdeun.comment.dto.CommentAttachmentResponseDtos.*; +import com.teamEWSN.gitdeun.comment.entity.Comment; +import com.teamEWSN.gitdeun.comment.entity.CommentAttachment; +import com.teamEWSN.gitdeun.comment.entity.EmojiType; +import com.teamEWSN.gitdeun.comment.mapper.CommentMapper; +import com.teamEWSN.gitdeun.comment.repository.CommentAttachmentRepository; +import com.teamEWSN.gitdeun.comment.repository.CommentRepository; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class CommentService { - + private final CommentRepository commentRepository; + private final CommentAttachmentRepository commentAttachmentRepository; + private final CodeReviewRepository codeReviewRepository; + private final UserRepository userRepository; + private final MindmapAuthService mindmapAuthService; + private final CommentAttachmentService attachmentService; + private final CommentMapper commentMapper; + + @Transactional + public CommentResponse createComment(Long reviewId, Long userId, CreateRequest request, List files) { + CodeReview review = codeReviewRepository.findById(reviewId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REVIEW_NOT_FOUND)); + + if (!mindmapAuthService.hasEdit(review.getMindmap().getId(), userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + User author = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Comment parent = null; + // 부모 댓글이 있는 경우 + if (request.getParentId() != null) { + parent = commentRepository.findById(request.getParentId()) + .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); + // 이모지 여부 + if (request.getEmojiType() != null) { + throw new GlobalException(ErrorCode.EMOJI_ON_REPLY_NOT_ALLOWED); + } + } + + Comment comment = Comment.builder() + .codeReview(review) + .user(author) + .parentComment(parent) + .content(request.getContent()) + .emojiType(request.getEmojiType()) + .build(); + + Comment savedComment = commentRepository.save(comment); + + // 파일 첨부 로직 : 함께 첨부된 파일이 있으면, 파일을 S3에 업로드하고 CommentAttachment로 저장 + attachmentService.saveAttachments(savedComment, files); + + return commentMapper.toResponseDto(savedComment); + } + + @Transactional(readOnly = true) + public List getCommentsAsTree(Long reviewId) { + // 1. 리뷰에 속한 모든 댓글을 한 번에 조회합니다. + List allComments = commentRepository.findByCodeReviewId(reviewId); + + // 2. 댓글을 DTO로 변환하고, 댓글 ID를 키로 하는 맵을 생성합니다. + Map commentMap = allComments.stream() + .map(commentMapper::toResponseDto) + .collect(Collectors.toMap(CommentResponse::getCommentId, c -> c)); + + // 3. 최상위 댓글과 대댓글을 분리하고, 대댓글을 부모의 replies 리스트에 추가합니다. + List topLevelComments = new ArrayList<>(); + commentMap.values().forEach(commentDto -> { + Long parentId = commentDto.getParentId(); + if (parentId != null) { + CommentResponse parentDto = commentMap.get(parentId); + if (parentDto != null) { + // 부모의 replies 리스트가 null이면 초기화 + if (parentDto.getReplies() == null) { + parentDto.setReplies(new ArrayList<>()); + } + parentDto.getReplies().add(commentDto); + } + } else { + topLevelComments.add(commentDto); + } + }); + + return topLevelComments; + } + + @Transactional(readOnly = true) + public List addAttachmentsToComment(Long commentId, Long userId, List files) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + return commentMapper.toAttachmentResponseDtoList(attachmentService.saveAttachments(comment, files)); + } + + + @Transactional + public CommentResponse updateComment(Long commentId, Long userId, UpdateRequest request) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + comment.updateContent(request.getContent()); + return commentMapper.toResponseDto(comment); + } + + @Transactional + public void deleteComment(Long commentId, Long userId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); + + boolean isAuthor = comment.getUser().getId().equals(userId); + boolean isManager = mindmapAuthService.hasEdit(comment.getCodeReview().getMindmap().getId(), userId); + + if (!isAuthor && !isManager) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 1. 댓글에 첨부된 파일이 있으면 S3와 DB에서 모두 삭제 + if (!CollectionUtils.isEmpty(comment.getAttachments())) { + attachmentService.deleteAttachments(comment.getAttachments()); + } + + // 2. 댓글을 Soft Delete 처리 + commentRepository.delete(comment); + } + + /** + * 첨부파일 개별 삭제 + */ + @Transactional + public void deleteAttachment(Long attachmentId, Long userId) { + CommentAttachment attachment = commentAttachmentRepository.findById(attachmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.FILE_NOT_FOUND)); + + boolean isAuthor = attachment.getComment().getUser().getId().equals(userId); + boolean isManager = mindmapAuthService.hasEdit(attachment.getComment().getCodeReview().getMindmap().getId(), userId); + + if (!isAuthor && !isManager) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + attachmentService.deleteAttachments(List.of(attachment)); + } + + @Transactional + public void toggleResolveThread(Long commentId, Long userId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isReply()) { + throw new GlobalException(ErrorCode.CANNOT_RESOLVE_REPLY); + } + + if (!mindmapAuthService.hasEdit(comment.getCodeReview().getMindmap().getId(), userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + comment.toggleResolve(); + } + + @Transactional + public void updateEmoji(Long commentId, Long userId, EmojiType emojiType) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isReply()) { + throw new GlobalException(ErrorCode.EMOJI_ON_REPLY_NOT_ALLOWED); + } + + boolean isAuthor = comment.getUser().getId().equals(userId); + boolean isReviewAuthor = comment.getCodeReview().getAuthor().getId().equals(userId); + + if (!isAuthor && !isReviewAuthor) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + comment.updateEmoji(emojiType); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index cb37224..cfff642 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -81,6 +81,13 @@ public enum ErrorCode { NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION-001", "알림을 찾을 수 없습니다."), CANNOT_ACCESS_NOTIFICATION(HttpStatus.FORBIDDEN, "NOTIFICATION-002", "해당 알림에 접근할 권한이 없습니다."), + // 코드 리뷰 관련 + CODE_REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW-001", "해당 코드 리뷰를 찾을 수 없습니다."), + CODE_REFERENCE_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW-002", "해당 코드 참조를 찾을 수 없습니다."), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW-003", "해당 댓글을 찾을 수 없습니다."), + CANNOT_RESOLVE_REPLY(HttpStatus.BAD_REQUEST, "REVIEW-004", "대댓글은 해결 상태로 변경할 수 없습니다."), + EMOJI_ON_REPLY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "REVIEW-005", "이모지는 최상위 댓글에만 추가할 수 있습니다."), + // 방문기록 관련 HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."),