Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.Timo.Timo.domain.home.mapper;

import java.util.List;
import java.util.Set;

import org.springframework.stereotype.Component;

Expand All @@ -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 {
Expand All @@ -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())
);
}

Expand All @@ -51,9 +51,9 @@ private TodoTimerStatus resolveTimerStatus(TodoInstance instance) {
return instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED;
}

private List<SubtaskResponse> mapSubtasks(List<Subtask> subtasks, Set<Long> completedSubtaskIds) {
return subtasks.stream()
.map(subtask -> SubtaskResponse.from(subtask, completedSubtaskIds.contains(subtask.getId())))
private List<SubtaskResponse> mapSubtasks(List<Subtask> subtasks, SubtaskStates subtaskStates) {
return subtaskStates.sort(subtasks).stream()
.map(subtask -> SubtaskResponse.from(subtask, subtaskStates.isCompleted(subtask.getId())))
.toList();
}

Expand All @@ -62,7 +62,7 @@ public record TodoContext(
TodoInstance instance,
Tag tag,
int defaultSortOrder,
Set<Long> completedSubtaskIds
SubtaskStates subtaskStates
) {
}
}
30 changes: 14 additions & 16 deletions src/main/java/com/Timo/Timo/domain/home/service/HomeTodoReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -42,8 +41,8 @@ public class HomeTodoReader {
public LoadedTodos load(Long userId, LocalDate from, LocalDate to) {
List<Todo> rules = todoRepository.findRulesInRange(userId, from, to);
Map<InstanceKey, TodoInstance> instances = loadInstances(rules, from, to);
Map<Long, Set<Long>> completedSubtaskIds = loadCompletedSubtaskIds(instances.values());
return new LoadedTodos(rules, instances, loadTags(rules), completedSubtaskIds);
Map<Long, SubtaskStates> subtaskStates = loadSubtaskStates(instances.values());
return new LoadedTodos(rules, instances, loadTags(rules), subtaskStates);
}

public List<TodoResponse> sortedTodosOn(LoadedTodos loaded, LocalDate date) {
Expand All @@ -63,7 +62,7 @@ public List<TodoResponse> 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()))
));
}

Expand All @@ -73,31 +72,30 @@ public List<TodoResponse> 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<Long> 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<Long> 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<Long, Set<Long>> loadCompletedSubtaskIds(Collection<TodoInstance> instances) {
private Map<Long, SubtaskStates> loadSubtaskStates(Collection<TodoInstance> instances) {
List<Long> instanceIds = instances.stream()
.map(TodoInstance::getId)
.toList();
if (instanceIds.isEmpty()) {
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)
));
}

Expand Down Expand Up @@ -163,7 +161,7 @@ public record LoadedTodos(
List<Todo> rules,
Map<InstanceKey, TodoInstance> instancesByKey,
Map<Long, Tag> tagsById,
Map<Long, Set<Long>> completedSubtaskIdsByInstanceId
Map<Long, SubtaskStates> subtaskStatesByInstanceId
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -132,6 +134,22 @@ public ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
.body(BaseResponse.onSuccess(TodoSuccessCode.REORDERED, response));
}

@Override
@PatchMapping("/{todoId}/subtasks/{subtaskId}/order")
public ResponseEntity<BaseResponse<SubtaskReorderResponse>> 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<BaseResponse<Object>> updateTodo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -276,6 +278,7 @@ ResponseEntity<BaseResponse<TodoStatusChangeResponse>> changeTodoStatus(
예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다.
subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다.
subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다.
subtasks 배열 순서로 하위 태스크 순서를 바꾸지 않습니다. 신규 태스크는 맨 뒤에 추가되며, 순서 변경은 하위 태스크 순서 변경 API를 사용합니다.

Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다.
"""
Expand Down Expand Up @@ -497,6 +500,8 @@ ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
하위 태스크의 완료 여부를 변경합니다.

todoId로 소유한 TODO를 확인한 뒤, 해당 TODO에 속한 subtaskId의 완료 상태를 변경합니다.
완료하면 해당 날짜 완료 그룹의 맨 아래로, 완료를 취소하면 미완료 그룹의 맨 위로 이동하며 해당 날짜의 하위 태스크 순서를 재계산합니다.
응답의 sortOrder는 해당 날짜에서 확정된 정렬 순서입니다.

Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다.
"""
Expand Down Expand Up @@ -547,6 +552,14 @@ ResponseEntity<BaseResponse<TodoReorderResponse>> reorderTodo(
schema = @Schema(implementation = ErrorDto.class)
)
),
@ApiResponse(
responseCode = "409",
description = "같은 날짜의 다른 요청이 처리 중이어서 잠금 대기 시간을 초과한 경우",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorDto.class)
)
),
@ApiResponse(
responseCode = "500",
description = "서버 내부 오류",
Expand All @@ -565,6 +578,91 @@ ResponseEntity<BaseResponse<SubtaskStatusChangeResponse>> 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<BaseResponse<SubtaskReorderResponse>> 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 = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.Timo.Timo.domain.todo.dto.request;

import java.time.LocalDate;

public record SubtaskReorderRequest(
Integer newIndex,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4) newIndex가 null/음수여도 DTO에서는 통과해서, SubtaskRedorderer에서 완료 레코드를 먼저 INSERT 한 뒤 INVALID_INDEX로 롤백되는 것으로 이해했습니다. 잘못된 요청에 불필요한 INSERT가 나가게 되어서 @NotNull @Min(0)을 붙여서 DTO에서 먼저 400으로 막는 방법을 고려해봐도 좋을 것 같습니다! 근데 이 부분은 TodoReorderRequest에서도 같은 구조로 쓰이고 있는 것 같기는 합니다..

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀해주신 것처럼 검증 전에 INSERT가 나가고 있었습니다! DTO에 @NotNull @Min(0)을 붙이면 이 컨트롤러에서는 TodoExceptionHandler가 "필수 필드가 누락되었거나 형식이 올바르지 않습니다." 메시지로 응답해서 같은 인덱스 오류의 메시지가 둘로 나뉘고, 상한(미완료 개수 이상)은 DTO에서 검사할 수 없어서 다른 방법으로 해결했습니다.

새 레코드는 메모리에서만 준비하고, 인덱스 검증을 통과한 뒤에 saveAll()로 저장하도록 순서를 바꿨습니다. 잘못된 요청에는 INSERT가 나가지 않고, 에러 응답은 기존과 같은 TODO_400입니다. (fb86531)


LocalDate date
) { }
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Loading
Loading