diff --git a/db/migration/2026-07-16__drop_subtasks_completed.sql b/db/migration/2026-07-16__drop_subtasks_completed.sql new file mode 100644 index 00000000..5cdc2bd5 --- /dev/null +++ b/db/migration/2026-07-16__drop_subtasks_completed.sql @@ -0,0 +1,6 @@ +-- 하위 태스크 완료가 날짜별(subtask_completions)로 이관되어 규칙 레벨 subtasks.completed는 미사용이 되었다. +-- ddl-auto: update는 컬럼을 자동 삭제하지 않으므로, 기존 DB에서는 이 SQL로 제거해야 +-- 새 하위 태스크 INSERT 시 NOT NULL 위반이 발생하지 않는다. + +ALTER TABLE subtasks + DROP COLUMN completed; diff --git a/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java b/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java index 113ee445..fa7d0172 100644 --- a/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java +++ b/src/main/java/com/Timo/Timo/domain/focus/service/FocusService.java @@ -17,6 +17,8 @@ import com.Timo.Timo.domain.timer.enums.TimerStatus; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.entity.TodoInstance; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; @@ -34,6 +36,7 @@ public class FocusService { private final UserRepository userRepository; private final HomeTodoReader homeTodoReader; private final TimerRecordRepository timerRecordRepository; + private final TodoInstanceRepository todoInstanceRepository; public FocusTodoResult getFocusTodo(Long userId) { User user = getUser(userId); @@ -64,7 +67,7 @@ public FocusTodoResult getFocusTodo(Long userId) { String memo = loaded.rules().stream() .filter(rule -> rule.getId().equals(focusTodo.todoId())) .findFirst() - .map(Todo::getMemo) + .map(rule -> resolveMemo(rule, today)) .orElse(null); return new FocusTodoResult(FocusSuccessCode.GET_FOCUS_TODO, FocusTodoResponse.of(today, focusTodo, memo)); @@ -85,10 +88,16 @@ private FocusTodoResult resolveActiveTimerFocus(Long userId) { return new FocusTodoResult( FocusSuccessCode.GET_FOCUS_TODO, - FocusTodoResponse.of(timerDate, focusTodo, todo.getMemo()) + FocusTodoResponse.of(timerDate, focusTodo, resolveMemo(todo, timerDate)) ); } + private String resolveMemo(Todo rule, LocalDate date) { + return todoInstanceRepository.findByTodo_IdAndDate(rule.getId(), date) + .map(TodoInstance::resolveMemo) + .orElse(rule.getMemo()); + } + private User getUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java b/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java index 1fbc426e..8db3d142 100644 --- a/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java +++ b/src/main/java/com/Timo/Timo/domain/home/dto/response/HomeResponse.java @@ -106,11 +106,11 @@ public record SubtaskResponse( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean completed ) { - public static SubtaskResponse from(Subtask subtask) { + public static SubtaskResponse from(Subtask subtask, boolean completed) { return new SubtaskResponse( subtask.getId(), subtask.getContent(), - subtask.isCompleted() + completed ); } } 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 e8aeec7b..2260b82c 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,6 +1,7 @@ package com.Timo.Timo.domain.home.mapper; import java.util.List; +import java.util.Set; import org.springframework.stereotype.Component; @@ -33,7 +34,7 @@ public TodoResponse toResponse(TodoContext context) { todo.getRepeatType() != RepeatType.NONE, resolveTimerStatus(instance), instance != null ? instance.getSortOrder() : context.defaultSortOrder(), - mapSubtasks(todo.getSubtasks()) + mapSubtasks(todo.getSubtasks(), context.completedSubtaskIds()) ); } @@ -45,9 +46,9 @@ private TodoTimerStatus resolveTimerStatus(TodoInstance instance) { return instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED; } - private List mapSubtasks(List subtasks) { + private List mapSubtasks(List subtasks, Set completedSubtaskIds) { return subtasks.stream() - .map(SubtaskResponse::from) + .map(subtask -> SubtaskResponse.from(subtask, completedSubtaskIds.contains(subtask.getId()))) .toList(); } @@ -55,7 +56,8 @@ public record TodoContext( Todo todo, TodoInstance instance, Tag tag, - int defaultSortOrder + int defaultSortOrder, + Set completedSubtaskIds ) { } } 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 96d0a02d..0db555a7 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 @@ -2,10 +2,12 @@ import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collection; import java.util.Comparator; 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; @@ -16,8 +18,10 @@ 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; @@ -33,10 +37,13 @@ public class HomeTodoReader { private final TagRepository tagRepository; private final TodoDateCalculator todoDateCalculator; private final HomeTodoMapper homeTodoMapper; + private final SubtaskCompletionRepository subtaskCompletionRepository; public LoadedTodos load(Long userId, LocalDate from, LocalDate to) { List rules = todoRepository.findRulesInRange(userId, from, to); - return new LoadedTodos(rules, loadInstances(rules, from, to), loadTags(rules)); + Map instances = loadInstances(rules, from, to); + Map> completedSubtaskIds = loadCompletedSubtaskIds(instances.values()); + return new LoadedTodos(rules, instances, loadTags(rules), completedSubtaskIds); } public List sortedTodosOn(LoadedTodos loaded, LocalDate date) { @@ -55,7 +62,8 @@ public List sortedTodosOn(LoadedTodos loaded, LocalDate date) { Todo rule = entry.rule(); Tag tag = rule.getTagId() == null ? null : loaded.tagsById().get(rule.getTagId()); todos.add(homeTodoMapper.toResponse( - new TodoContext(rule, entry.instance(), tag, index) + new TodoContext(rule, entry.instance(), tag, index, + completedSubtaskIdsFor(loaded, entry.instance())) )); } @@ -65,7 +73,32 @@ 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); - return homeTodoMapper.toResponse(new TodoContext(rule, instance, tag, 0)); + 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)); + } + + private Set completedSubtaskIdsFor(LoadedTodos loaded, TodoInstance instance) { + if (instance == null) { + return Set.of(); + } + return loaded.completedSubtaskIdsByInstanceId().getOrDefault(instance.getId(), Set.of()); + } + + private Map> loadCompletedSubtaskIds(Collection instances) { + List 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()) + )); } private static final Comparator TODO_ORDER = Comparator @@ -129,7 +162,8 @@ private Map loadTags(List rules) { public record LoadedTodos( List rules, Map instancesByKey, - Map tagsById + Map tagsById, + Map> completedSubtaskIdsByInstanceId ) { } diff --git a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java index eb8ebe7b..9d8df1de 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/timer/docs/TimerStartControllerDocs.java @@ -78,7 +78,7 @@ public interface TimerStartControllerDocs { ResponseEntity> startTimer( @Parameter(description = "타이머를 시작할 투두 ID", example = "3") @PathVariable Long todoId, - @Parameter(description = "타이머가 상태를 반영할 대상 날짜(미지정 시 오늘). ISO 형식", example = "2026-07-20") + @Parameter(description = "대상 날짜(쿼리 파라미터). 미지정 시 사용자 타임존 기준 오늘", example = "2026-07-20") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @Parameter(hidden = true) CustomUserDetails userDetails 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 6ea9f974..3e902086 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 @@ -1,7 +1,9 @@ package com.Timo.Timo.domain.todo.controller; +import java.time.LocalDate; import java.util.Map; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -17,6 +19,7 @@ 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.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; @@ -89,16 +92,32 @@ public ResponseEntity> changeSubtaskSt @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long todoId, @PathVariable Long subtaskId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @Valid @RequestBody SubtaskStatusUpdateRequest request ) { SubtaskStatusChangeResponse response = - todoService.changeSubtaskCompletion(userDetails.getUserId(), todoId, subtaskId, request); + todoService.changeSubtaskCompletion(userDetails.getUserId(), todoId, subtaskId, date, request); return ResponseEntity .status(TodoSuccessCode.SUBTASK_STATUS_CHANGED.getHttpStatus()) .body(BaseResponse.onSuccess(TodoSuccessCode.SUBTASK_STATUS_CHANGED, response)); } + @Override + @PatchMapping("/{todoId}/memo") + public ResponseEntity> updateMemo( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long todoId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @Valid @RequestBody TodoMemoUpdateRequest request + ) { + todoService.updateMemo(userDetails.getUserId(), todoId, date, request.memo()); + + return ResponseEntity + .status(TodoSuccessCode.UPDATED.getHttpStatus()) + .body(BaseResponse.onSuccess(TodoSuccessCode.UPDATED, Map.of())); + } + @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 b587e21b..9602984a 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 @@ -1,9 +1,12 @@ package com.Timo.Timo.domain.todo.docs; +import java.time.LocalDate; + 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.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; @@ -289,10 +292,9 @@ ResponseEntity> changeTodoStatus( { "title": "티모 하이와프 작업하기 v2", "priority": "VERY_HIGH", - "memo": "레퍼런스 정리 먼저 → API 명세", "subtasks": [ - { "subtaskId": 1, "content": "타이머 명세 초안", "completed": true }, - { "subtaskId": null, "content": "리뷰 반영", "completed": false } + { "subtaskId": 1, "content": "타이머 명세 초안" }, + { "subtaskId": null, "content": "리뷰 반영" } ] } """ @@ -558,6 +560,33 @@ ResponseEntity> changeSubtaskStatus( @Parameter(hidden = true) CustomUserDetails userDetails, @Parameter(description = "대상 TODO ID", example = "5") Long todoId, @Parameter(description = "대상 하위 태스크 ID", example = "2") Long subtaskId, + @Parameter(description = "완료 상태를 변경할 날짜 (ISO). 반복 TODO는 날짜별로 완료가 분리됩니다.", example = "2026-07-20") + LocalDate date, SubtaskStatusUpdateRequest request ); + + @Operation( + summary = "메모 수정 (날짜별)", + description = """ + 해당 날짜의 메모를 수정합니다. + 메모는 생성 시에만 모든 날짜에 공유되고, 이후 수정은 요청한 date의 인스턴스에만 저장됩니다(다른 날짜는 규칙 메모로 폴백). + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "메모 수정 성공", useReturnTypeSchema = true), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 TODO이거나 해당 날짜에 발생하지 않는 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ) + }) + ResponseEntity> updateMemo( + @Parameter(hidden = true) CustomUserDetails userDetails, + @Parameter(description = "대상 TODO ID", example = "5") Long todoId, + @Parameter(description = "메모를 저장할 날짜 (ISO)", example = "2026-07-20") LocalDate date, + TodoMemoUpdateRequest request + ); } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoMemoUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoMemoUpdateRequest.java new file mode 100644 index 00000000..de418349 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoMemoUpdateRequest.java @@ -0,0 +1,10 @@ +package com.Timo.Timo.domain.todo.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; + +public record TodoMemoUpdateRequest( + @Size(max = 300) + @Schema(description = "해당 날짜에 저장할 메모 (null이면 규칙 메모로 폴백)", example = "오늘은 30분만") + String memo +) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java index f887cfa7..1981e2ee 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoSubtaskUpdateRequest.java @@ -3,15 +3,11 @@ import com.Timo.Timo.domain.todo.validation.ValidSubtaskContent; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; public record TodoSubtaskUpdateRequest( Long subtaskId, @NotBlank @ValidSubtaskContent - String content, - - @NotNull - Boolean completed + String content ) { } diff --git a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java index 7df25968..776e4d65 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java +++ b/src/main/java/com/Timo/Timo/domain/todo/dto/request/TodoUpdateRequest.java @@ -12,7 +12,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Size; public record TodoUpdateRequest( TodoIcon icon, @@ -37,9 +36,6 @@ public record TodoUpdateRequest( @Max(31) Integer repeatDayOfMonth, - @Size(max = 300) - String memo, - @Valid List subtasks ) { @@ -53,7 +49,6 @@ public boolean hasNoUpdatableField() { && repeatType == null && repeatWeekdays == null && repeatDayOfMonth == null - && memo == null && subtasks == null; } } 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 7885c06b..2c2ef2aa 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,7 +1,5 @@ package com.Timo.Timo.domain.todo.dto.response; -import com.Timo.Timo.domain.todo.entity.Subtask; - import io.swagger.v3.oas.annotations.media.Schema; public record SubtaskStatusChangeResponse( @@ -10,10 +8,4 @@ public record SubtaskStatusChangeResponse( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) 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/dto/response/TodoDetailResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java index c57e00a7..eb7a35fa 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,6 +3,7 @@ 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; @@ -39,7 +40,8 @@ public record TodoDetailResponse( List subtasks ) { - public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate date, Tag tag) { + public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate date, Tag tag, + Set completedSubtaskIds) { return new TodoDetailResponse( todo.getId(), todo.getIcon() != null ? todo.getIcon().name() : null, @@ -52,10 +54,10 @@ public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate TagResponse.from(tag), RepeatResponse.from(todo), instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED, - todo.getMemo(), + instance != null ? instance.resolveMemo() : todo.getMemo(), instance != null ? instance.getSortOrder() : null, todo.getSubtasks().stream() - .map(SubtaskResponse::from) + .map(subtask -> SubtaskResponse.from(subtask, completedSubtaskIds.contains(subtask.getId()))) .toList() ); } @@ -98,12 +100,12 @@ public record SubtaskResponse( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean completed ) { - public static SubtaskResponse from(Subtask subtask) { + public static SubtaskResponse from(Subtask subtask, boolean completed) { Objects.requireNonNull(subtask, "하위 태스크는 null일 수 없습니다."); return new SubtaskResponse( subtask.getId(), subtask.getContent(), - subtask.isCompleted() + completed ); } } 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 4a6f1b95..39ff6b5d 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 @@ -36,32 +36,19 @@ public class Subtask extends BaseTimeEntity { @Column(name = "sort_order", nullable = false) private Integer sortOrder; - @Column(name = "completed", nullable = false) - private boolean completed; - public static Subtask of(String content, int sortOrder) { - return of(content, sortOrder, false); - } - - public static Subtask of(String content, int sortOrder, boolean completed) { Subtask subtask = new Subtask(); subtask.content = content; subtask.sortOrder = sortOrder; - subtask.completed = completed; return subtask; } - public void update(String content, boolean completed, int sortOrder) { + public void update(String content, int sortOrder) { this.content = content; - this.completed = completed; this.sortOrder = 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/entity/SubtaskCompletion.java b/src/main/java/com/Timo/Timo/domain/todo/entity/SubtaskCompletion.java new file mode 100644 index 00000000..db66c43f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/SubtaskCompletion.java @@ -0,0 +1,60 @@ +package com.Timo.Timo.domain.todo.entity; + +import com.Timo.Timo.global.common.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "subtask_completions", + uniqueConstraints = @UniqueConstraint( + name = "uk_subtask_completion", + columnNames = {"todo_instance_id", "subtask_id"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SubtaskCompletion extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "todo_instance_id", nullable = false) + private TodoInstance todoInstance; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "subtask_id", nullable = false) + private Subtask subtask; + + @Column(name = "completed", nullable = false) + private boolean completed; + + private SubtaskCompletion(TodoInstance todoInstance, Subtask subtask, boolean completed) { + this.todoInstance = todoInstance; + this.subtask = subtask; + this.completed = completed; + } + + public static SubtaskCompletion of(TodoInstance todoInstance, Subtask subtask, boolean completed) { + return new SubtaskCompletion(todoInstance, subtask, completed); + } + + public void updateCompleted(boolean completed) { + this.completed = completed; + } +} 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 a887da03..2a806556 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 @@ -170,8 +170,7 @@ public void updateFields( String title, Integer durationSeconds, TodoPriority priority, - Long tagId, - String memo + Long tagId ) { if (icon == TodoIcon.NONE) { this.icon = null; @@ -190,9 +189,6 @@ public void updateFields( if (tagId != null) { this.tagId = tagId; } - if (memo != null) { - this.memo = memo; - } } public void changeSchedule( @@ -230,10 +226,10 @@ public void replaceSubtasks(List edits) { if (existing == null) { throw new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND); } - existing.update(edit.content(), edit.completed(), sortOrder); + existing.update(edit.content(), sortOrder); retained.add(existing); } else { - Subtask created = Subtask.of(edit.content(), sortOrder, edit.completed()); + Subtask created = Subtask.of(edit.content(), sortOrder); created.assignTodo(this); retained.add(created); } @@ -247,7 +243,7 @@ public void replaceSubtasks(List edits) { } } - public record SubtaskEdit(Long subtaskId, String content, boolean completed) { } + public record SubtaskEdit(Long subtaskId, String content) { } public List getSubtasks() { if (subtasks == null) { diff --git a/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java b/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java index a8692ed5..5f7b77dc 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java +++ b/src/main/java/com/Timo/Timo/domain/todo/entity/TodoInstance.java @@ -14,6 +14,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -55,6 +56,10 @@ public class TodoInstance extends BaseTimeEntity { @Column(name = "timer_status", nullable = false, length = 20) private TodoTimerStatus timerStatus = TodoTimerStatus.STOPPED; + @Lob + @Column(name = "memo") + private String memo; + public static TodoInstance of(Todo todo, LocalDate date, int sortOrder) { TodoInstance instance = new TodoInstance(); instance.todo = todo; @@ -92,4 +97,12 @@ public void pauseTimer() { public void stopTimer() { this.timerStatus = TodoTimerStatus.STOPPED; } + + public void updateMemo(String memo) { + this.memo = memo; + } + + public String resolveMemo() { + return memo != null ? memo : todo.getMemo(); + } } 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 new file mode 100644 index 00000000..615c672f --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/SubtaskCompletionRepository.java @@ -0,0 +1,28 @@ +package com.Timo.Timo.domain.todo.repository; + +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.Timo.Timo.domain.todo.entity.SubtaskCompletion; + +public interface SubtaskCompletionRepository extends JpaRepository { + + Optional findByTodoInstance_IdAndSubtask_Id(Long todoInstanceId, Long subtaskId); + + List findByTodoInstance_IdIn(Collection todoInstanceIds); + + @Modifying(clearAutomatically = true) + @Query("delete from SubtaskCompletion sc where sc.todoInstance.id in " + + "(select ti.id from TodoInstance ti where ti.todo.id = :todoId)") + void deleteByTodoId(@Param("todoId") Long todoId); + + @Modifying(clearAutomatically = true) + @Query("delete from SubtaskCompletion sc where sc.subtask.id in :subtaskIds") + void deleteBySubtaskIdIn(@Param("subtaskIds") Collection subtaskIds); +} 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 15af99f2..c9cad042 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 @@ -4,6 +4,8 @@ import java.time.format.DateTimeParseException; import java.time.ZoneId; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,11 +26,13 @@ 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.SubtaskCompletion; import com.Timo.Timo.domain.todo.entity.Todo; import com.Timo.Timo.domain.todo.entity.TodoInstance; import com.Timo.Timo.domain.todo.enums.RepeatType; import com.Timo.Timo.domain.todo.enums.Weekday; import com.Timo.Timo.domain.todo.exception.TodoErrorCode; +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.vo.Duration; @@ -46,6 +50,7 @@ public class TodoService { private final TodoRepository todoRepository; private final TodoInstanceRepository todoInstanceRepository; + private final SubtaskCompletionRepository subtaskCompletionRepository; private final UserRepository userRepository; private final TagRepository tagRepository; private final TodoDateCalculator todoDateCalculator; @@ -104,7 +109,19 @@ public TodoDetailResponse getTodoDetail(Long userId, Long todoId, String dateVal ? tagRepository.findById(todo.getTagId()).orElse(null) : null; - return TodoDetailResponse.of(todo, instance, date, tag); + Set completedSubtaskIds = resolveCompletedSubtaskIds(instance); + + return TodoDetailResponse.of(todo, instance, date, tag, completedSubtaskIds); + } + + private Set resolveCompletedSubtaskIds(TodoInstance instance) { + if (instance == null) { + return Set.of(); + } + return subtaskCompletionRepository.findByTodoInstance_IdIn(List.of(instance.getId())).stream() + .filter(SubtaskCompletion::isCompleted) + .map(completion -> completion.getSubtask().getId()) + .collect(Collectors.toSet()); } @Transactional @@ -130,7 +147,7 @@ public TodoStatusChangeResponse changeCompletion(Long userId, Long todoId, TodoS @Transactional public SubtaskStatusChangeResponse changeSubtaskCompletion( - Long userId, Long todoId, Long subtaskId, SubtaskStatusUpdateRequest request) { + Long userId, Long todoId, Long subtaskId, LocalDate date, SubtaskStatusUpdateRequest request) { Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); @@ -139,8 +156,35 @@ public SubtaskStatusChangeResponse changeSubtaskCompletion( .findFirst() .orElseThrow(() -> new CustomException(TodoErrorCode.SUBTASK_NOT_FOUND)); - subtask.updateCompleted(request.isCompleted()); - return SubtaskStatusChangeResponse.from(subtask); + 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()); + } + + @Transactional + public void updateMemo(Long userId, Long todoId, LocalDate date, String memo) { + Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) + .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + + requireOccurrence(todo, date); + + // 메모는 생성 이후 항상 날짜별로 저장한다. 대상 인스턴스를 확정한 뒤 override를 설정. + TodoInstance instance = todoInstanceReorderer.materializeInstance(userId, todo, date); + instance.updateMemo(memo); + } + + private void requireOccurrence(Todo todo, LocalDate date) { + boolean instanceExists = todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), date).isPresent(); + if (!instanceExists && !todoDateCalculator.occursOn(todo, date)) { + throw new CustomException(TodoErrorCode.TODO_NOT_FOUND); + } } @Transactional @@ -178,13 +222,24 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { request.title(), request.durationSeconds(), request.priority(), - request.tagId(), - request.memo() + request.tagId() ); applyScheduleChange(todo, request); if (request.subtasks() != null) { + // 제거되는 하위 태스크의 날짜별 완료 레코드를 먼저 정리해 FK 위반을 방지한다. + Set keepIds = request.subtasks().stream() + .map(TodoSubtaskUpdateRequest::subtaskId) + .filter(id -> id != null) + .collect(Collectors.toSet()); + List removedIds = todo.getSubtasks().stream() + .map(Subtask::getId) + .filter(id -> !keepIds.contains(id)) + .toList(); + if (!removedIds.isEmpty()) { + subtaskCompletionRepository.deleteBySubtaskIdIn(removedIds); + } todo.replaceSubtasks(toSubtaskEdits(request.subtasks())); } } @@ -199,6 +254,8 @@ public void deleteTodo(Long userId, Long todoId) { } timerService.deleteTimersByTodo(todoId); + // 서브태스크 완료 레코드가 인스턴스/서브태스크를 FK로 참조하므로 먼저 삭제한다. + subtaskCompletionRepository.deleteByTodoId(todoId); todoInstanceRepository.deleteByTodoId(todoId); todoRepository.delete(todo); } @@ -272,8 +329,7 @@ private List toSubtaskEdits(List sub return subtasks.stream() .map(subtask -> new Todo.SubtaskEdit( subtask.subtaskId(), - subtask.content(), - subtask.completed() + subtask.content() )) .toList(); }