diff --git a/src/main/java/com/umc/timeto/todo/controller/TodoController.java b/src/main/java/com/umc/timeto/todo/controller/TodoController.java index c45fefb..470cff9 100644 --- a/src/main/java/com/umc/timeto/todo/controller/TodoController.java +++ b/src/main/java/com/umc/timeto/todo/controller/TodoController.java @@ -3,12 +3,10 @@ import com.umc.timeto.global.apiPayload.code.ResponseCode; import com.umc.timeto.global.apiPayload.dto.ResponseDTO; import com.umc.timeto.todo.dto.request.TodoCreateRequest; +import com.umc.timeto.todo.dto.request.TodoOrderUpdateRequest; import com.umc.timeto.todo.dto.request.TodoStatusUpdateRequest; import com.umc.timeto.todo.dto.request.TodoUpdateRequest; -import com.umc.timeto.todo.dto.response.TodoCreateResponse; -import com.umc.timeto.todo.dto.response.TodoGetResponse; -import com.umc.timeto.todo.dto.response.TodoIngListResponse; -import com.umc.timeto.todo.dto.response.TodoStatusUpdateResponse; +import com.umc.timeto.todo.dto.response.*; import com.umc.timeto.todo.service.TodoCommandService; import com.umc.timeto.todo.service.TodoQueryService; import com.umc.timeto.todo.service.TodoService; @@ -135,4 +133,16 @@ public ResponseEntity>> getUnblockedTodos( ); } + @Operation(summary = "할 일 순서 변경", description = "todoId를 targetOrder로 이동시킵니다. (state별 정렬)") + @PatchMapping("/order/{todoId}") + public ResponseDTO updateTodoOrder( + @PathVariable Long todoId, + @RequestBody @Valid TodoOrderUpdateRequest request + ) { + Long memberId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + TodoOrderUpdateResponse data = todoCommandService.updateTodoOrder(memberId, todoId, request.getTargetOrder()); + return new ResponseDTO<>(ResponseCode.COMMON200, data); + } + + } diff --git a/src/main/java/com/umc/timeto/todo/domain/Todo.java b/src/main/java/com/umc/timeto/todo/domain/Todo.java index df2ca19..d7a138e 100644 --- a/src/main/java/com/umc/timeto/todo/domain/Todo.java +++ b/src/main/java/com/umc/timeto/todo/domain/Todo.java @@ -44,6 +44,17 @@ public class Todo { @Column(name = "start_at") private LocalDateTime startAt; + // 추가 + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + +// getter는 @Getter로 자동 + + public void changeSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + // ✅ 지금은 연관관계 없이 숫자 FK만 들고감 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "folder_id", nullable = false) @@ -60,17 +71,19 @@ public class Todo { - public static Todo create(Folder folder, String name, TodoPriority priority, LocalTime duration) { + public static Todo create(Folder folder, String name, TodoPriority priority, LocalTime duration, int sortOrder) { Todo todo = new Todo(); todo.folder = folder; todo.name = name; todo.priority = priority; todo.duration = duration; - todo.state = TodoState.progress; // 기본값 + todo.state = TodoState.progress; todo.createAt = LocalDateTime.now(); + todo.sortOrder = sortOrder; return todo; } + public void changeState(TodoState state) { this.state = state; } diff --git a/src/main/java/com/umc/timeto/todo/dto/request/TodoOrderUpdateRequest.java b/src/main/java/com/umc/timeto/todo/dto/request/TodoOrderUpdateRequest.java new file mode 100644 index 0000000..538a940 --- /dev/null +++ b/src/main/java/com/umc/timeto/todo/dto/request/TodoOrderUpdateRequest.java @@ -0,0 +1,12 @@ +package com.umc.timeto.todo.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class TodoOrderUpdateRequest { + @NotNull + @Min(1) + private Integer targetOrder; +} diff --git a/src/main/java/com/umc/timeto/todo/dto/response/TodoGetResponse.java b/src/main/java/com/umc/timeto/todo/dto/response/TodoGetResponse.java index 2aba550..11ebf68 100644 --- a/src/main/java/com/umc/timeto/todo/dto/response/TodoGetResponse.java +++ b/src/main/java/com/umc/timeto/todo/dto/response/TodoGetResponse.java @@ -16,4 +16,5 @@ public class TodoGetResponse { private TodoPriority priority; private TodoState state; private LocalDateTime startAt; + private Integer sortOrder; } diff --git a/src/main/java/com/umc/timeto/todo/dto/response/TodoIngListResponse.java b/src/main/java/com/umc/timeto/todo/dto/response/TodoIngListResponse.java index 06f3cb1..a5a2478 100644 --- a/src/main/java/com/umc/timeto/todo/dto/response/TodoIngListResponse.java +++ b/src/main/java/com/umc/timeto/todo/dto/response/TodoIngListResponse.java @@ -24,5 +24,6 @@ public static class TodoIngItem { private TodoPriority priority; private String duration; // "1H 10M" 형태 private LocalDateTime startAt; + private Integer sortOrder; } } diff --git a/src/main/java/com/umc/timeto/todo/dto/response/TodoOrderUpdateResponse.java b/src/main/java/com/umc/timeto/todo/dto/response/TodoOrderUpdateResponse.java new file mode 100644 index 0000000..6abc81c --- /dev/null +++ b/src/main/java/com/umc/timeto/todo/dto/response/TodoOrderUpdateResponse.java @@ -0,0 +1,11 @@ +package com.umc.timeto.todo.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TodoOrderUpdateResponse { + private Long todoId; + private Integer sortOrder; +} diff --git a/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java b/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java index 810a865..9a5b772 100644 --- a/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java +++ b/src/main/java/com/umc/timeto/todo/repository/TodoRepository.java @@ -18,6 +18,116 @@ public interface TodoRepository extends JpaRepository { List findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndState( Long folderId, Long memberId, TodoState state ); + List findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndStateOrderBySortOrderAsc( + Long folderId, Long memberId, TodoState state + ); + + @Query(""" + select coalesce(max(t.sortOrder), 0) + from Todo t + where t.folder.folderId = :folderId + and t.state = :state + and exists ( + select 1 + from Folder f + join f.goal g + where f = t.folder + and g.member.memberId = :memberId + ) +""") + int findMaxSortOrder(@Param("folderId") Long folderId, + @Param("memberId") Long memberId, + @Param("state") TodoState state); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Todo t + set t.sortOrder = t.sortOrder - 1 + where t.folder.folderId = :folderId + and t.state = :state + and t.sortOrder > :deletedOrder + and exists ( + select 1 + from Folder f + join f.goal g + where f = t.folder + and g.member.memberId = :memberId + ) +""") + void pullUpAfterDelete(@Param("folderId") Long folderId, + @Param("memberId") Long memberId, + @Param("state") TodoState state, + @Param("deletedOrder") int deletedOrder); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Todo t + set t.sortOrder = t.sortOrder + 1 + where t.folder.folderId = :folderId + and t.state = :state + and t.sortOrder >= :targetOrder + and t.sortOrder < :currentOrder + and exists ( + select 1 + from Folder f + join f.goal g + where f = t.folder + and g.member.memberId = :memberId + ) +""") + void shiftDownForMoveUp(@Param("folderId") Long folderId, + @Param("memberId") Long memberId, + @Param("state") TodoState state, + @Param("targetOrder") int targetOrder, + @Param("currentOrder") int currentOrder); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Todo t + set t.sortOrder = t.sortOrder - 1 + where t.folder.folderId = :folderId + and t.state = :state + and t.sortOrder > :currentOrder + and t.sortOrder <= :targetOrder + and exists ( + select 1 + from Folder f + join f.goal g + where f = t.folder + and g.member.memberId = :memberId + ) +""") + void shiftUpForMoveDown(@Param("folderId") Long folderId, + @Param("memberId") Long memberId, + @Param("state") TodoState state, + @Param("currentOrder") int currentOrder, + @Param("targetOrder") int targetOrder); + + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update Todo t + set t.sortOrder = t.sortOrder - 1 + where t.folder.folderId = :folderId + and t.state = :fromState + and t.sortOrder > :fromOrder + and exists ( + select 1 + from Folder f + join f.goal g + where f = t.folder + and g.member.memberId = :memberId + ) +""") + void pullUpAfterStateMove(@Param("folderId") Long folderId, + @Param("memberId") Long memberId, + @Param("fromState") TodoState fromState, + @Param("fromOrder") int fromOrder); + + // ✅ 내 todo만 삭제 void deleteByTodoIdAndFolder_Goal_Member_MemberId(Long todoId, Long memberId); diff --git a/src/main/java/com/umc/timeto/todo/service/TodoCommandService.java b/src/main/java/com/umc/timeto/todo/service/TodoCommandService.java index ae3dcea..d44d2c5 100644 --- a/src/main/java/com/umc/timeto/todo/service/TodoCommandService.java +++ b/src/main/java/com/umc/timeto/todo/service/TodoCommandService.java @@ -3,6 +3,7 @@ import com.umc.timeto.todo.dto.request.TodoStatusUpdateRequest; import com.umc.timeto.todo.dto.request.TodoUpdateRequest; import com.umc.timeto.todo.dto.response.TodoGetResponse; +import com.umc.timeto.todo.dto.response.TodoOrderUpdateResponse; import com.umc.timeto.todo.dto.response.TodoStatusUpdateResponse; import org.springframework.transaction.annotation.Transactional; @@ -15,5 +16,8 @@ public interface TodoCommandService { @Transactional(readOnly = true) List getUnblockedTodos(Long memberId,Long folderId); + + TodoOrderUpdateResponse updateTodoOrder(Long memberId, Long todoId, int targetOrder); + } diff --git a/src/main/java/com/umc/timeto/todo/service/TodoCommandServiceImpl.java b/src/main/java/com/umc/timeto/todo/service/TodoCommandServiceImpl.java index 63fdef8..c410b36 100644 --- a/src/main/java/com/umc/timeto/todo/service/TodoCommandServiceImpl.java +++ b/src/main/java/com/umc/timeto/todo/service/TodoCommandServiceImpl.java @@ -3,9 +3,11 @@ import com.umc.timeto.global.apiPayload.code.ErrorCode; import com.umc.timeto.global.apiPayload.exception.GlobalException; import com.umc.timeto.todo.domain.Todo; +import com.umc.timeto.todo.domain.enums.TodoState; import com.umc.timeto.todo.dto.request.TodoStatusUpdateRequest; import com.umc.timeto.todo.dto.request.TodoUpdateRequest; import com.umc.timeto.todo.dto.response.TodoGetResponse; +import com.umc.timeto.todo.dto.response.TodoOrderUpdateResponse; import com.umc.timeto.todo.dto.response.TodoStatusUpdateResponse; import com.umc.timeto.todo.repository.TodoRepository; import com.umc.timeto.todo.util.DurationFormatter; @@ -25,18 +27,46 @@ public class TodoCommandServiceImpl implements TodoCommandService{ private final BlockService blockService; @Override - public TodoStatusUpdateResponse updateStatus(Long memberId,Long todoId, TodoStatusUpdateRequest request) { + public TodoStatusUpdateResponse updateStatus(Long memberId, Long todoId, TodoStatusUpdateRequest request) { - Todo todo = todoRepository.findByTodoIdAndFolder_Goal_Member_MemberId(todoId,memberId) - .orElseThrow(() -> new GlobalException(ErrorCode.TODO_NOT_FOUND)); // 너희 ErrorCode로 교체 + Todo todo = todoRepository.findByTodoIdAndFolder_Goal_Member_MemberId(todoId, memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.TODO_NOT_FOUND)); - todo.changeState(request.getState()); + TodoState fromState = todo.getState(); + TodoState toState = request.getState(); - return new TodoStatusUpdateResponse( - todo.getTodoId(), - todo.getState().name() // "COMPLETE" - ); + if (fromState != toState) { + + Long folderId = todo.getFolder().getFolderId(); + + // 1️⃣ 상태 먼저 변경 + todo.changeState(toState); + + // 2️⃣ 기존 state 정렬 재정립 + List fromList = + todoRepository.findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndStateOrderBySortOrderAsc( + folderId, memberId, fromState + ); + + int order = 1; + for (Todo t : fromList) { + t.changeSortOrder(order++); + } + + // 3️⃣ 새 state에서 맨 아래 배치 + List toList = + todoRepository.findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndStateOrderBySortOrderAsc( + folderId, memberId, toState + ); + + todo.changeSortOrder(toList.size()); + } + + return new TodoStatusUpdateResponse(todo.getTodoId(), todo.getState().name()); } + + + @Override public TodoGetResponse updateTodo(Long memberId, Long todoId, TodoUpdateRequest request) { @@ -63,20 +93,25 @@ public TodoGetResponse updateTodo(Long memberId, Long todoId, TodoUpdateRequest DurationFormatter.format(todo.getDuration()), todo.getPriority(), todo.getState(), - todo.getStartAt() + todo.getStartAt(), + todo.getSortOrder() ); } - @Override @Transactional public void deleteTodo(Long memberId, Long todoId) { - boolean exists = todoRepository.existsByTodoIdAndFolder_Goal_Member_MemberId(todoId, memberId); - if (!exists) { - throw new GlobalException(ErrorCode.TODO_NOT_FOUND); - } - todoRepository.deleteByTodoIdAndFolder_Goal_Member_MemberId(todoId, memberId); + Todo todo = todoRepository.findByTodoIdAndFolder_Goal_Member_MemberId(todoId, memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.TODO_NOT_FOUND)); + + Long folderId = todo.getFolder().getFolderId(); + TodoState state = todo.getState(); + int deletedOrder = todo.getSortOrder(); + + todoRepository.delete(todo); // 또는 기존 deleteBy... + todoRepository.pullUpAfterDelete(folderId, memberId, state, deletedOrder); } + @Transactional(readOnly = true) @Override public List getUnblockedTodos(Long memberId,Long folderId) { @@ -95,9 +130,43 @@ public List getUnblockedTodos(Long memberId,Long folderId) { DurationFormatter.format(todo.getDuration()), todo.getPriority(), todo.getState(), - todo.getStartAt() + todo.getStartAt(), + todo.getSortOrder() )) .toList(); } + @Transactional + public TodoOrderUpdateResponse updateTodoOrder(Long memberId, Long todoId, int targetOrder) { + Todo todo = todoRepository.findByTodoIdAndFolder_Goal_Member_MemberId(todoId, memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.TODO_NOT_FOUND)); + + Long folderId = todo.getFolder().getFolderId(); + TodoState state = todo.getState(); + + int currentOrder = todo.getSortOrder(); + + int max = todoRepository.findMaxSortOrder(folderId, memberId, state); + int clamped = Math.max(1, Math.min(targetOrder, max)); + + if (clamped == currentOrder) { + return new TodoOrderUpdateResponse(todoId, currentOrder); + } + + if (clamped < currentOrder) { + todoRepository.shiftDownForMoveUp(folderId, memberId, state, clamped, currentOrder); + } else { + todoRepository.shiftUpForMoveDown(folderId, memberId, state, currentOrder, clamped); + } + + // ✅ bulk update 이후에는 다시 조회해서 안전하게 반영 + Todo refreshed = todoRepository.findById(todoId) + .orElseThrow(() -> new GlobalException(ErrorCode.TODO_NOT_FOUND)); + refreshed.changeSortOrder(clamped); + + return new TodoOrderUpdateResponse(todoId, clamped); + } + + + } diff --git a/src/main/java/com/umc/timeto/todo/service/TodoQueryServiceImpl.java b/src/main/java/com/umc/timeto/todo/service/TodoQueryServiceImpl.java index b7b49cd..70b66d9 100644 --- a/src/main/java/com/umc/timeto/todo/service/TodoQueryServiceImpl.java +++ b/src/main/java/com/umc/timeto/todo/service/TodoQueryServiceImpl.java @@ -30,13 +30,17 @@ public TodoGetResponse getTodo(Long memberId, Long todoId) { DurationFormatter.format(todo.getDuration()), todo.getPriority(), todo.getState(), - todo.getStartAt() + todo.getStartAt(), + todo.getSortOrder() ); } @Override public TodoIngListResponse getInProgressTodos(Long memberId, Long folderId) { - List todos = todoRepository.findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndState(folderId,memberId, TodoState.progress); + List todos = + todoRepository.findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndStateOrderBySortOrderAsc( + folderId, memberId, TodoState.progress + ); List items = todos.stream() .map(t -> TodoIngListResponse.TodoIngItem.builder() @@ -45,9 +49,11 @@ public TodoIngListResponse getInProgressTodos(Long memberId, Long folderId) { .priority(t.getPriority()) .duration(DurationFormatter.format(t.getDuration())) .startAt(t.getStartAt()) + .sortOrder(t.getSortOrder()) // ✅ .build()) .toList(); + return TodoIngListResponse.builder() .count(items.size()) .todos(items) @@ -57,7 +63,10 @@ public TodoIngListResponse getInProgressTodos(Long memberId, Long folderId) { @Override public TodoIngListResponse getCompleteTodos(Long memberId, Long folderId) { // ✅ enum이 COMPLETE면 TodoState.COMPLETE 로 변경 - List todos = todoRepository.findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndState(folderId,memberId, TodoState.complete); + List todos = + todoRepository.findAllByFolder_FolderIdAndFolder_Goal_Member_MemberIdAndStateOrderBySortOrderAsc( + folderId, memberId, TodoState.complete + ); List items = todos.stream() .map(t -> TodoIngListResponse.TodoIngItem.builder() @@ -66,9 +75,11 @@ public TodoIngListResponse getCompleteTodos(Long memberId, Long folderId) { .priority(t.getPriority()) .duration(DurationFormatter.format(t.getDuration())) .startAt(t.getStartAt()) + .sortOrder(t.getSortOrder()) // ✅ .build()) .toList(); + return TodoIngListResponse.builder() .count(items.size()) .todos(items) diff --git a/src/main/java/com/umc/timeto/todo/service/TodoServiceImpl.java b/src/main/java/com/umc/timeto/todo/service/TodoServiceImpl.java index 31c0177..ac3bdf5 100644 --- a/src/main/java/com/umc/timeto/todo/service/TodoServiceImpl.java +++ b/src/main/java/com/umc/timeto/todo/service/TodoServiceImpl.java @@ -5,6 +5,7 @@ import com.umc.timeto.global.apiPayload.code.ErrorCode; import com.umc.timeto.global.apiPayload.exception.GlobalException; import com.umc.timeto.todo.domain.Todo; +import com.umc.timeto.todo.domain.enums.TodoState; import com.umc.timeto.todo.dto.request.TodoCreateRequest; import com.umc.timeto.todo.dto.response.TodoCreateResponse; import com.umc.timeto.todo.repository.TodoRepository; @@ -31,7 +32,11 @@ public TodoCreateResponse createTodo(Long memberId, Long folderId, TodoCreateReq LocalTime duration = DurationParser.parseToLocalTime(request.getDuration()); - Todo todo = Todo.create(folder, request.getName(), request.getPriority(), duration); + int max = todoRepository.findMaxSortOrder(folderId, memberId, TodoState.progress); + int newOrder = max + 1; + + Todo todo = Todo.create(folder, request.getName(), request.getPriority(), duration, newOrder); + Todo saved = todoRepository.save(todo); return new TodoCreateResponse(saved.getTodoId());