diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java index e4f4b78f..119e9154 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java @@ -30,6 +30,26 @@ boolean existsByTodo_IdAndTargetDateAndStatusIn( @Query("delete from TimerRecord r where r.todo.id = :todoId") void deleteByTodoId(@Param("todoId") Long todoId); + @Query(""" + select count(r) > 0 + from TimerRecord r + where r.todo.id = :todoId + and r.status in :statuses + and r.targetDate >= :fromDate + """) + boolean existsByTodoIdAndTargetDateFromAndStatusIn( + @Param("todoId") Long todoId, + @Param("fromDate") LocalDate fromDate, + @Param("statuses") List statuses + ); + + @Modifying(clearAutomatically = true) + @Query("delete from TimerRecord r where r.todo.id = :todoId and r.targetDate >= :fromDate") + void deleteByTodoIdAndTargetDateFrom( + @Param("todoId") Long todoId, + @Param("fromDate") LocalDate fromDate + ); + @Modifying(flushAutomatically = true) @Query(""" update TimerRecord r diff --git a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java index a00c18ec..159f8e5d 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java +++ b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerSessionRepository.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.timer.repository; import com.Timo.Timo.domain.timer.entity.TimerSession; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -19,6 +20,17 @@ public interface TimerSessionRepository extends JpaRepository= :fromDate + """) + void deleteByTodoIdAndTargetDateFrom( + @Param("todoId") Long todoId, + @Param("fromDate") LocalDate fromDate + ); + @Query(""" select ts from TimerSession ts diff --git a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java index 9ef9b900..50232d4b 100644 --- a/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java +++ b/src/main/java/com/Timo/Timo/domain/timer/service/TimerService.java @@ -227,6 +227,16 @@ public void deleteTimersByTodo(Long todoId) { timerRecordRepository.deleteByTodoId(todoId); } + public boolean hasActiveTimerFrom(Long todoId, LocalDate fromDate) { + return timerRecordRepository.existsByTodoIdAndTargetDateFromAndStatusIn(todoId, fromDate, ACTIVE_STATUS); + } + + @Transactional + public void deleteTimersByTodoFrom(Long todoId, LocalDate fromDate) { + timerSessionRepository.deleteByTodoIdAndTargetDateFrom(todoId, fromDate); + timerRecordRepository.deleteByTodoIdAndTargetDateFrom(todoId, fromDate); + } + @Transactional(propagation = Propagation.NOT_SUPPORTED) public TimerFinishResponse completeTimer(Long userId, Long timerId) { return finishTimer(userId, timerId, TimerStatus.COMPLETED); 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..1d40ed99 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 @@ -150,9 +150,10 @@ public ResponseEntity> updateTodo( @DeleteMapping("/{todoId}") public ResponseEntity> deleteTodo( @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable Long todoId + @PathVariable Long todoId, + @RequestParam(required = false) String date ) { - todoService.deleteTodo(userDetails.getUserId(), todoId); + todoService.deleteTodo(userDetails.getUserId(), todoId, date); return ResponseEntity .status(TodoSuccessCode.DELETED.getHttpStatus()) 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 6f5607f6..41445e79 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 @@ -363,6 +363,11 @@ ResponseEntity> updateTodo( TODO와 연결된 하위 태스크, 반복 규칙을 함께 삭제합니다. 해당 TODO에 실행 중이거나 일시정지된 타이머가 있으면 삭제할 수 없습니다. + date에는 삭제를 누른 카드의 날짜를 반복 여부와 상관없이 항상 전달합니다. + 반복 TODO는 date를 포함한 이후 반복 일정만 삭제하고, date 이전 일정과 완료·타이머 기록은 유지합니다. + 이때 반복 종료일은 date 전날로 변경되며, date 이후 일정에 실행 중이거나 일시정지된 타이머가 있으면 삭제할 수 없습니다. + 반복이 아닌 TODO는 해당 TODO 전체를 삭제합니다. + 구글 캘린더에서 가져온 일정은 삭제할 수 없습니다. 단, 구글에서 삭제되었지만 타이머 기록이 있어 남아 있는 일정(isDeletedFromGoogle=true)과 캘린더 연동 해제 후 남은 일정(isDisconnectedFromGoogle=true)은 삭제할 수 있습니다. @@ -376,6 +381,14 @@ ResponseEntity> updateTodo( description = "TODO 삭제 성공", useReturnTypeSchema = true ), + @ApiResponse( + responseCode = "400", + description = "date가 누락되었거나 날짜 형식(yyyy-MM-dd)이 올바르지 않은 경우", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorDto.class) + ) + ), @ApiResponse( responseCode = "401", description = "Access Token이 없거나 만료되었거나 유효하지 않은 경우", @@ -411,7 +424,8 @@ ResponseEntity> updateTodo( }) ResponseEntity> deleteTodo( @Parameter(hidden = true) CustomUserDetails userDetails, - @Parameter(description = "삭제할 TODO ID", example = "205") Long todoId + @Parameter(description = "삭제할 TODO ID", example = "205") Long todoId, + @Parameter(description = "삭제를 누른 카드의 날짜 (yyyy-MM-dd)", example = "2026-10-20", required = true) String date ); @Operation( 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 32afad6e..e1efe734 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 @@ -254,6 +254,16 @@ public void changeSchedule( this.repeatDayOfMonth = repeatType == RepeatType.MONTHLY ? repeatDayOfMonth : null; } + public boolean isRepeating() { + return repeatType != RepeatType.NONE; + } + + public void endRepeatBefore(LocalDate date) { + if (!date.isAfter(endDate)) { + this.endDate = date.minusDays(1); + } + } + public void replaceSubtasks(List edits) { Map existingById = subtasks.stream() .filter(subtask -> subtask.getId() != null) 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..1cbe1be8 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 @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.todo.repository; +import java.time.LocalDate; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -22,6 +23,11 @@ public interface SubtaskCompletionRepository extends JpaRepository= :fromDate)") + void deleteByTodoIdAndDateFrom(@Param("todoId") Long todoId, @Param("fromDate") LocalDate fromDate); + @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/repository/TodoInstanceRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoInstanceRepository.java index 2d360a1f..509eab12 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 @@ -19,6 +19,10 @@ public interface TodoInstanceRepository extends JpaRepository= :fromDate") + void deleteByTodoIdAndDateFrom(@Param("todoId") Long todoId, @Param("fromDate") LocalDate fromDate); + @Query(""" select ti from TodoInstance ti diff --git a/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java b/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java index 746e36b0..069f4171 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java +++ b/src/main/java/com/Timo/Timo/domain/todo/service/TodoDateCalculator.java @@ -79,6 +79,16 @@ public boolean occursOn(Todo rule, LocalDate date) { }; } + public boolean occursBefore(Todo rule, LocalDate date) { + LocalDate end = date.isAfter(rule.getEndDate()) ? rule.getEndDate().plusDays(1) : date; + if (!end.isAfter(rule.getStartDate())) { + return false; + } + + return rule.getStartDate().datesUntil(end) + .anyMatch(candidate -> occursOn(rule, candidate)); + } + private boolean matchesWeekly(LocalDate date, List weekdays) { Set targets = weekdays.stream() .map(DAY_OF_WEEKS::get) 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 fbb331e0..e00f8c23 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 @@ -256,7 +256,10 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { } @Transactional - public void deleteTodo(Long userId, Long todoId) { + public void deleteTodo(Long userId, Long todoId, String dateValue) { + LocalDate date = parseDate(dateValue); + userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); @@ -264,6 +267,31 @@ public void deleteTodo(Long userId, Long todoId) { throw new CustomException(TodoErrorCode.GOOGLE_EVENT_NOT_DELETABLE); } + if (todo.isRepeating() && todoDateCalculator.occursBefore(todo, date)) { + removeTodoFrom(todo, date); + } else { + removeTodo(todo); + } + + aiHistoryCacheService.bumpUserHistoryVersionAfterCommit(userId); + } + + private void removeTodoFrom(Todo todo, LocalDate date) { + Long todoId = todo.getId(); + if (timerService.hasActiveTimerFrom(todoId, date)) { + throw new CustomException(TodoErrorCode.TIMER_RUNNING); + } + + todo.endRepeatBefore(date); + todoRepository.flush(); + + timerService.deleteTimersByTodoFrom(todoId, date); + subtaskCompletionRepository.deleteByTodoIdAndDateFrom(todoId, date); + todoInstanceRepository.deleteByTodoIdAndDateFrom(todoId, date); + } + + private void removeTodo(Todo todo) { + Long todoId = todo.getId(); if (timerService.hasActiveTimer(todoId)) { throw new CustomException(TodoErrorCode.TIMER_RUNNING); } @@ -273,8 +301,6 @@ public void deleteTodo(Long userId, Long todoId) { subtaskCompletionRepository.deleteByTodoId(todoId); todoInstanceRepository.deleteByTodoId(todoId); todoRepository.delete(todo); - - aiHistoryCacheService.bumpUserHistoryVersionAfterCommit(userId); } private void validateGoogleEventUpdatable(Todo todo, TodoUpdateRequest request) {