diff --git a/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java b/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java index a647a0d1..641fb65a 100644 --- a/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java +++ b/src/main/java/com/Timo/Timo/domain/home/mapper/HomeTodoMapper.java @@ -1,7 +1,6 @@ package com.Timo.Timo.domain.home.mapper; import java.util.List; -import java.util.Set; import org.springframework.stereotype.Component; @@ -14,6 +13,7 @@ import com.Timo.Timo.domain.todo.entity.TodoInstance; import com.Timo.Timo.domain.todo.enums.RepeatType; import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; +import com.Timo.Timo.domain.todo.vo.SubtaskStates; @Component public class HomeTodoMapper { @@ -34,7 +34,7 @@ public TodoResponse toResponse(TodoContext context) { todo.getRepeatType() != RepeatType.NONE, resolveTimerStatus(instance), instance != null ? instance.getSortOrder() : context.defaultSortOrder(), - mapSubtasks(todo.getSubtasks(), context.completedSubtaskIds()) + mapSubtasks(todo.getSubtasks(), context.subtaskStates()) ); } @@ -51,9 +51,9 @@ private TodoTimerStatus resolveTimerStatus(TodoInstance instance) { return instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED; } - private List mapSubtasks(List subtasks, Set completedSubtaskIds) { - return subtasks.stream() - .map(subtask -> SubtaskResponse.from(subtask, completedSubtaskIds.contains(subtask.getId()))) + private List mapSubtasks(List subtasks, SubtaskStates subtaskStates) { + return subtaskStates.sort(subtasks).stream() + .map(subtask -> SubtaskResponse.from(subtask, subtaskStates.isCompleted(subtask.getId()))) .toList(); } @@ -62,7 +62,7 @@ public record TodoContext( TodoInstance instance, Tag tag, int defaultSortOrder, - Set completedSubtaskIds + SubtaskStates subtaskStates ) { } } diff --git a/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java b/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java index 0db555a7..e3dbf19e 100644 --- a/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java +++ b/src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -18,13 +17,13 @@ import com.Timo.Timo.domain.home.mapper.HomeTodoMapper.TodoContext; import com.Timo.Timo.domain.tag.entity.Tag; import com.Timo.Timo.domain.tag.repository.TagRepository; -import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; import com.Timo.Timo.domain.todo.entity.Todo; import com.Timo.Timo.domain.todo.entity.TodoInstance; import com.Timo.Timo.domain.todo.repository.SubtaskCompletionRepository; import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; import com.Timo.Timo.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.todo.service.TodoDateCalculator; +import com.Timo.Timo.domain.todo.vo.SubtaskStates; import lombok.RequiredArgsConstructor; @@ -42,8 +41,8 @@ public class HomeTodoReader { public LoadedTodos load(Long userId, LocalDate from, LocalDate to) { List rules = todoRepository.findRulesInRange(userId, from, to); Map instances = loadInstances(rules, from, to); - Map> completedSubtaskIds = loadCompletedSubtaskIds(instances.values()); - return new LoadedTodos(rules, instances, loadTags(rules), completedSubtaskIds); + Map subtaskStates = loadSubtaskStates(instances.values()); + return new LoadedTodos(rules, instances, loadTags(rules), subtaskStates); } public List sortedTodosOn(LoadedTodos loaded, LocalDate date) { @@ -63,7 +62,7 @@ public List sortedTodosOn(LoadedTodos loaded, LocalDate date) { Tag tag = rule.getTagId() == null ? null : loaded.tagsById().get(rule.getTagId()); todos.add(homeTodoMapper.toResponse( new TodoContext(rule, entry.instance(), tag, index, - completedSubtaskIdsFor(loaded, entry.instance())) + subtaskStatesFor(loaded, entry.instance())) )); } @@ -73,20 +72,20 @@ public List sortedTodosOn(LoadedTodos loaded, LocalDate date) { public TodoResponse todoResponseOn(Todo rule, LocalDate date) { TodoInstance instance = todoInstanceRepository.findByTodo_IdAndDate(rule.getId(), date).orElse(null); Tag tag = rule.getTagId() == null ? null : tagRepository.findById(rule.getTagId()).orElse(null); - Set completedSubtaskIds = instance == null - ? Set.of() - : loadCompletedSubtaskIds(List.of(instance)).getOrDefault(instance.getId(), Set.of()); - return homeTodoMapper.toResponse(new TodoContext(rule, instance, tag, 0, completedSubtaskIds)); + SubtaskStates subtaskStates = instance == null + ? SubtaskStates.EMPTY + : loadSubtaskStates(List.of(instance)).getOrDefault(instance.getId(), SubtaskStates.EMPTY); + return homeTodoMapper.toResponse(new TodoContext(rule, instance, tag, 0, subtaskStates)); } - private Set completedSubtaskIdsFor(LoadedTodos loaded, TodoInstance instance) { + private SubtaskStates subtaskStatesFor(LoadedTodos loaded, TodoInstance instance) { if (instance == null) { - return Set.of(); + return SubtaskStates.EMPTY; } - return loaded.completedSubtaskIdsByInstanceId().getOrDefault(instance.getId(), Set.of()); + return loaded.subtaskStatesByInstanceId().getOrDefault(instance.getId(), SubtaskStates.EMPTY); } - private Map> loadCompletedSubtaskIds(Collection instances) { + private Map loadSubtaskStates(Collection instances) { List instanceIds = instances.stream() .map(TodoInstance::getId) .toList(); @@ -94,10 +93,9 @@ private Map> loadCompletedSubtaskIds(Collection in return Map.of(); } return subtaskCompletionRepository.findByTodoInstance_IdIn(instanceIds).stream() - .filter(SubtaskCompletion::isCompleted) .collect(Collectors.groupingBy( completion -> completion.getTodoInstance().getId(), - Collectors.mapping(completion -> completion.getSubtask().getId(), Collectors.toSet()) + Collectors.collectingAndThen(Collectors.toList(), SubtaskStates::from) )); } @@ -163,7 +161,7 @@ public record LoadedTodos( List rules, Map instancesByKey, Map tagsById, - Map> completedSubtaskIdsByInstanceId + Map subtaskStatesByInstanceId ) { } 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 3e902086..6d95cfdb 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 @@ -17,12 +17,14 @@ import org.springframework.web.bind.annotation.RestController; import com.Timo.Timo.domain.todo.docs.TodoControllerDocs; +import com.Timo.Timo.domain.todo.dto.request.SubtaskReorderRequest; 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.TodoMemoUpdateRequest; 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.SubtaskReorderResponse; 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; @@ -132,6 +134,22 @@ public ResponseEntity> reorderTodo( .body(BaseResponse.onSuccess(TodoSuccessCode.REORDERED, response)); } + @Override + @PatchMapping("/{todoId}/subtasks/{subtaskId}/order") + public ResponseEntity> reorderSubtask( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @PathVariable Long subtaskId, + @Valid @RequestBody SubtaskReorderRequest request + ) { + SubtaskReorderResponse response = + todoService.reorderSubtask(userDetails.getUserId(), todoId, subtaskId, request); + + return ResponseEntity + .status(TodoSuccessCode.SUBTASK_REORDERED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.SUBTASK_REORDERED, response)); + } + @Override @PatchMapping("/{todoId}") public ResponseEntity> updateTodo( 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 9602984a..5ee18134 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 @@ -4,12 +4,14 @@ import org.springframework.http.ResponseEntity; +import com.Timo.Timo.domain.todo.dto.request.SubtaskReorderRequest; 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.TodoMemoUpdateRequest; 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.SubtaskReorderResponse; 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; @@ -276,6 +278,7 @@ ResponseEntity> changeTodoStatus( 예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다. subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. + subtasks 배열 순서로 하위 태스크 순서를 바꾸지 않습니다. 신규 태스크는 맨 뒤에 추가되며, 순서 변경은 하위 태스크 순서 변경 API를 사용합니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ @@ -497,6 +500,8 @@ ResponseEntity> reorderTodo( 하위 태스크의 완료 여부를 변경합니다. todoId로 소유한 TODO를 확인한 뒤, 해당 TODO에 속한 subtaskId의 완료 상태를 변경합니다. + 완료하면 해당 날짜 완료 그룹의 맨 아래로, 완료를 취소하면 미완료 그룹의 맨 위로 이동하며 해당 날짜의 하위 태스크 순서를 재계산합니다. + 응답의 sortOrder는 해당 날짜에서 확정된 정렬 순서입니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ @@ -547,6 +552,14 @@ ResponseEntity> reorderTodo( schema = @Schema(implementation = ErrorDto.class) ) ), + @ApiResponse( + responseCode = "409", + description = "같은 날짜의 다른 요청이 처리 중이어서 잠금 대기 시간을 초과한 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "500", description = "서버 내부 오류", @@ -565,6 +578,91 @@ ResponseEntity> changeSubtaskStatus( SubtaskStatusUpdateRequest request ); + @Operation( + summary = "하위 태스크 순서 변경", + description = """ + 드래그 앤 드롭으로 변경된 하위 태스크의 순서를 반영합니다. + + 하위 태스크 순서는 날짜별로 관리되며, 요청한 날짜에만 적용됩니다. 순서를 바꾸지 않은 날짜는 기본 순서를 따릅니다. + 순서 변경은 같은 날짜의 미완료 하위 태스크 그룹 내부에서만 가능하며, 완료된 하위 태스크는 순서를 변경할 수 없습니다. + newIndex는 해당 날짜 미완료 그룹 내에서 0부터 시작하는 목표 위치입니다. + date를 생략하면 사용자 타임존 기준 오늘로 처리합니다. + 서버는 대상 하위 태스크를 새 인덱스로 옮기고, 미완료 그룹을 0부터 재정렬한 뒤 완료 그룹을 그 아래에 배치합니다. + + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. + """ + ) + @RequestBody( + required = true, + description = "하위 태스크 순서 변경 요청", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SubtaskReorderRequest.class), + examples = @ExampleObject( + name = "하위 태스크 순서 변경 요청 예시", + value = """ + { + "newIndex": 0, + "date": "2026-10-09" + } + """ + ) + ) + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "하위 태스크 순서 변경 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "newIndex가 누락되었거나 음수이거나 미완료 그룹 크기를 초과하는 경우, 요청 형식이 잘못된 경우", + 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인 경우, 해당 TODO에 속하지 않는 하위 태스크인 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), + @ApiResponse( + responseCode = "409", + description = "완료된 하위 태스크의 순서를 변경하려는 경우, 같은 날짜의 다른 요청이 처리 중이어서 잠금 대기 시간을 초과한 경우", + 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> reorderSubtask( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "대상 TODO ID", example = "145") Long todoId, + @Parameter(description = "이동할 하위 태스크 ID", example = "12") Long subtaskId, + SubtaskReorderRequest request + ); + @Operation( summary = "메모 수정 (날짜별)", description = """ diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskReorderRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskReorderRequest.java new file mode 100644 index 00000000..25e9a310 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/SubtaskReorderRequest.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import java.time.LocalDate; + +public record SubtaskReorderRequest( + Integer newIndex, + + LocalDate date +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskReorderResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskReorderResponse.java new file mode 100644 index 00000000..3514359d --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/SubtaskReorderResponse.java @@ -0,0 +1,20 @@ +package com.Timo.Timo.domain.todo.dto.response; + +import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record SubtaskReorderResponse( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Long subtaskId, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer sortOrder +) { + + public static SubtaskReorderResponse from(SubtaskCompletion completion) { + return new SubtaskReorderResponse( + completion.getSubtask().getId(), + completion.getSortOrder() + ); + } +} 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 index 2c2ef2aa..d157bf75 100644 --- 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 @@ -1,11 +1,23 @@ package com.Timo.Timo.domain.todo.dto.response; +import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; + import io.swagger.v3.oas.annotations.media.Schema; public record SubtaskStatusChangeResponse( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) Long subtaskId, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - Boolean completed + Boolean completed, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + Integer sortOrder ) { + + public static SubtaskStatusChangeResponse from(SubtaskCompletion completion) { + return new SubtaskStatusChangeResponse( + completion.getSubtask().getId(), + completion.isCompleted(), + completion.getSortOrder() + ); + } } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java index eb7a35fa..64f5ce65 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java @@ -3,7 +3,6 @@ import java.time.LocalDate; import java.util.List; import java.util.Objects; -import java.util.Set; import com.Timo.Timo.domain.tag.entity.Tag; import com.Timo.Timo.domain.todo.entity.Subtask; @@ -12,6 +11,7 @@ import com.Timo.Timo.domain.todo.enums.RepeatType; import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; import com.Timo.Timo.domain.todo.enums.Weekday; +import com.Timo.Timo.domain.todo.vo.SubtaskStates; import io.swagger.v3.oas.annotations.media.Schema; @@ -41,7 +41,7 @@ public record TodoDetailResponse( ) { public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate date, Tag tag, - Set completedSubtaskIds) { + SubtaskStates subtaskStates) { return new TodoDetailResponse( todo.getId(), todo.getIcon() != null ? todo.getIcon().name() : null, @@ -56,8 +56,8 @@ public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED, instance != null ? instance.resolveMemo() : todo.getMemo(), instance != null ? instance.getSortOrder() : null, - todo.getSubtasks().stream() - .map(subtask -> SubtaskResponse.from(subtask, completedSubtaskIds.contains(subtask.getId()))) + subtaskStates.sort(todo.getSubtasks()).stream() + .map(subtask -> SubtaskResponse.from(subtask, subtaskStates.isCompleted(subtask.getId()))) .toList() ); } 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 94511a54..d6c27114 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 @@ -46,9 +46,8 @@ public static Subtask of(String content, int sortOrder) { return subtask; } - public void update(String content, int sortOrder) { + public void updateContent(String content) { this.content = content; - this.sortOrder = sortOrder; } void assignTodo(Todo todo) { diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/SubtaskCompletion.java b/src/main/java/com/Timo/Timo/domain/todo/entity/SubtaskCompletion.java index dfcc07eb..9e7ed246 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/SubtaskCompletion.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/SubtaskCompletion.java @@ -48,6 +48,10 @@ public class SubtaskCompletion extends BaseTimeEntity { @Column(name = "completed", nullable = false) private boolean completed; + // 해당 날짜에서 사용자가 지정한 순서. null이면 하위 태스크의 기본 순서를 따른다. + @Column(name = "sort_order") + private Integer sortOrder; + private SubtaskCompletion(TodoInstance todoInstance, Subtask subtask, boolean completed) { this.todoInstance = todoInstance; this.subtask = subtask; @@ -61,4 +65,8 @@ public static SubtaskCompletion of(TodoInstance todoInstance, Subtask subtask, b public void updateCompleted(boolean completed) { this.completed = completed; } + + public void updateSortOrder(int sortOrder) { + this.sortOrder = sortOrder; + } } diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java index 31bc7c07..0f9f3e84 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import com.Timo.Timo.domain.todo.enums.RepeatType; @@ -31,6 +32,7 @@ import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -64,6 +66,7 @@ public class Todo extends BaseTimeEntity { @Getter(AccessLevel.NONE) @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC, id ASC") private List subtasks = new ArrayList<>(); @Column(name = "start_date", nullable = false) @@ -219,20 +222,26 @@ public void replaceSubtasks(List edits) { .filter(subtask -> subtask.getId() != null) .collect(Collectors.toMap(Subtask::getId, subtask -> subtask)); - List retained = new ArrayList<>(); - for (int i = 0; i < edits.size(); i++) { - SubtaskEdit edit = edits.get(i); - int sortOrder = i + 1; + // 하위 태스크 순서는 날짜별 순서 변경 API로만 바꾼다. 기존 항목은 기본 순서를 유지하고 새 항목은 맨 뒤에 붙인다. + int nextSortOrder = edits.stream() + .map(SubtaskEdit::subtaskId) + .map(existingById::get) + .filter(Objects::nonNull) + .mapToInt(Subtask::getSortOrder) + .max() + .orElse(0) + 1; + List retained = new ArrayList<>(); + for (SubtaskEdit edit : edits) { if (edit.subtaskId() != null) { Subtask existing = existingById.get(edit.subtaskId()); if (existing == null) { throw new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND); } - existing.update(edit.content(), sortOrder); + existing.updateContent(edit.content()); retained.add(existing); } else { - Subtask created = Subtask.of(edit.content(), sortOrder); + Subtask created = Subtask.of(edit.content(), nextSortOrder++); created.assignTodo(this); retained.add(created); } 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 9bf392be..168f3f89 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 @@ -22,6 +22,7 @@ public enum TodoErrorCode implements BaseErrorCode { SUBTASK_NOT_FOUND(HttpStatus.NOT_FOUND, "SUBTASK_404", "존재하지 않는 하위 태스크입니다."), MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), COMPLETED_CANNOT_REORDER(HttpStatus.CONFLICT, "TODO_409", "완료된 투두는 순서를 변경할 수 없습니다."), + COMPLETED_SUBTASK_CANNOT_REORDER(HttpStatus.CONFLICT, "SUBTASK_409", "완료된 하위 태스크는 순서를 변경할 수 없습니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), ; 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 aa52e72d..9735515a 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 @@ -18,6 +18,7 @@ public enum TodoSuccessCode implements BaseSuccessCode { UPDATED(HttpStatus.OK, "TODO가 수정되었습니다."), DELETED(HttpStatus.OK, "TODO가 삭제되었습니다."), REORDERED(HttpStatus.OK, "TODO 순서가 변경되었습니다."), + SUBTASK_REORDERED(HttpStatus.OK, "하위 태스크 순서가 변경되었습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/SubtaskCompletionRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/SubtaskCompletionRepository.java index 615c672f..b99ccca8 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/SubtaskCompletionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/SubtaskCompletionRepository.java @@ -2,21 +2,32 @@ import java.util.Collection; import java.util.List; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; -public interface SubtaskCompletionRepository extends JpaRepository { +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; - Optional findByTodoInstance_IdAndSubtask_Id(Long todoInstanceId, Long subtaskId); +public interface SubtaskCompletionRepository extends JpaRepository { List findByTodoInstance_IdIn(Collection todoInstanceIds); + // 잠금 조회는 트랜잭션 스냅샷이 아닌 최신 커밋 값을 읽으므로, 먼저 처리된 요청의 변경을 덮어쓰지 않는다. + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints({ + @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"), + @QueryHint(name = "jakarta.persistence.lock.scope", value = "NORMAL") + }) + @Query("select sc from SubtaskCompletion sc where sc.todoInstance.id = :todoInstanceId") + List findAllByTodoInstanceIdForUpdate(@Param("todoInstanceId") Long todoInstanceId); + @Modifying(clearAutomatically = true) @Query("delete from SubtaskCompletion sc where sc.todoInstance.id in " + "(select ti.id from TodoInstance ti where ti.todo.id = :todoId)") diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java index 2d360a1f..03acc860 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java @@ -5,16 +5,29 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.query.Param; import com.Timo.Timo.domain.todo.entity.TodoInstance; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; + public interface TodoInstanceRepository extends JpaRepository { Optional findByTodo_IdAndDate(Long todoId, LocalDate date); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints({ + @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"), + @QueryHint(name = "jakarta.persistence.lock.scope", value = "NORMAL") + }) + @Query("select ti from TodoInstance ti where ti.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + @Modifying(clearAutomatically = true) @Query("delete from TodoInstance i where i.todo.id = :todoId") void deleteByTodoId(@Param("todoId") Long todoId); diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/SubtaskReorderer.java b/src/main/java/com/Timo/Timo/domain/todo/service/SubtaskReorderer.java new file mode 100644 index 00000000..cb94d784 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/service/SubtaskReorderer.java @@ -0,0 +1,118 @@ +package com.Timo.Timo.domain.todo.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import com.Timo.Timo.domain.todo.entity.Subtask; +import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +import com.Timo.Timo.domain.todo.repository.SubtaskCompletionRepository; +import com.Timo.Timo.domain.todo.vo.SubtaskStates; +import com.Timo.Timo.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SubtaskReorderer { + + private final SubtaskCompletionRepository subtaskCompletionRepository; + + public SubtaskCompletion applyReorder(Todo todo, TodoInstance instance, Subtask target, Integer newIndex) { + DayGroup group = loadDayGroup(todo, instance); + SubtaskCompletion targetCompletion = group.completionOf(target); + + if (targetCompletion.isCompleted()) { + throw new CustomException(TodoErrorCode.COMPLETED_SUBTASK_CANNOT_REORDER); + } + + List incomplete = group.incomplete(); + if (newIndex == null || newIndex < 0 || newIndex >= incomplete.size()) { + throw new CustomException(TodoErrorCode.INVALID_INDEX); + } + + incomplete.remove(targetCompletion); + incomplete.add(newIndex, targetCompletion); + + renumber(incomplete, group.completed()); + subtaskCompletionRepository.saveAll(group.newCompletions()); + return targetCompletion; + } + + public SubtaskCompletion applyCompletion(Todo todo, TodoInstance instance, Subtask target, boolean completed) { + DayGroup group = loadDayGroup(todo, instance); + SubtaskCompletion targetCompletion = group.completionOf(target); + + List incomplete = group.incomplete(); + List completedGroup = group.completed(); + incomplete.remove(targetCompletion); + completedGroup.remove(targetCompletion); + + targetCompletion.updateCompleted(completed); + if (completed) { + completedGroup.add(targetCompletion); + } else { + incomplete.add(0, targetCompletion); + } + + renumber(incomplete, completedGroup); + subtaskCompletionRepository.saveAll(group.newCompletions()); + return targetCompletion; + } + + private void renumber(List incomplete, List completed) { + int sortOrder = 0; + for (SubtaskCompletion completion : incomplete) { + completion.updateSortOrder(sortOrder++); + } + for (SubtaskCompletion completion : completed) { + completion.updateSortOrder(sortOrder++); + } + } + + // 날짜별 순서를 저장하려면 그날의 모든 하위 태스크에 레코드가 있어야 하므로, 없는 것은 미완료로 준비한다. + // 새 레코드는 검증이 끝난 뒤에 저장해, 잘못된 요청에 INSERT가 나가지 않게 한다. + private DayGroup loadDayGroup(Todo todo, TodoInstance instance) { + Map completionsBySubtaskId = subtaskCompletionRepository + .findAllByTodoInstanceIdForUpdate(instance.getId()).stream() + .collect(Collectors.toMap(completion -> completion.getSubtask().getId(), Function.identity())); + + List newCompletions = new ArrayList<>(); + for (Subtask subtask : todo.getSubtasks()) { + if (!completionsBySubtaskId.containsKey(subtask.getId())) { + SubtaskCompletion created = SubtaskCompletion.of(instance, subtask, false); + completionsBySubtaskId.put(subtask.getId(), created); + newCompletions.add(created); + } + } + + // 화면에 보이는 현재 순서(미완료 → 완료)를 기준으로 그룹을 나눈다. + Map> partitioned = SubtaskStates.from(completionsBySubtaskId.values()) + .sort(todo.getSubtasks()).stream() + .map(subtask -> completionsBySubtaskId.get(subtask.getId())) + .collect(Collectors.partitioningBy( + SubtaskCompletion::isCompleted, + Collectors.toCollection(ArrayList::new) + )); + + return new DayGroup(completionsBySubtaskId, partitioned.get(false), partitioned.get(true), newCompletions); + } + + private record DayGroup( + Map completionsBySubtaskId, + List incomplete, + List completed, + List newCompletions + ) { + SubtaskCompletion completionOf(Subtask subtask) { + return completionsBySubtaskId.get(subtask.getId()); + } + } +} 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 f3446eed..f21b9cd2 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 @@ -14,12 +14,14 @@ 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.SubtaskReorderRequest; 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.SubtaskReorderResponse; 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; @@ -36,6 +38,7 @@ import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; import com.Timo.Timo.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.todo.vo.Duration; +import com.Timo.Timo.domain.todo.vo.SubtaskStates; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; @@ -56,6 +59,7 @@ public class TodoService { private final TodoDateCalculator todoDateCalculator; private final TodoCapacityChecker todoCapacityChecker; private final TodoInstanceReorderer todoInstanceReorderer; + private final SubtaskReorderer subtaskReorderer; private final TimerService timerService; @Transactional @@ -109,19 +113,16 @@ public TodoDetailResponse getTodoDetail(Long userId, Long todoId, String dateVal ? tagRepository.findById(todo.getTagId()).orElse(null) : null; - Set completedSubtaskIds = resolveCompletedSubtaskIds(instance); + SubtaskStates subtaskStates = resolveSubtaskStates(instance); - return TodoDetailResponse.of(todo, instance, date, tag, completedSubtaskIds); + return TodoDetailResponse.of(todo, instance, date, tag, subtaskStates); } - private Set resolveCompletedSubtaskIds(TodoInstance instance) { + private SubtaskStates resolveSubtaskStates(TodoInstance instance) { if (instance == null) { - return Set.of(); + return SubtaskStates.EMPTY; } - return subtaskCompletionRepository.findByTodoInstance_IdIn(List.of(instance.getId())).stream() - .filter(SubtaskCompletion::isCompleted) - .map(completion -> completion.getSubtask().getId()) - .collect(Collectors.toSet()); + return SubtaskStates.from(subtaskCompletionRepository.findByTodoInstance_IdIn(List.of(instance.getId()))); } @Transactional @@ -158,14 +159,9 @@ public SubtaskStatusChangeResponse changeSubtaskCompletion( requireOccurrence(todo, date); - TodoInstance instance = todoInstanceReorderer.materializeInstance(userId, todo, date); - SubtaskCompletion completion = subtaskCompletionRepository - .findByTodoInstance_IdAndSubtask_Id(instance.getId(), subtaskId) - .orElseGet(() -> subtaskCompletionRepository.save( - SubtaskCompletion.of(instance, subtask, false))); - completion.updateCompleted(request.isCompleted()); - - return new SubtaskStatusChangeResponse(subtaskId, request.isCompleted()); + TodoInstance instance = lockInstance(userId, todo, date); + SubtaskCompletion completion = subtaskReorderer.applyCompletion(todo, instance, subtask, request.isCompleted()); + return SubtaskStatusChangeResponse.from(completion); } @Transactional @@ -180,6 +176,14 @@ public void updateMemo(Long userId, Long todoId, LocalDate date, String memo) { instance.updateMemo(memo); } + // 하위 태스크 완료·순서는 날짜별 인스턴스에 저장하므로 인스턴스를 확정한 뒤 잠근다. + // 같은 날짜의 요청이 겹쳐도 완료 레코드를 중복 생성하지 않도록 순서대로 처리한다. + private TodoInstance lockInstance(Long userId, Todo todo, LocalDate date) { + TodoInstance instance = todoInstanceReorderer.materializeInstance(userId, todo, date); + return todoInstanceRepository.findByIdForUpdate(instance.getId()) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + } + private void requireOccurrence(Todo todo, LocalDate date) { boolean instanceExists = todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), date).isPresent(); if (!instanceExists && !todoDateCalculator.occursOn(todo, date)) { @@ -201,6 +205,25 @@ public TodoReorderResponse reorderTodo(Long userId, Long todoId, TodoReorderRequ return TodoReorderResponse.from(todoId, instance); } + @Transactional + public SubtaskReorderResponse reorderSubtask( + Long userId, Long todoId, Long subtaskId, SubtaskReorderRequest 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)); + + LocalDate date = resolveDate(userId, request.date()); + requireOccurrence(todo, date); + + TodoInstance instance = lockInstance(userId, todo, date); + SubtaskCompletion completion = subtaskReorderer.applyReorder(todo, instance, subtask, request.newIndex()); + return SubtaskReorderResponse.from(completion); + } + @Transactional public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { if (request.hasNoUpdatableField()) { diff --git a/src/main/java/com/Timo/Timo/domain/todo/vo/SubtaskStates.java b/src/main/java/com/Timo/Timo/domain/todo/vo/SubtaskStates.java new file mode 100644 index 00000000..45fee0a0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/vo/SubtaskStates.java @@ -0,0 +1,62 @@ +package com.Timo.Timo.domain.todo.vo; + +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.Timo.Timo.domain.todo.entity.Subtask; +import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; + +/** + * 한 날짜(TODO 인스턴스)의 하위 태스크 완료 여부와 날짜별 순서. + */ +public record SubtaskStates( + Set completedSubtaskIds, + Map sortOrderBySubtaskId +) { + + public static final SubtaskStates EMPTY = new SubtaskStates(Set.of(), Map.of()); + + // 값 객체이므로 생성 후 내부 컬렉션이 바뀌지 않도록 불변 복사본으로 보관한다. + public SubtaskStates { + completedSubtaskIds = Set.copyOf(completedSubtaskIds); + sortOrderBySubtaskId = Map.copyOf(sortOrderBySubtaskId); + } + + public static SubtaskStates from(Collection completions) { + Set completedSubtaskIds = completions.stream() + .filter(SubtaskCompletion::isCompleted) + .map(completion -> completion.getSubtask().getId()) + .collect(Collectors.toSet()); + + Map sortOrderBySubtaskId = completions.stream() + .filter(completion -> completion.getSortOrder() != null) + .collect(Collectors.toMap( + completion -> completion.getSubtask().getId(), + SubtaskCompletion::getSortOrder + )); + + return new SubtaskStates(completedSubtaskIds, sortOrderBySubtaskId); + } + + public boolean isCompleted(Long subtaskId) { + return completedSubtaskIds.contains(subtaskId); + } + + /** + * 미완료 → 완료 순으로 나누고, 각 그룹 안에서는 날짜별 순서가 있는 항목을 먼저, 없는 항목은 기본 순서로 뒤에 둔다. + */ + public List sort(List subtasks) { + return subtasks.stream() + .sorted(Comparator + .comparing((Subtask subtask) -> isCompleted(subtask.getId())) + .thenComparing(subtask -> !sortOrderBySubtaskId.containsKey(subtask.getId())) + .thenComparingInt(subtask -> sortOrderBySubtaskId.getOrDefault(subtask.getId(), 0)) + .thenComparingInt(Subtask::getSortOrder) + .thenComparing(Subtask::getId)) + .toList(); + } +}