diff --git a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java index 9b6879a..6ea9f97 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java +++ b/src/main/java/com/Timo/Timo/domain/todo/controller/TodoController.java @@ -15,10 +15,12 @@ import org.springframework.web.bind.annotation.RestController; import com.Timo.Timo.domain.todo.docs.TodoControllerDocs; +import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest; import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; +import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse; import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; @@ -81,6 +83,22 @@ public ResponseEntity> changeTodoStatus( .body(BaseResponse.onSuccess(TodoSuccessCode.STATUS_CHANGED, response)); } + @Override + @PatchMapping("/{todoId}/subtasks/{subtaskId}/status") + public ResponseEntity> changeSubtaskStatus( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @PathVariable Long subtaskId, + @Valid @RequestBody SubtaskStatusUpdateRequest request + ) { + SubtaskStatusChangeResponse response = + todoService.changeSubtaskCompletion(userDetails.getUserId(), todoId, subtaskId, request); + + return ResponseEntity + .status(TodoSuccessCode.SUBTASK_STATUS_CHANGED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.SUBTASK_STATUS_CHANGED, response)); + } + @Override @PatchMapping("/{todoId}/order") public ResponseEntity> reorderTodo( diff --git a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java index 02d5c44..f89cf2d 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/todo/docs/TodoControllerDocs.java @@ -2,10 +2,12 @@ import org.springframework.http.ResponseEntity; +import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest; import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; +import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; import com.Timo.Timo.domain.todo.dto.response.TodoDetailResponse; import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse; @@ -485,4 +487,76 @@ ResponseEntity> reorderTodo( @Parameter(description = "이동할 TODO ID", example = "145") Long todoId, TodoReorderRequest request ); + + @Operation( + summary = "하위 태스크 완료 상태 변경", + description = """ + 하위 태스크의 완료 여부를 변경합니다. + + todoId로 소유한 TODO를 확인한 뒤, 해당 TODO에 속한 subtaskId의 완료 상태를 변경합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "하위 태스크 완료 상태 변경 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SubtaskStatusUpdateRequest.class), + examples = @ExampleObject( + name = "하위 태스크 완료 상태 변경 요청 예시", + value = """ + { + "isCompleted": true + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "하위 태스크 완료 상태 변경 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "isCompleted가 누락된 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO이거나 해당 TODO에 속하지 않는 하위 태스크인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> changeSubtaskStatus( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "대상 TODO ID", example = "5") Long todoId, + @Parameter(description = "대상 하위 태스크 ID", example = "2") Long subtaskId, + SubtaskStatusUpdateRequest request + ); } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java new file mode 100644 index 0000000..0d87c51 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotNull; + +public record SubtaskStatusUpdateRequest( + @NotNull + @JsonProperty("isCompleted") + Boolean isCompleted +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java index cf359cb..30be563 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoStatusUpdateRequest.java @@ -4,7 +4,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; + public record TodoStatusUpdateRequest( + @NotNull @JsonProperty("isCompleted") Boolean isCompleted, diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java new file mode 100644 index 0000000..8fbd72e --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskStatusChangeResponse.java @@ -0,0 +1,15 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import com.Timo.Timo.domain.todo.entity.Subtask; + +public record SubtaskStatusChangeResponse( + Long subtaskId, + Boolean completed +) { + public static SubtaskStatusChangeResponse from(Subtask subtask) { + return new SubtaskStatusChangeResponse( + subtask.getId(), + subtask.isCompleted() + ); + } +} diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java index 231ea76..4a6f1b9 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Subtask.java @@ -60,4 +60,8 @@ public void update(String content, boolean completed, int sortOrder) { void assignTodo(Todo todo) { this.todo = todo; } + + public void updateCompleted(boolean completed) { + this.completed = completed; + } } diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java index 8673ab4..7ea562e 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoErrorCode.java @@ -14,7 +14,6 @@ public enum TodoErrorCode implements BaseErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "TODO_400", "필수 필드가 누락되었거나 형식이 올바르지 않습니다."), INVALID_TITLE(HttpStatus.BAD_REQUEST, "TODO_400", "투두명은 한국어 20자/영어 30자를 초과할 수 없습니다."), NO_UPDATE_FIELDS(HttpStatus.BAD_REQUEST, "TODO_400", "수정할 필드가 없습니다."), - IS_COMPLETED_REQUIRED(HttpStatus.BAD_REQUEST, "TODO_400", "isCompleted는 필수입니다."), INVALID_INDEX(HttpStatus.BAD_REQUEST, "TODO_400", "유효하지 않은 인덱스 값입니다."), TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "TODO_404", "존재하지 않는 TODO입니다."), TODO_NOT_FOUND_ON_DATE(HttpStatus.NOT_FOUND, "TODO_404", "해당 날짜에 존재하지 않는 투두입니다."), diff --git a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java index 0d3e5b6..aa52e72 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/todo/exception/TodoSuccessCode.java @@ -14,6 +14,7 @@ public enum TodoSuccessCode implements BaseSuccessCode { CREATED(HttpStatus.CREATED, "TODO가 생성되었습니다."), GET_DETAIL(HttpStatus.OK, "TODO 상세 조회 성공"), STATUS_CHANGED(HttpStatus.OK, "TODO 상태가 변경되었습니다."), + SUBTASK_STATUS_CHANGED(HttpStatus.OK, "하위 태스크 상태가 변경되었습니다."), UPDATED(HttpStatus.OK, "TODO가 수정되었습니다."), DELETED(HttpStatus.OK, "TODO가 삭제되었습니다."), REORDERED(HttpStatus.OK, "TODO 순서가 변경되었습니다."), diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java index 9533ef3..207a28f 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoService.java @@ -12,15 +12,18 @@ import com.Timo.Timo.domain.tag.exception.TagErrorCode; import com.Timo.Timo.domain.tag.repository.TagRepository; import com.Timo.Timo.domain.timer.service.TimerService; +import com.Timo.Timo.domain.todo.dto.request.SubtaskStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoCreateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoReorderRequest; import com.Timo.Timo.domain.todo.dto.request.TodoStatusUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoSubtaskUpdateRequest; import com.Timo.Timo.domain.todo.dto.request.TodoUpdateRequest; +import com.Timo.Timo.domain.todo.dto.response.SubtaskStatusChangeResponse; import com.Timo.Timo.domain.todo.dto.response.TodoCreateResponse; import com.Timo.Timo.domain.todo.dto.response.TodoDetailResponse; import com.Timo.Timo.domain.todo.dto.response.TodoReorderResponse; import com.Timo.Timo.domain.todo.dto.response.TodoStatusChangeResponse; +import com.Timo.Timo.domain.todo.entity.Subtask; import com.Timo.Timo.domain.todo.entity.Todo; import com.Timo.Timo.domain.todo.entity.TodoInstance; import com.Timo.Timo.domain.todo.enums.RepeatType; @@ -106,10 +109,6 @@ public TodoDetailResponse getTodoDetail(Long userId, Long todoId, String dateVal @Transactional public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoStatusUpdateRequest request) { - if (request.isCompleted() == null) { - throw new CustomException(TodoErrorCode.IS_COMPLETED_REQUIRED); - } - Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); @@ -126,6 +125,21 @@ public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoS return TodoStatusChangeResponse.from(todoId, instance); } + @Transactional + public SubtaskStatusChangeResponse changeSubtaskCompletion( + Long userId, Long todoId, Long subtaskId, SubtaskStatusUpdateRequest request) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + Subtask subtask = todo.getSubtasks().stream() + .filter(it -> it.getId().equals(subtaskId)) + .findFirst() + .orElseThrow(() -> new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND)); + + subtask.updateCompleted(request.isCompleted()); + return SubtaskStatusChangeResponse.from(subtask); + } + @Transactional public TodoReorderResponse reorderTodo(Long userId, Long todoId, TodoReorderRequest request) { Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId)