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/entity/EmojiType.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java index 96c76fc..34f427c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java @@ -1,9 +1,9 @@ package com.teamEWSN.gitdeun.comment.entity; public enum EmojiType { - QUESTION, - IDEA, - BUG, - IMPORTANT, - LOVE + QUESTION, // ❓ 질문 + IDEA, // 💡 아이디어 + BUG, // 🐞 오류 + IMPORTANT, // ⭐️ 중요 + LOVE // ❤️ 칭찬 } 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 f6c4b6a..cfff642 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -75,11 +75,19 @@ public enum ErrorCode { INVITATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "INVITE-003", "이미 초대 대기 중인 사용자입니다."), CANNOT_INVITE_SELF(HttpStatus.BAD_REQUEST, "INVITE-004", "자기 자신을 초대할 수 없습니다."), INVITATION_REJECTED_USER(HttpStatus.FORBIDDEN, "INVITE-005", "초대를 거절한 사용자이므로 초대할 수 없습니다."), + INVITATION_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "INVITE-006", "이미 처리된 초대입니다."), // 알림 관련 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", "방문 기록을 찾을 수 없습니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index 11c12db..d40a94a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -16,6 +16,7 @@ import java.time.LocalDateTime; import java.util.HashMap; +import java.util.List; import java.util.Map; @Slf4j @@ -211,6 +212,40 @@ public void deleteMindmapData(String repoUrl, String authorizationHeader) { .block(); } + /** + * 프롬프트 기반 자동 제안 생성 (auto endpoint) + * - 프롬프트에서 스코프를 자동 추론 + * - 관련 파일들에 대한 코드 제안 일괄 생성 + */ + public SuggestionAutoResponse createAutoSuggestions( + String repoUrl, + String prompt, + String authHeader) { + + String mapId = extractMapId(repoUrl); + log.info("자동 제안 생성 시작 - mapId: {}, prompt: {}", mapId, prompt); + + Map request = new HashMap<>(); + request.put("repo_url", repoUrl); + request.put("prompt", prompt); + request.put("include_children", true); + request.put("max_files", 12); + request.put("return_code", true); + + try { + return webClient.post() + .uri("/suggestion/{mapId}/auto", mapId) + .header("Authorization", authHeader) + .body(Mono.just(request), Map.class) + .retrieve() + .bodyToMono(SuggestionAutoResponse.class) + .block(); + } catch (Exception e) { + log.error("자동 제안 생성 실패: {}", e.getMessage(), e); + throw new RuntimeException("제안 생성 실패: " + e.getMessage()); + } + } + // === Helper Methods === // 저장소명 추출 @@ -289,4 +324,29 @@ public static class DeleteResponse { } + @Getter + @Setter + public static class SuggestionAutoResponse { + private String map_id; + private String prompt; + private List chosen_scopes; // 선택된 스코프 (노드명) + private List candidates; + private Integer total_target_files; + private Integer created; + private List items; + } + + @Getter + @Setter + public static class AutoScopeItem { + private String scope_label; + private String scope_node_key; + private String file_path; + private String suggestion_key; + private String node_key; + private String status; + private String error; + private String code; + } + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java index 5e503e9..2feb023 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java @@ -6,6 +6,7 @@ import com.teamEWSN.gitdeun.invitation.dto.InviteRequestDto; import com.teamEWSN.gitdeun.invitation.dto.LinkResponseDto; import com.teamEWSN.gitdeun.invitation.service.InvitationService; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @RestController @@ -66,12 +68,12 @@ public ResponseEntity> getPendingInvitations( // 초대 수락 @PostMapping("/{invitationId}/accept") - public ResponseEntity acceptInvitation( - @PathVariable Long invitationId, - @AuthenticationPrincipal CustomUserDetails userDetails + public ResponseEntity> acceptInvitation( // 반환 타입 Map으로 변경 + @PathVariable Long invitationId, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - invitationService.acceptInvitation(invitationId, userDetails.getId()); - return ResponseEntity.noContent().build(); + Mindmap mindmap = invitationService.acceptInvitation(invitationId, userDetails.getId()); + return ResponseEntity.ok(Map.of("mindmapId", mindmap.getId())); // mindmapId를 JSON 형태로 반환 } // 초대 거절 diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java index 5ef2c43..581777f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java @@ -20,6 +20,7 @@ import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -42,9 +43,10 @@ public class InvitationService { private final NotificationService notificationService; private final InvitationMapper invitationMapper; - // TODO: 배포 시 이메일 주소 변경 - private static final String INVITATION_BASE_URL = "http://localhost:8080/invitations/"; - // private static final String INVITATION_BASE_URL = "https://gitdeun.site/invitations/"; + @Value("${app.front-url}") + private String frontUrl; + + // 배포 시 이메일 주소 변경됨 // 초대 전송(이메일 + 알림) @Transactional @@ -147,30 +149,38 @@ public List getPendingInvitationsByMindmap(Long mapId, Lo // 초대 수락 @Transactional - public void acceptInvitation(Long invitationId, Long userId) { + public Mindmap acceptInvitation(Long invitationId, Long userId) { Invitation invitation = invitationRepository.findById(invitationId) .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); + // 초대시간 만료 + if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.INVITATION_EXPIRED); + } + // 초대된 마인드맵이 삭제되었는지 확인 if (invitation.getMindmap().isDeleted()) { throw new GlobalException(ErrorCode.MINDMAP_NOT_FOUND); } + // 이미 처리된 초대 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GlobalException(ErrorCode.INVITATION_ALREADY_PROCESSED); + } + // 초대 중복 여부 if (!invitation.getInvitee().getId().equals(userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - // 초대시간 만료 - if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { - throw new GlobalException(ErrorCode.INVITATION_EXPIRED); - } Invitation newInvitation = invitation.accept(); MindmapMember newMember = MindmapMember.of(newInvitation.getMindmap(), newInvitation.getInvitee(), newInvitation.getRole()); mindmapMemberRepository.save(newMember); notificationService.notifyAcceptance(invitation); + + return newInvitation.getMindmap(); } // 초대 거절 @@ -179,15 +189,21 @@ public void rejectInvitation(Long invitationId, Long userId) { Invitation invitation = invitationRepository.findById(invitationId) .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); - if (!invitation.getInvitee().getId().equals(userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); - } - // 초대 시간 만료 if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { throw new GlobalException(ErrorCode.INVITATION_EXPIRED); } + // 이미 처리된 초대 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GlobalException(ErrorCode.INVITATION_ALREADY_PROCESSED); + } + + // 초대 중복 여부 + if (!invitation.getInvitee().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + invitation.reject(); } @@ -215,7 +231,9 @@ public LinkResponseDto createInvitationLink(Long mapId, Long inviterId) { .build(); invitationRepository.save(invitation); - return new LinkResponseDto(INVITATION_BASE_URL + invitation.getToken()); + String invitationLink = frontUrl + "/invitations/" + invitation.getToken(); + + return new LinkResponseDto(invitationLink); } // 초대 링크 접근 @@ -224,13 +242,20 @@ public void acceptInvitationByLink(String token, Long userId) { Invitation invitation = invitationRepository.findByToken(token) .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); + Long mapId = invitation.getMindmap().getId(); + // 만료된 초대 여부 확인 if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { throw new GlobalException(ErrorCode.INVITATION_EXPIRED); } + // 기존 멤버 여부 확인 + if (mindmapMemberRepository.existsByMindmapIdAndUserId(mapId, userId)) { + throw new GlobalException(ErrorCode.MEMBER_ALREADY_EXISTS); + } + // 이미 초대 거절한 사용자 확인 - if (invitationRepository.existsByMindmapIdAndInviteeIdAndStatus(invitation.getMindmap().getId(), userId, InvitationStatus.REJECTED)) { + if (invitationRepository.existsByMindmapIdAndInviteeIdAndStatus(mapId, userId, InvitationStatus.REJECTED)) { throw new GlobalException(ErrorCode.INVITATION_REJECTED_USER); } @@ -258,10 +283,15 @@ public InvitationActionResponseDto approveLinkInvitation(Long invitationId, Long .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); // owner 확인 - if (!invitation.getMindmap().getUser().getId().equals(ownerId)) { + if (!mindmapAuthService.isOwner(invitation.getMindmap().getId(), ownerId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } + // 이미 처리된 초대 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GlobalException(ErrorCode.INVITATION_ALREADY_PROCESSED); + } + // 만료 시간 확인 if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { throw new GlobalException(ErrorCode.INVITATION_EXPIRED); @@ -276,6 +306,9 @@ public InvitationActionResponseDto approveLinkInvitation(Long invitationId, Long MindmapMember newMember = MindmapMember.of(newInvitation.getMindmap(), newInvitation.getInvitee(), newInvitation.getRole()); mindmapMemberRepository.save(newMember); + // 참여 요청자에게 승인 알림 전송 + notificationService.notifyLinkApproval(newInvitation); + return new InvitationActionResponseDto("초대 요청이 승인되었습니다."); } @@ -286,10 +319,15 @@ public InvitationActionResponseDto rejectLinkApproval(Long invitationId, Long ow .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); // owner 확인 - if (!invitation.getMindmap().getUser().getId().equals(ownerId)) { + if (!mindmapAuthService.isOwner(invitation.getMindmap().getId(), ownerId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } + // 이미 처리된 초대 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GlobalException(ErrorCode.INVITATION_ALREADY_PROCESSED); + } + // 만료 시간 확인 if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { throw new GlobalException(ErrorCode.INVITATION_EXPIRED); @@ -302,6 +340,9 @@ public InvitationActionResponseDto rejectLinkApproval(Long invitationId, Long ow invitation.reject(); + // 참여 요청자에게 거절 알림 전송 + notificationService.notifyLinkRejection(invitation); + return new InvitationActionResponseDto("초대 요청이 거부되었습니다."); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index cdfa0d1..46e61a9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -5,9 +5,7 @@ import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.dto.*; import com.teamEWSN.gitdeun.mindmap.dto.prompt.MindmapPromptAnalysisDto; -import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptApplyRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; -import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.request.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.service.MindmapOrchestrationService; import com.teamEWSN.gitdeun.mindmap.service.MindmapService; @@ -103,7 +101,7 @@ public ResponseEntity refreshMindmap( } // 비동기 서비스 호출 - mindmapOrchestrationService.refreshMindmap(mapId, authorizationHeader); + mindmapOrchestrationService.refreshMindmap(mapId, userDetails.getId(), authorizationHeader); // 즉시 202 Accepted 응답 반환 return ResponseEntity.accepted().build(); @@ -130,39 +128,43 @@ public ResponseEntity deleteMindmap( // 프롬프트 기록 관련 /** - * 프롬프트 분석 및 미리보기 생성 + * 프롬프트 분석요청 및 적용 */ @PostMapping("/{mapId}/prompts") - public ResponseEntity analyzePromptPreview( + public ResponseEntity analyzePrompt( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody MindmapPromptAnalysisDto request, - @RequestHeader("Authorization") String authorizationHeader + @RequestHeader("Authorization") String authorizationHeader, + @RequestParam(name = "apply-immediately", defaultValue = "true") boolean applyImmediately ) { - PromptPreviewResponseDto responseDto = promptHistoryService.createPromptPreview( - mapId, - userDetails.getId(), - request, - authorizationHeader - ); - return ResponseEntity.ok(responseDto); + // 권한 검증은 동기적으로 먼저 수행 + if (!mindmapAuthService.hasEdit(mapId, userDetails.getId())) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 비동기 서비스 호출 + mindmapOrchestrationService.promptMindmap(mapId, request.getPrompt(), userDetails.getId(), authorizationHeader, applyImmediately); + + // 즉시 202 Accepted 응답 반환 + return ResponseEntity.accepted().build(); } /** * 프롬프트 히스토리 적용 */ - @PostMapping("/{mapId}/prompts/apply") - public ResponseEntity applyPromptHistory( - @PathVariable Long mapId, - @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestBody PromptApplyRequestDto request, - @RequestHeader("Authorization") String authorizationHeader - ) { - promptHistoryService.applyPromptHistory(mapId, userDetails.getId(), request); - - MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), authorizationHeader); - return ResponseEntity.ok(responseDto); - } +// @PostMapping("/{mapId}/prompts/{historyId}") +// public ResponseEntity applyPromptHistory( +// @PathVariable Long mapId, +// @PathVariable Long historyId, +// @AuthenticationPrincipal CustomUserDetails userDetails, +// @RequestHeader("Authorization") String authorizationHeader +// ) { +// promptHistoryService.applyPromptHistory(mapId, historyId, userDetails.getId()); +// +// MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), authorizationHeader); +// return ResponseEntity.ok(responseDto); +// } /** * 프롬프트 히스토리 목록 조회 (페이징) @@ -182,15 +184,15 @@ public ResponseEntity> getPromptHistories( /** * 특정 프롬프트 히스토리 미리보기 조회 */ - @GetMapping("/{mapId}/prompts/histories/{historyId}/preview") - public ResponseEntity getPromptHistoryPreview( - @PathVariable Long mapId, - @PathVariable Long historyId, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - PromptPreviewResponseDto responseDto = promptHistoryService.getPromptHistoryPreview(mapId, historyId, userDetails.getId()); - return ResponseEntity.ok(responseDto); - } +// @GetMapping("/{mapId}/prompts/histories/{historyId}/preview") +// public ResponseEntity getPromptHistoryPreview( +// @PathVariable Long mapId, +// @PathVariable Long historyId, +// @AuthenticationPrincipal CustomUserDetails userDetails +// ) { +// PromptPreviewResponseDto responseDto = promptHistoryService.getPromptHistoryPreview(mapId, historyId, userDetails.getId()); +// return ResponseEntity.ok(responseDto); +// } /** * 프롬프트 히스토리 삭제 (적용되지 않은 것만) @@ -213,6 +215,6 @@ public ResponseEntity getAppliedPromptHistory( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails ) { - return ResponseEntity.ok(promptHistoryService.getAppliedPromptHistory(mapId, userDetails.getId())); + return ResponseEntity.ok(promptHistoryService.getAppliedPromptHistory(mapId, userDetails.getId())); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptApplyRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptApplyRequestDto.java deleted file mode 100644 index 548064f..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptApplyRequestDto.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.teamEWSN.gitdeun.mindmap.dto.prompt; - -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class PromptApplyRequestDto { - private Long historyId; // 적용할 히스토리 ID -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java index fbe0698..1e1f6fa 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java @@ -12,7 +12,7 @@ public class PromptHistoryResponseDto { private Long historyId; private String prompt; - private String title; + private String summary; private Boolean applied; private LocalDateTime createdAt; } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java index 8fe5bbd..c077521 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java @@ -13,7 +13,7 @@ public class PromptPreviewResponseDto { private Long historyId; private String prompt; - private String title; + private String summary; // TODO: 프롬프트 미리보기용 맵 데이터 private MindmapGraphResponseDto mindmapGraph; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java index 4928cc1..cea5886 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapOrchestrationService.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient.SuggestionAutoResponse; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.mindmap.dto.request.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.request.ValidatedMindmapRequest; @@ -19,10 +20,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import java.util.concurrent.CompletableFuture; +import static com.teamEWSN.gitdeun.notification.entity.NotificationType.*; + + @Slf4j @Service @RequiredArgsConstructor @@ -30,6 +33,7 @@ public class MindmapOrchestrationService { private final FastApiClient fastApiClient; private final MindmapService mindmapService; + private final PromptHistoryService promptHistoryService; private final NotificationService notificationService; private final UserRepository userRepository; private final MindmapRepository mindmapRepository; @@ -68,18 +72,19 @@ public void createMindmap(MindmapCreateRequestDto request, Long userId, String a }).whenComplete((mindmap, throwable) -> { // 3. 최종 결과에 따라 알림 전송 if (throwable != null) { - handleFailureAndNotify(throwable, userId, normalizedUrl); + handleFailureAndNotify(throwable, userId, normalizedUrl, "생성에", MINDMAP_CREATE); } else { - handleSuccessAndNotify(mindmap, userId); + handleSuccessAndNotify(mindmap, userId, "생성이", MINDMAP_CREATE); } }); } @Async("mindmapExecutor") - public void refreshMindmap(Long mapId, String authHeader) { + public void refreshMindmap(Long mapId, Long userId, String authHeader) { + Mindmap mindmap = null; try { log.info("비동기 새로고침 시작 - 마인드맵 ID: {}", mapId); - Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); PromptHistory appliedPrompt = mindmap.getAppliedPromptHistory(); @@ -94,12 +99,19 @@ public void refreshMindmap(Long mapId, String authHeader) { authHeader ); - // 분석 결과를 DB에 업데이트 (트랜잭션) + // 분석 결과를 DB에 업데이트 (트랜잭션) 및 SSE 브로드캐스트 mindmapService.updateMindmapFromAnalysis(mapId, authHeader, analysisResult); log.info("비동기 새로고침 성공 - 마인드맵 ID: {}", mapId); + // 성공 알림 전송 + handleSuccessAndNotify(mindmap, userId, "새로고침이", MINDMAP_UPDATE); + } catch (Exception e) { log.error("비동기 새로고침 실패 - 마인드맵 ID: {}, 원인: {}", mapId, e.getMessage(), e); + // 실패 알림 전송 + if (mindmap != null) { + handleFailureAndNotify(e, userId, mindmap.getRepo().getGithubRepoUrl(), "새로고침에", MINDMAP_UPDATE); + } } } @@ -111,45 +123,91 @@ public void cleanUpMindmapData(String repoUrl, String authorizationHeader) { try { fastApiClient.deleteMindmapData(repoUrl, authorizationHeader); log.info("ArangoDB 데이터 비동기 삭제 완료: {}", repoUrl); + } catch (Exception e) { log.error("ArangoDB 데이터 비동기 삭제 실패 - 저장소: {}, 원인: {}", repoUrl, e.getMessage()); - // TODO: 실패 시 재시도 로직 또는 관리자 알림 등의 후속 처리 구현 가능 + } } - private void handleSuccessAndNotify(Mindmap mindmap, Long userId) { + /** + * 프롬프트를 기반으로 마인드맵을 분석하고 미리보기를 생성하는 비동기 프로세스 + */ + @Async("mindmapExecutor") + public void promptMindmap(Long mapId, String prompt, Long userId, String authHeader, boolean applyImmediately) { + Mindmap mindmap = null; + try { + log.info("비동기 프롬프트 분석 시작 - 마인드맵 ID: {}", mapId); + mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + String repoUrl = mindmap.getRepo().getGithubRepoUrl(); + + // FastAPI에 자동 분석 요청 + SuggestionAutoResponse suggestionResponse = fastApiClient.createAutoSuggestions(repoUrl, prompt, authHeader); + + // 분석 결과를 바탕으로 마인드맵 그래프 데이터 업데이트 및 캐시 갱신 + mindmapService.updateMindmapFromPromptAnalysis(mapId, authHeader); + + // 분석 결과를 바탕으로 PromptHistory 생성 및 SSE 알림 + PromptHistory newHistory = promptHistoryService.createPromptHistoryFromSuggestion(mapId, prompt, suggestionResponse); + + // 프롬프트 적용 여부 확인 후 알림 + if (applyImmediately) { + promptHistoryService.applyPromptHistory(mapId, newHistory.getId(), userId); + + log.info("비동기 프롬프트 분석 및 적용 성공 - 마인드맵 ID: {}", mapId); + handleSuccessAndNotify(mindmap, userId, "프롬프트 분석 및 적용이", ANALYSIS_PROMPT); + } else { + log.info("비동기 프롬프트 분석 성공 - 마인드맵 ID: {}", mapId); + handleSuccessAndNotify(mindmap, userId, "프롬프트 분석이", ANALYSIS_PROMPT); + } + + } catch (Exception e) { + log.error("비동기 프롬프트 분석 실패 - 마인드맵 ID: {}, 원인: {}", mapId, e.getMessage(), e); + // 실패 알림 전송 + if (mindmap != null) { + handleFailureAndNotify(e, userId, mindmap.getRepo().getGithubRepoUrl(), "프롬프트 분석에", ANALYSIS_PROMPT); + } + } + } + + + // 성공 알림 (메시지 동적 생성) + private void handleSuccessAndNotify(Mindmap mindmap, Long userId, String action, NotificationType type) { try { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - String successMessage = String.format("마인드맵 '%s' 생성이 완료되었습니다.", mindmap.getTitle()); + String successMessage = String.format("마인드맵 '%s'의 %s 완료되었습니다.", mindmap.getTitle(), action); notificationService.createAndSendNotification( NotificationCreateDto.actionable( user, - NotificationType.SYSTEM_UPDATE, + type, successMessage, mindmap.getId(), null ) ); - log.info("마인드맵 생성 성공 및 알림 전송 완료 - ID: {}, 사용자: {}", mindmap.getId(), userId); + log.info("마인드맵 {} 성공 및 알림 전송 완료 - ID: {}, 사용자: {}", action, mindmap.getId(), userId); } catch (Exception e) { log.error("성공 알림 전송 실패 - 마인드맵 ID: {}, 사용자 ID: {}, 오류: {}", mindmap.getId(), userId, e.getMessage()); } } - private void handleFailureAndNotify(Throwable throwable, Long userId, String repoUrl) { + // 실패 알림 (메시지 동적 생성) + private void handleFailureAndNotify(Throwable throwable, Long userId, String repoUrl, String action, NotificationType type) { final Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable; - log.error("마인드맵 생성 최종 실패 - 사용자: {}, 저장소: {}, 원인: {}", userId, repoUrl, cause.getMessage()); + log.error("마인드맵 {} 최종 실패 - 사용자: {}, 저장소: {}, 원인: {}", action, userId, repoUrl, cause.getMessage()); try { userRepository.findByIdAndDeletedAtIsNull(userId).ifPresent(user -> { - String errorMessage = "마인드맵 생성에 실패했습니다: " + getSimplifiedErrorMessage(cause); + String errorMessage = String.format("마인드맵 %s 실패했습니다: %s", action, getSimplifiedErrorMessage(cause)); notificationService.createAndSendNotification( NotificationCreateDto.simple( user, - NotificationType.SYSTEM_UPDATE, + type, errorMessage ) ); diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index c14438a..bdb3a1a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -73,7 +73,7 @@ public Mindmap saveMindmapFromAnalysis(AnalysisResultDto analysisResult, String } // 마인드맵 상세 정보 조회 - @Transactional(readOnly = true) + @Transactional public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authHeader) { if (!mindmapAuthService.hasView(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); @@ -82,6 +82,11 @@ public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authH Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + // 방문 기록 생성 또는 갱신 + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + visitHistoryService.recordOrUpdateVisit(user, mindmap); + // 캐싱된 그래프 데이터 조회 MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( mindmap.getRepo().getGithubRepoUrl(), @@ -110,7 +115,9 @@ public MindmapDetailResponseDto updateMindmapTitle(Long mapId, Long userId, Mind return responseDto; } - // 분석 결과를 바탕으로 기존 마인드맵을 업데이트 (새로고침) + /** + * 분석 결과를 바탕으로 기존 마인드맵을 업데이트 (새로고침) + */ @Transactional public MindmapDetailResponseDto updateMindmapFromAnalysis(Long mapId, String authHeader, AnalysisResultDto analysisResult) { Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) @@ -118,18 +125,40 @@ public MindmapDetailResponseDto updateMindmapFromAnalysis(Long mapId, String aut mindmap.getRepo().updateWithAnalysis(analysisResult); - // 새로고침 시 그래프 캐시 무효화 + // 공통 로직 호출 + return evictCacheAndBroadcastUpdate(mindmap, authHeader); + } + + /** + * 프롬프트 분석 후 마인드맵을 업데이트 (캐시 갱신 및 SSE 브로드캐스트) + */ + @Transactional + public MindmapDetailResponseDto updateMindmapFromPromptAnalysis(Long mapId, String authHeader) { + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + // 공통 로직 호출 + return evictCacheAndBroadcastUpdate(mindmap, authHeader); + } + + /** + * 마인드맵 업데이트 시 공통 로직 (캐시 무효화, 새 데이터 조회, SSE 전송) + */ + private MindmapDetailResponseDto evictCacheAndBroadcastUpdate(Mindmap mindmap, String authHeader) { + // 그래프 캐시 무효화 mindmapGraphCache.evictCache(mindmap.getRepo().getGithubRepoUrl()); + // 새로운 그래프 데이터 조회 MindmapGraphResponseDto graphData = mindmapGraphCache.getGraphWithHybridCache( mindmap.getRepo().getGithubRepoUrl(), authHeader ); + // DTO 변환 및 SSE 브로드캐스트 MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap, graphData); - mindmapSseService.broadcastUpdate(mapId, responseDto); + mindmapSseService.broadcastUpdate(mindmap.getId(), responseDto); - log.info("마인드맵 새로고침 DB 업데이트 완료 - ID: {}", mapId); + log.info("마인드맵 데이터 업데이트 및 SSE 브로드캐스트 완료 - ID: {}", mindmap.getId()); return responseDto; } @@ -155,6 +184,8 @@ public Repo deleteMindmap(Long mapId, Long userId) { return mindmap.getRepo(); // 후처리를 위해 Repo 반환 } + + /** * TODO: Webhook을 통한 마인드맵 업데이트 */ diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java index 73e9aee..f52dd44 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -67,6 +68,14 @@ public void broadcastUpdate(Long mapId, MindmapDetailResponseDto data) { sendToMapSubscribers(mapId, "mindmap-update", data); } + /** + * 새로운 프롬프트 미리보기 준비 완료 브로드캐스트 + */ + public void broadcastPromptReady(Long mapId, PromptPreviewResponseDto data) { + sendToMapSubscribers(mapId, "prompt-ready", data); + } + + /** * 프롬프트 적용 브로드캐스트 */ diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java index e7c198a..7f78401 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java @@ -2,10 +2,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; -import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; -import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; -import com.teamEWSN.gitdeun.mindmap.dto.prompt.MindmapPromptAnalysisDto; -import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptApplyRequestDto; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient.SuggestionAutoResponse; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; @@ -21,6 +18,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Service @@ -31,52 +30,41 @@ public class PromptHistoryService { private final PromptHistoryRepository promptHistoryRepository; private final MindmapRepository mindmapRepository; private final MindmapAuthService mindmapAuthService; - private final FastApiClient fastApiClient; private final MindmapSseService mindmapSseService; private final PromptHistoryMapper promptHistoryMapper; /** - * 프롬프트 분석 및 미리보기 생성 + * FastAPI의 제안 분석 결과를 바탕으로 프롬프트 히스토리를 생성하고 DB에 저장 */ - public PromptPreviewResponseDto createPromptPreview(Long mapId, Long userId, MindmapPromptAnalysisDto req, String authorizationHeader) { - if (!mindmapAuthService.hasEdit(mapId, userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); - } - + public PromptHistory createPromptHistoryFromSuggestion(Long mapId, String prompt, SuggestionAutoResponse suggestionResponse) { Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - String repoUrl = mindmap.getRepo().getGithubRepoUrl(); - - try { - AnalysisResultDto analysisResult = fastApiClient.refreshMindmap(repoUrl, req.getPrompt(), authorizationHeader); - - // FastAPI로부터 받은 analysisSummary 사용 - String summary = analysisResult.getSummary(); - - // analysisSummary가 없거나 비어있는 경우 대체 로직 사용 - if (summary == null || summary.trim().isEmpty()) { - summary = generateFallbackSummary(req.getPrompt()); - } - - PromptHistory history = PromptHistory.builder() - .mindmap(mindmap) - .prompt(req.getPrompt()) - .summary(summary) - .applied(false) - .build(); + // chosen_scopes 리스트의 첫 번째 항목을 summary로 사용 + String summary; + List scopes = suggestionResponse.getChosen_scopes(); + if (scopes != null && !scopes.isEmpty()) { + summary = scopes.getFirst(); + } else { + // fallback 로직: AI가 추천 스코프를 찾지 못한 경우 + summary = generateFallbackSummary(prompt); + } - promptHistoryRepository.save(history); + PromptHistory history = PromptHistory.builder() + .mindmap(mindmap) + .prompt(prompt) + .summary(summary) + .applied(false) + .build(); - log.info("프롬프트 미리보기 생성 완료 - 마인드맵 ID: {}, 히스토리 ID: {}", mapId, history.getId()); + PromptHistory savedHistory = promptHistoryRepository.save(history); + log.info("프롬프트 히스토리 생성 완료 - 마인드맵 ID: {}, 히스토리 ID: {}", mapId, savedHistory.getId()); - // 매퍼를 활용한 변환 - return promptHistoryMapper.toPreviewResponseDto(history); + // DTO로 변환하여 SSE로 브로드캐스트 + PromptPreviewResponseDto previewDto = promptHistoryMapper.toPreviewResponseDto(savedHistory); + mindmapSseService.broadcastPromptReady(mapId, previewDto); - } catch (Exception e) { - log.error("프롬프트 미리보기 생성 실패: {}", e.getMessage(), e); - throw new RuntimeException("프롬프트 분석 중 오류가 발생했습니다: " + e.getMessage()); - } + return savedHistory; } /** @@ -132,7 +120,7 @@ public PromptHistoryResponseDto getAppliedPromptHistory(Long mapId, Long userId) /** * 프롬프트 히스토리 적용 */ - public void applyPromptHistory(Long mapId, Long userId, PromptApplyRequestDto req) { + public void applyPromptHistory(Long mapId, Long historyId, Long userId) { if (!mindmapAuthService.hasEdit(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } @@ -140,7 +128,7 @@ public void applyPromptHistory(Long mapId, Long userId, PromptApplyRequestDto re Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - PromptHistory historyToApply = promptHistoryRepository.findByIdAndMindmapId(req.getHistoryId(), mapId) + PromptHistory historyToApply = promptHistoryRepository.findByIdAndMindmapId(historyId, mapId) .orElseThrow(() -> new GlobalException(ErrorCode.PROMPT_HISTORY_NOT_FOUND)); mindmap.applyPromptHistory(historyToApply); diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java index 08d23f8..b50cf2a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java @@ -3,10 +3,16 @@ public enum NotificationType { INVITE_MINDMAP, // 마인드맵 초대 ACCEPT_MINDMAP, // 초대 수락 + REJECT_MINDMAP, // 초대 거절 MENTION_COMMENT, // 댓글에서 맨션 APPLICATION_RECEIVED, // 지원 신청 APPLICATION_ACCEPTED, // 지원 수락 APPLICATION_REJECTED, // 지원 거절 APPLICATION_WITHDRAWN_AFTER_ACCEPTANCE, // 지원 수락 철회 + + ANALYSIS_PROMPT, // 분석 프롬프트 + MINDMAP_CREATE, // 마인드맵 생성 + MINDMAP_UPDATE, // 마인드맵 업데이트 + MINDMAP_DELETE, // 마인드맵 삭제 SYSTEM_UPDATE // 시스템 업데이트(webhook) } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java index 0eba8b8..c430aa2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java @@ -41,23 +41,40 @@ public class NotificationService { @Value("${spring.mail.properties.from.name:Gitdeun}") private String fromName; + @Value("${app.front-url}") + private String frontUrl; + /** * 이메일 초대 알림 */ @Transactional public void notifyInvitation(Invitation invitation) { - User invitee = invitation.getInvitee(); - String message = String.format("'%s'님이 '%s' 마인드맵으로 초대했습니다.", + String webMessage = String.format("'%s'님이 '%s' 마인드맵으로 초대했습니다.", invitation.getInviter().getName(), invitation.getMindmap().getTitle()); - createAndSendNotification(NotificationCreateDto.actionable( - invitee, - NotificationType.INVITE_MINDMAP, - message, - invitation.getId(), - invitation.getExpiresAt() - )); + // 이메일용 초대 링크 및 HTML 메시지 생성 + String invitationLink = frontUrl + "/invitations/" + invitation.getToken(); + String emailMessage = String.format( + """ + + +

%s님이 '%s' 마인드맵으로 초대했습니다.

+

아래 버튼을 클릭하여 초대를 수락하거나 거절할 수 있습니다.

+ + 초대 확인하기 + +

링크는 24시간 동안 유효합니다.

+ + + """, + invitation.getInviter().getName(), + invitation.getMindmap().getTitle(), + invitationLink + ); + + // 웹 알림 메시지와 이메일 메시지를 함께 전달 + createAndSendNotification(invitation, webMessage, emailMessage); } /** @@ -98,9 +115,66 @@ public void notifyLinkApprovalRequest(Invitation invitation) { )); } + /** + * 링크 초대 승인 알림 (링크 초대 요청자에게 전송) + */ + @Transactional + public void notifyLinkApproval(Invitation invitation) { + User invitee = invitation.getInvitee(); + String message = String.format("'%s'님이 '%s' 마인드맵 참여를 승인했습니다.", + invitation.getMindmap().getUser().getName(), + invitation.getMindmap().getTitle()); + + createAndSendNotification(NotificationCreateDto.actionable( + invitee, + NotificationType.ACCEPT_MINDMAP, + message, + invitation.getMindmap().getId(), + invitation.getExpiresAt() + )); + } + + /** + * 링크 초대 거절 알림 (링크 초대 요청자에게 전송) + */ + @Transactional + public void notifyLinkRejection(Invitation invitation) { + String message = String.format("아쉽지만, '%s' 마인드맵 참여 요청이 거절되었습니다.", + invitation.getMindmap().getTitle()); + + createAndSendNotification(NotificationCreateDto.actionable( + invitation.getInvitee(), + NotificationType.REJECT_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); + } + + // 초대 이메일 알림 생성 및 발송 (HTML 형식) + @Transactional + public void createAndSendNotification(Invitation invitation, String webMessage, String emailMessage) { + // Notification 엔티티 생성 (웹 알림용 메시지 사용) + Notification notification = Notification.builder() + .user(invitation.getInvitee()) + .notificationType(NotificationType.INVITE_MINDMAP) + .message(webMessage) + .referenceId(invitation.getId()) + .expiresAt(invitation.getExpiresAt()) + .build(); + notificationRepository.save(notification); + + // HTML 형식의 이메일 발송 + sendEmailNotification(invitation.getInvitee().getEmail(), "[Gitdeun] 마인드맵 초대장이 도착했습니다.", emailMessage); + + // 실시간 알림 전송 (웹) + int unreadCount = notificationRepository.countByUserAndReadFalse(invitation.getInvitee()); + notificationSseService.sendUnreadCount(invitation.getInvitee().getId(), unreadCount); + notificationSseService.sendNewNotification(invitation.getInvitee().getId(), notificationMapper.toResponseDto(notification)); + } /** - * 알림 생성 및 발송 (공통 호출) + * 알림 생성 및 발송 (공통 호출 - 메세지 전송) */ @Transactional public void createAndSendNotification(NotificationCreateDto dto) { @@ -148,7 +222,7 @@ public UnreadNotificationCountDto getUnreadNotificationCount(Long userId) { } /** - * TODO: 알림 읽음 처리 + * 알림 읽음 처리 */ @Transactional public void markAsRead(Long notificationId, Long userId) { @@ -201,7 +275,7 @@ public void sendEmailNotification(String to, String subject, String text) { helper.setTo(to); helper.setSubject(subject); - helper.setText(text, false); // HTML이면 true + helper.setText(text, true); helper.setFrom(fromEmail, fromName); // 이름과 이메일 분리 설정 mailSender.send(message); diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java index ef50958..2e6bdf5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java @@ -37,4 +37,7 @@ public class VisitHistory { @OneToMany(mappedBy = "visitHistory", cascade = CascadeType.ALL, orphanRemoval = true) private List pinnedHistorys = new ArrayList<>(); + public void updateLastVisitedAt() { + this.lastVisitedAt = LocalDateTime.now(); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java index 6cc2376..b50dbba 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface VisitHistoryRepository extends JpaRepository { @@ -19,4 +20,6 @@ public interface VisitHistoryRepository extends JpaRepository findUnpinnedHistoriesByUserAndNotDeletedMindmap(@Param("user") User user, Pageable pageable); + + Optional findByUserIdAndMindmapId(Long userId, Long mindmapId); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java index 7a0148e..a68fcf4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java @@ -67,6 +67,21 @@ public void createVisitHistory(User user, Mindmap mindmap) { visitHistoryRepository.save(visitHistory); } + @Transactional + public void recordOrUpdateVisit(User user, Mindmap mindmap) { + visitHistoryRepository.findByUserIdAndMindmapId(user.getId(), mindmap.getId()) + .ifPresentOrElse( + // 방문 기록이 있으면: 마지막 방문 시간만 갱신 + visitHistory -> { + // visitHistory.setLastVisitedAt(LocalDateTime.now()); // Setter가 있다면 사용 + visitHistory.updateLastVisitedAt(); // 또는 엔티티 내부에 갱신 메소드 구현 + visitHistoryRepository.save(visitHistory); + }, + // 방문 기록이 없으면: 새로 생성 + () -> createVisitHistory(user, mindmap) + ); + } + // 핀 고정되지 않은 방문 기록 조회 @Transactional(readOnly = true) public Page getVisitHistories(Long userId, Pageable pageable) {