Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .gradle/8.14.3/checksums/checksums.lock
Binary file not shown.
Binary file not shown.
Binary file added .gradle/8.14.3/fileChanges/last-build.bin
Binary file not shown.
Binary file added .gradle/8.14.3/fileHashes/fileHashes.lock
Binary file not shown.
Empty file added .gradle/8.14.3/gc.properties
Empty file.
Binary file added .gradle/buildOutputCleanup/buildOutputCleanup.lock
Binary file not shown.
2 changes: 2 additions & 0 deletions .gradle/buildOutputCleanup/cache.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#Tue Aug 26 15:38:41 KST 2025
gradle.version=8.14.3
Empty file added .gradle/vcs-1/gc.properties
Empty file.
47 changes: 47 additions & 0 deletions HELP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Getting Started

### Reference Documentation
For further reference, please consider the following sections:

* [Official Gradle documentation](https://docs.gradle.org)
* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.5/gradle-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.5/gradle-plugin/packaging-oci-image.html)
* [Spring Boot Testcontainers support](https://docs.spring.io/spring-boot/3.5.5/reference/testing/testcontainers.html#testing.testcontainers)
* [Testcontainers MySQL Module Reference Guide](https://java.testcontainers.org/modules/databases/mysql/)
* [Spring Web](https://docs.spring.io/spring-boot/3.5.5/reference/web/servlet.html)
* [Validation](https://docs.spring.io/spring-boot/3.5.5/reference/io/validation.html)
* [Spring Security](https://docs.spring.io/spring-boot/3.5.5/reference/web/spring-security.html)
* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.5/reference/data/sql.html#data.sql.jpa-and-spring-data)
* [Flyway Migration](https://docs.spring.io/spring-boot/3.5.5/how-to/data-initialization.html#howto.data-initialization.migration-tool.flyway)
* [Spring Boot Actuator](https://docs.spring.io/spring-boot/3.5.5/reference/actuator/index.html)
* [Testcontainers](https://java.testcontainers.org/)

### Guides
The following guides illustrate how to use some features concretely:

* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
* [Validation](https://spring.io/guides/gs/validating-form-input/)
* [Securing a Web Application](https://spring.io/guides/gs/securing-web/)
* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/)
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
* [Building a RESTful Web Service with Spring Boot Actuator](https://spring.io/guides/gs/actuator-service/)
* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/)

### Additional Links
These additional references should also help you:

* [Gradle Build Scans – insights for your project's build](https://scans.gradle.com#gradle)

### Testcontainers support

This project uses [Testcontainers at development time](https://docs.spring.io/spring-boot/3.5.5/reference/features/dev-services.html#features.dev-services.testcontainers).

Testcontainers has been configured to use the following Docker images:

* [`mysql:latest`](https://hub.docker.com/_/mysql)

Please review the tags of the used images and set them to the same as you're running in production.

Empty file modified gradlew
100755 → 100644
Empty file.
50 changes: 50 additions & 0 deletions src/main/java/com/issueDive/controller/CommentController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.issueDive.controller;

import com.issueDive.dto.*;
import com.issueDive.service.CommentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/issues/{issueId}/comments")
public class CommentController {
private final CommentService commentService;

@GetMapping
public ResponseEntity<ApiResponse<List<CommentResponse>>> getComment(@PathVariable Long issueId){
List<CommentResponse> tree = commentService.getTreeByIssue(issueId);
return ResponseEntity.ok(ApiResponse.ok(tree));
}

@PostMapping
public ResponseEntity<ApiResponse<CommentResponse>> createComment(@PathVariable Long issueId, @RequestBody @Valid CreateCommentRequest request
,@RequestHeader("X-USER-ID") Long userId){

CommentResponse created = commentService.createComment(issueId, request, userId);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(created));
}

@PatchMapping("/{commentId}")
public ResponseEntity<ApiResponse<CommentResponse>> updateComment(@PathVariable Long issueId, @PathVariable Long commentId, @RequestBody @Valid UpdateCommentRequest request, @RequestHeader("X-USER-ID") Long userId){
CommentResponse updated = commentService.updateComment(issueId, commentId, request, userId);
return ResponseEntity.ok(ApiResponse.ok(updated));
}

@DeleteMapping("/{commentId}")
public ResponseEntity<?> deleteComment(@PathVariable Long issueId, @PathVariable Long commentId, @RequestHeader("X-USER-ID") Long userId){
commentService.deleteComment(issueId, commentId, userId);
return ResponseEntity.noContent().build();
}

@GetMapping("/count")
public ResponseEntity<ApiResponse<CountCommentResponse>> countComment(@PathVariable Long issueId){
CountCommentResponse response = commentService.countByIssue(issueId);
return ResponseEntity.ok(ApiResponse.ok(response));
}
}
81 changes: 81 additions & 0 deletions src/main/java/com/issueDive/dto/CommentResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.issueDive.dto;

import com.issueDive.entity.Comment;
import lombok.*;

import java.time.LocalDateTime;
import java.util.*;

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class CommentResponse {

private Long id;
private Long issueId;
private Long userId;
private String author;
private String description;
private Long parentId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

@Builder.Default
private List<CommentResponse> children = new ArrayList<>();

// 방어적 추가 헬퍼
public void addChild(CommentResponse child) {
if (children == null) children = new ArrayList<>();
children.add(child);
}

// 널-세이프 getter
public List<CommentResponse> getChildren() {
if (children == null) children = new ArrayList<>();
return children;
}

public static CommentResponse from(Comment comment){
return CommentResponse.builder()
.id(comment.getId())
.issueId(comment.getIssue().getId())
.userId(comment.getUser().getId())
.author(comment.getUser().getUsername())
.description(comment.getDescription())
.parentId(comment.getParent() != null ? comment.getParent().getId() : null)
.createdAt(comment.getCreatedAt())
.updatedAt(comment.getUpdatedAt())
.build();
}

public static List<CommentResponse> fromAllToTree(List<Comment> comments){
Map<Long, CommentResponse> map = new LinkedHashMap<>();
List<CommentResponse> roots = new ArrayList<>();

// 1) 엔티티 → DTO 변환 & 인덱싱
for (Comment c : comments) {
map.put(c.getId(), from(c));
}

// 2) 부모-자식 연결
for (Comment c : comments) {
Long pId = (c.getParent() != null) ? c.getParent().getId() : null;
CommentResponse dto = map.get(c.getId());

if (pId == null) {
roots.add(dto);
} else {
CommentResponse parent = map.get(pId);
if (parent != null) {
parent.addChild(dto);
} else {
// 부모 누락 방어: 루트에 승격(정책에 맞게 조정 가능)
roots.add(dto);
}
}
}
return roots;
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/issueDive/dto/CountCommentResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.issueDive.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@AllArgsConstructor
public class CountCommentResponse {
private Long issueId;
private Long count;
}
12 changes: 12 additions & 0 deletions src/main/java/com/issueDive/dto/CreateCommentRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.issueDive.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class CreateCommentRequest {
@NotBlank
private String description;
private Long parentId;
}
13 changes: 13 additions & 0 deletions src/main/java/com/issueDive/dto/UpdateCommentRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.issueDive.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UpdateCommentRequest {

@NotBlank
private String description;
}
52 changes: 52 additions & 0 deletions src/main/java/com/issueDive/entity/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.issueDive.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "comments")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Comment {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "issue_id", nullable = false)
private Issue issue;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Lob
@Column(nullable = false)
private String description;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;

@Builder.Default
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> children = new ArrayList<>();

@Column(name = "created_at", updatable = false, insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
private LocalDateTime createdAt;

@Column(name = "updated_at", insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
private LocalDateTime updatedAt;

public void changeDescription(String description) { this.description = description; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.issueDive.exception;

public class CommentNotFoundException extends RuntimeException{

public CommentNotFoundException(String message) {
super(message);
}

public CommentNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
2 changes: 1 addition & 1 deletion src/main/java/com/issueDive/exception/ErrorCode.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.issueDive.exception;

public enum ErrorCode {
ValidationError, InvalidQueryParam, Unauthorized, Forbidden, InternalServerError,
ValidationError, InvalidQueryParam, Unauthorized, Forbidden, BadRequest, InternalServerError,
IssueNotFound, InvalidStatus,

LabelNotFound, IssueLabelNotFound, DuplicateLabel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
import com.issueDive.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e) {
public ResponseEntity<ErrorResponse> handleLabelNotFound(NotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.IssueNotFound, e.getMessage()));
}
Expand All @@ -32,4 +33,34 @@ public ResponseEntity<ErrorResponse> handleLabelNotFound(LabelNotFoundException
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.LabelNotFound, e.getMessage()));
}

@ExceptionHandler(CommentNotFoundException.class)
public ResponseEntity<ErrorResponse> handleCommentNotFound(CommentNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.CommentNotFound, e.getMessage()));
}

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse.of(ErrorCode.UserNotFound, e.getMessage()));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(ErrorCode.BadRequest, e.getMessage()));
}

@ExceptionHandler(SecurityException.class)
public ResponseEntity<ErrorResponse> handleForbidden(SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ErrorResponse.of(ErrorCode.Forbidden, e.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(ErrorCode.BadRequest, e.getMessage()));
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/issueDive/exception/UserNotFoundException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.issueDive.exception;

public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
public UserNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/issueDive/repository/CommentRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.issueDive.repository;
import com.issueDive.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface CommentRepository extends JpaRepository<Comment, Long> {

@Query("SELECT c FROM Comment c JOIN FETCH c.user WHERE c.issue.id = :issueId")
List<Comment> findAllByIssueIdWithUser(@Param("issueId") Long issueId);

long countByIssueId(Long issueId);


}
Loading
Loading