From d1516af895add6052811a1b573ccb31a02ea94f1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sat, 10 Oct 2026 01:33:55 +0900 Subject: [PATCH 01/23] =?UTF-8?q?feat(todo):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EA=B5=AC=EB=B6=84=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/Timo/domain/todo/entity/Todo.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 ba7a67fe..7978aadf 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 @@ -33,17 +33,23 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; @Entity @Table( name = "todos", - indexes = @Index(name = "idx_todos_user_title", columnList = "user_id, title") + indexes = @Index(name = "idx_todos_user_title", columnList = "user_id, title"), + uniqueConstraints = @UniqueConstraint( + name = "uk_todo_user_external_event", + columnNames = {"user_id", "external_event_id"} + ) ) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -104,6 +110,13 @@ public class Todo extends BaseTimeEntity { @Column(name = "memo") private String memo; + @Column(name = "external_event_id", length = 255) + private String externalEventId; + + @ColumnDefault("false") + @Column(name = "deleted_from_google", nullable = false) + private boolean deletedFromGoogle; + @Builder(access = AccessLevel.PRIVATE) private Todo( User user, @@ -263,6 +276,10 @@ public List getRepeatWeekdays() { return Collections.unmodifiableList(repeatWeekdays); } + public boolean isGoogleEvent() { + return externalEventId != null; + } + private void addSubtask(Subtask subtask) { this.subtasks.add(subtask); subtask.assignTodo(this); From 48ec0f334e5df85c6014e7c656aca07728107d06 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 00:31:10 +0900 Subject: [PATCH 02/23] =?UTF-8?q?feat(todo):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=ED=88=AC=EB=91=90=20=EC=88=98=EC=A0=95/?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/todo/docs/TodoControllerDocs.java | 10 ++++++-- .../domain/todo/exception/TodoErrorCode.java | 2 ++ .../Timo/domain/todo/service/TodoService.java | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) 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..54d2aab2 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 @@ -277,6 +277,9 @@ ResponseEntity> changeTodoStatus( subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. + 구글 캘린더에서 가져온 일정은 구글이 원본이므로 제목, 날짜(반복 규칙 포함)를 수정할 수 없습니다. + 소요 시간, 아이콘, 우선순위, 태그, 하위 태스크는 수정할 수 있습니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ ) @@ -333,7 +336,7 @@ ResponseEntity> changeTodoStatus( ), @ApiResponse( responseCode = "409", - description = "타이머 실행 중에 일정/소요시간 변경을 시도했거나, 변경된 일정의 특정 날짜 TODO가 최대 개수(20개)를 초과한 경우", + description = "타이머 실행 중에 일정/소요시간 변경을 시도했거나, 변경된 일정의 특정 날짜 TODO가 최대 개수(20개)를 초과했거나, 구글 캘린더 일정의 제목/날짜를 수정하려는 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) @@ -360,6 +363,9 @@ ResponseEntity> updateTodo( TODO와 연결된 하위 태스크, 반복 규칙을 함께 삭제합니다. 해당 TODO에 실행 중이거나 일시정지된 타이머가 있으면 삭제할 수 없습니다. + 구글 캘린더에서 가져온 일정은 삭제할 수 없습니다. + 단, 구글에서 삭제되었지만 타이머 기록이 있어 남아 있는 일정은 삭제할 수 있습니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ ) @@ -387,7 +393,7 @@ ResponseEntity> updateTodo( ), @ApiResponse( responseCode = "409", - description = "타이머가 실행 중이거나 일시정지된 상태에서 삭제를 시도한 경우", + description = "타이머가 실행 중이거나 일시정지된 상태에서 삭제를 시도했거나, 구글 캘린더 일정을 삭제하려는 경우", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class) 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..b3c3d528 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 @@ -23,6 +23,8 @@ public enum TodoErrorCode implements BaseErrorCode { MAX_COUNT_EXCEEDED(HttpStatus.CONFLICT, "TODO_409", "해당 날짜의 투두가 최대 개수(20개)를 초과했습니다."), COMPLETED_CANNOT_REORDER(HttpStatus.CONFLICT, "TODO_409", "완료된 투두는 순서를 변경할 수 없습니다."), TIMER_RUNNING(HttpStatus.CONFLICT, "TODO_409", "타이머가 실행 중인 TODO는 변경할 수 없습니다. 타이머를 먼저 종료해주세요."), + GOOGLE_EVENT_NOT_MODIFIABLE(HttpStatus.CONFLICT, "TODO_409", "구글 캘린더에서 가져온 일정은 제목과 날짜를 수정할 수 없습니다."), + GOOGLE_EVENT_NOT_DELETABLE(HttpStatus.CONFLICT, "TODO_409", "구글 캘린더에서 가져온 일정은 삭제할 수 없습니다."), ; private final HttpStatus httpStatus; 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 06ff087f..b3d0dc9f 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 @@ -212,6 +212,8 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + validateGoogleEventUpdatable(todo, request); + boolean changesTimerSensitiveFields = request.durationSeconds() != null || isScheduleChanged(request); if (changesTimerSensitiveFields && timerService.hasActiveTimer(todoId)) { throw new CustomException(TodoErrorCode.TIMER_RUNNING); @@ -257,6 +259,10 @@ public void deleteTodo(Long userId, Long todoId) { Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); + if (todo.isGoogleEvent() && !todo.isDeletedFromGoogle()) { + throw new CustomException(TodoErrorCode.GOOGLE_EVENT_NOT_DELETABLE); + } + if (timerService.hasActiveTimer(todoId)) { throw new CustomException(TodoErrorCode.TIMER_RUNNING); } @@ -270,6 +276,23 @@ public void deleteTodo(Long userId, Long todoId) { aiHistoryCacheService.bumpUserHistoryVersionAfterCommit(userId); } + private void validateGoogleEventUpdatable(Todo todo, TodoUpdateRequest request) { + if (!todo.isGoogleEvent()) { + return; + } + + boolean changesTitle = request.title() != null && !request.title().equals(todo.getTitle()); + boolean changesSchedule = (request.date() != null && !request.date().equals(todo.getStartDate())) + || (request.repeatType() != null && request.repeatType() != todo.getRepeatType()) + || (request.repeatWeekdays() != null && !request.repeatWeekdays().equals(todo.getRepeatWeekdays())) + || (request.repeatDayOfMonth() != null + && !request.repeatDayOfMonth().equals(todo.getRepeatDayOfMonth())); + + if (changesTitle || changesSchedule) { + throw new CustomException(TodoErrorCode.GOOGLE_EVENT_NOT_MODIFIABLE); + } + } + private boolean isHistoryKeyChanged(Todo todo, TodoUpdateRequest request) { return (request.title() != null && !request.title().equals(todo.getTitle())) || (request.tagId() != null && !request.tagId().equals(todo.getTagId())); From ea7e903c782a44c71a30a26dbc030526905aa11c Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 01:42:33 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat(calendar):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=EC=9D=84=20=ED=88=AC=EB=91=90=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarTodoSynchronizer.java | 108 ++++++++++++++++++ .../Timo/Timo/domain/todo/entity/Todo.java | 21 +++- .../todo/repository/TodoRepository.java | 3 + 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java new file mode 100644 index 00000000..d9c48bf6 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -0,0 +1,108 @@ +package com.Timo.Timo.domain.calendar.service; + +import com.Timo.Timo.domain.calendar.dto.client.CalendarEventItem; +import com.Timo.Timo.domain.calendar.utils.CalendarEventDateResolver; +import com.Timo.Timo.domain.todo.entity.Todo; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.domain.user.entity.User; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CalendarTodoSynchronizer { + + private static final int MAX_TITLE_LENGTH = 30; + private static final int MAX_EXTERNAL_EVENT_ID_LENGTH = 255; + private static final String EMPTY_TITLE = "제목 없음"; + + private final TodoRepository todoRepository; + + public void registerEvents( + User user, List items, LocalDate from, LocalDate to, ZoneId userZone + ) { + List occurrences = toOccurrences(items, from, to, userZone); + if (occurrences.isEmpty()) { + return; + } + + List externalEventIds = occurrences.stream() + .map(EventOccurrence::externalEventId) + .toList(); + Map existingByExternalEventId = todoRepository + .findByUser_IdAndExternalEventIdIn(user.getId(), externalEventIds) + .stream() + .collect(Collectors.toMap(Todo::getExternalEventId, Function.identity())); + + List newestFirst = new ArrayList<>(occurrences); + Collections.reverse(newestFirst); + + for (EventOccurrence occurrence : newestFirst) { + Todo existing = existingByExternalEventId.get(occurrence.externalEventId()); + if (existing == null) { + todoRepository.save(Todo.createFromGoogleEvent( + user, occurrence.title(), occurrence.date(), occurrence.externalEventId() + )); + continue; + } + if (!existing.getTitle().equals(occurrence.title())) { + existing.updateFields(null, occurrence.title(), null, null, null); + } + } + } + + private List toOccurrences( + List items, LocalDate from, LocalDate to, ZoneId userZone + ) { + Map occurrencesByExternalEventId = new LinkedHashMap<>(); + + for (CalendarEventItem item : items) { + if (item.id() == null) { + continue; + } + String title = toTitle(item.summary()); + for (LocalDate date : CalendarEventDateResolver.resolveDates(item, userZone)) { + if (date.isBefore(from) || date.isAfter(to)) { + continue; + } + String externalEventId = item.id() + "_" + date; + if (externalEventId.length() > MAX_EXTERNAL_EVENT_ID_LENGTH) { + continue; + } + occurrencesByExternalEventId.putIfAbsent( + externalEventId, new EventOccurrence(externalEventId, title, date) + ); + } + } + + return occurrencesByExternalEventId.values().stream() + .sorted(Comparator.comparing(EventOccurrence::date)) + .toList(); + } + + private String toTitle(String summary) { + if (summary == null || summary.isBlank()) { + return EMPTY_TITLE; + } + + String title = summary.strip(); + if (title.codePointCount(0, title.length()) <= MAX_TITLE_LENGTH) { + return title; + } + return title.substring(0, title.offsetByCodePoints(0, MAX_TITLE_LENGTH)); + } + + private record EventOccurrence(String externalEventId, String title, LocalDate date) { + + } +} 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 7978aadf..61afcf74 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 @@ -130,7 +130,8 @@ private Todo( Integer durationSeconds, TodoPriority priority, Long tagId, - String memo + String memo, + String externalEventId ) { this.user = user; this.icon = icon == TodoIcon.NONE ? null : icon; @@ -145,6 +146,24 @@ private Todo( this.priority = priority; this.tagId = tagId; this.memo = memo; + this.externalEventId = externalEventId; + } + + public static Todo createFromGoogleEvent( + User user, + String title, + LocalDate date, + String externalEventId + ) { + return Todo.builder() + .user(user) + .title(title) + .startDate(date) + .endDate(date) + .repeatType(RepeatType.NONE) + .durationSeconds(0) + .externalEventId(externalEventId) + .build(); } public static Todo create( diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 0da956c7..c3dcce58 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.todo.repository; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -14,6 +15,8 @@ public interface TodoRepository extends JpaRepository { Optional findByIdAndUser_Id(Long id, Long userId); + List findByUser_IdAndExternalEventIdIn(Long userId, Collection externalEventIds); + @Query(""" select t from Todo t where t.user.id = :userId From 1e0a4e53cd88eba0cf5d9eae697ad38b029204f5 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 02:22:17 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat(calendar):=20=EA=B5=AC=EA=B8=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AD=EC=A0=9C=EB=90=9C=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarTodoSynchronizer.java | 56 +++++++++++++++++++ .../repository/TimerRecordRepository.java | 4 ++ .../Timo/Timo/domain/todo/entity/Todo.java | 8 +++ .../todo/repository/TodoRepository.java | 12 ++++ 4 files changed, 80 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index d9c48bf6..dcffd998 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -2,7 +2,10 @@ import com.Timo.Timo.domain.calendar.dto.client.CalendarEventItem; import com.Timo.Timo.domain.calendar.utils.CalendarEventDateResolver; +import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; import com.Timo.Timo.domain.todo.entity.Todo; +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.user.entity.User; import java.time.LocalDate; @@ -10,9 +13,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -27,11 +32,20 @@ public class CalendarTodoSynchronizer { private static final String EMPTY_TITLE = "제목 없음"; private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final SubtaskCompletionRepository subtaskCompletionRepository; + private final TimerRecordRepository timerRecordRepository; public void registerEvents( User user, List items, LocalDate from, LocalDate to, ZoneId userZone ) { List occurrences = toOccurrences(items, from, to, userZone); + + saveOrUpdate(user, occurrences); + removeMissing(user, occurrences, from, to); + } + + private void saveOrUpdate(User user, List occurrences) { if (occurrences.isEmpty()) { return; } @@ -55,12 +69,54 @@ public void registerEvents( )); continue; } + if (existing.isDeletedFromGoogle()) { + existing.unmarkDeletedFromGoogle(); + } if (!existing.getTitle().equals(occurrence.title())) { existing.updateFields(null, occurrence.title(), null, null, null); } } } + private void removeMissing( + User user, List occurrences, LocalDate from, LocalDate to + ) { + Set currentExternalEventIds = occurrences.stream() + .map(EventOccurrence::externalEventId) + .collect(Collectors.toSet()); + + List missingTodos = todoRepository.findGoogleEventsInRange(user.getId(), from, to) + .stream() + .filter(todo -> !currentExternalEventIds.contains(todo.getExternalEventId())) + .toList(); + if (missingTodos.isEmpty()) { + return; + } + + Set recordedTodoIds = new HashSet<>(timerRecordRepository.findTodoIdsHavingRecords( + missingTodos.stream().map(Todo::getId).toList() + )); + + List deletableTodoIds = new ArrayList<>(); + for (Todo todo : missingTodos) { + if (recordedTodoIds.contains(todo.getId())) { + todo.markDeletedFromGoogle(); + continue; + } + deletableTodoIds.add(todo.getId()); + } + if (deletableTodoIds.isEmpty()) { + return; + } + + todoRepository.flush(); + for (Long todoId : deletableTodoIds) { + subtaskCompletionRepository.deleteByTodoId(todoId); + todoInstanceRepository.deleteByTodoId(todoId); + } + todoRepository.deleteAllById(deletableTodoIds); + } + private List toOccurrences( List items, LocalDate from, LocalDate to, ZoneId userZone ) { 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 dcb13d4f..8889573a 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 @@ -5,6 +5,7 @@ import jakarta.persistence.LockModeType; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -19,6 +20,9 @@ public interface TimerRecordRepository extends JpaRepository boolean existsByTodo_IdAndStatusIn(Long todoId, List statuses); + @Query("select distinct tr.todo.id from TimerRecord tr where tr.todo.id in :todoIds") + List findTodoIdsHavingRecords(@Param("todoIds") Collection todoIds); + boolean existsByTodo_IdAndTargetDateAndStatusIn( Long todoId, LocalDate targetDate, List statuses); 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 61afcf74..eddb87f3 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 @@ -299,6 +299,14 @@ public boolean isGoogleEvent() { return externalEventId != null; } + public void markDeletedFromGoogle() { + this.deletedFromGoogle = true; + } + + public void unmarkDeletedFromGoogle() { + this.deletedFromGoogle = false; + } + private void addSubtask(Subtask subtask) { this.subtasks.add(subtask); subtask.assignTodo(this); diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index c3dcce58..47432c76 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -17,6 +17,18 @@ public interface TodoRepository extends JpaRepository { List findByUser_IdAndExternalEventIdIn(Long userId, Collection externalEventIds); + @Query(""" + select t from Todo t + where t.user.id = :userId + and t.externalEventId is not null + and t.startDate between :from and :to + """) + List findGoogleEventsInRange( + @Param("userId") Long userId, + @Param("from") LocalDate from, + @Param("to") LocalDate to + ); + @Query(""" select t from Todo t where t.user.id = :userId From 6aced2a76cf3c75f12356ac841d013797102a747 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 04:27:15 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat(calendar):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EB=8F=99=EA=B8=B0=ED=99=94=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/CalendarController.java | 21 ++++++ .../calendar/docs/CalendarControllerDocs.java | 39 +++++++++++ .../dto/request/CalendarSyncRequest.java | 11 +++ .../exception/CalendarSuccessCode.java | 1 + .../service/CalendarEventSyncService.java | 68 +++++++++++++++++++ .../service/CalendarTodoSynchronizer.java | 17 ++++- 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarSyncRequest.java create mode 100644 src/main/java/com/Timo/Timo/domain/calendar/service/CalendarEventSyncService.java diff --git a/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java b/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java index b760ddae..70a81183 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java @@ -2,18 +2,21 @@ import com.Timo.Timo.domain.calendar.docs.CalendarControllerDocs; import com.Timo.Timo.domain.calendar.dto.request.CalendarConnectRequest; +import com.Timo.Timo.domain.calendar.dto.request.CalendarSyncRequest; import com.Timo.Timo.domain.calendar.dto.response.CalendarAuthorizeResponse; import com.Timo.Timo.domain.calendar.dto.response.CalendarConnectResponse; import com.Timo.Timo.domain.calendar.dto.response.CalendarDisconnectResponse; import com.Timo.Timo.domain.calendar.dto.response.CalendarEventsResponse; import com.Timo.Timo.domain.calendar.exception.CalendarSuccessCode; import com.Timo.Timo.domain.calendar.service.CalendarEventQueryService; +import com.Timo.Timo.domain.calendar.service.CalendarEventSyncService; import com.Timo.Timo.domain.calendar.service.CalendarService; import com.Timo.Timo.global.auth.principal.CustomUserDetails; import com.Timo.Timo.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -34,6 +37,7 @@ public class CalendarController implements CalendarControllerDocs { private final CalendarService calendarService; private final CalendarEventQueryService calendarEventQueryService; + private final CalendarEventSyncService calendarEventSyncService; @Override @GetMapping("/authorize") @@ -90,4 +94,21 @@ public ResponseEntity> getCalendarEvents( BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_EVENTS_RETRIEVED, response) ); } + + @Override + @PostMapping("/events/sync") + public ResponseEntity> syncCalendarEvents( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody(required = false) CalendarSyncRequest request + ) { + Long userId = userDetails.getUserId(); + String filter = request != null ? request.filter() : null; + String baseDate = request != null ? request.baseDate() : null; + + calendarEventSyncService.syncEvents(userId, filter, baseDate); + + return ResponseEntity.ok( + BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_EVENTS_SYNCED, Map.of()) + ); + } } diff --git a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java index ce01caab..541aa66d 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java @@ -1,6 +1,7 @@ package com.Timo.Timo.domain.calendar.docs; import com.Timo.Timo.domain.calendar.dto.request.CalendarConnectRequest; +import com.Timo.Timo.domain.calendar.dto.request.CalendarSyncRequest; import com.Timo.Timo.domain.calendar.dto.response.CalendarAuthorizeResponse; import com.Timo.Timo.domain.calendar.dto.response.CalendarConnectResponse; import com.Timo.Timo.domain.calendar.dto.response.CalendarDisconnectResponse; @@ -134,6 +135,44 @@ ResponseEntity> getCalendarEvents( @Parameter(description = "기준 날짜 (YYYY-MM-DD), 미입력 시 오늘", example = "2026-07-14") String baseDate ); + @Operation( + summary = "구글 캘린더 일정 동기화", + description = """ + filter(DEFAULT/WEEK)와 baseDate 범위의 구글 캘린더 일정을 투두로 저장/갱신합니다. + + DEFAULT: baseDate-7일 ~ baseDate+7일 (총 15일) + + WEEK: baseDate ~ baseDate+6일 (총 7일) + + filter, baseDate 미입력 시 각각 DEFAULT, 오늘 날짜가 기본값으로 사용됩니다. + 홈 조회(/api/v1/home, /api/v1/home/today)를 호출하기 전에 이 API를 먼저 호출해야 구글 일정이 함께 조회됩니다. + + 구글에서 새로 생긴 일정은 투두로 저장되고, 제목/날짜가 바뀐 일정은 갱신됩니다. + 구글에서 삭제된 일정은 타이머 기록이 없으면 삭제되고, 기록이 있으면 삭제되지 않고 구글에서 삭제된 일정으로 표시됩니다. + 소요 시간은 0으로 저장되며, 제목이 30자를 넘으면 30자까지만 저장됩니다. (1차 스프린트 임시 해결책) + """, + security = @SecurityRequirement(name = "bearerAuth") + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "동기화 성공", useReturnTypeSchema = true), + @ApiResponse(responseCode = "400", description = "유효하지 않은 filter 값이거나 날짜 형식 오류", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), + @ApiResponse(responseCode = "401", description = "Access Token 없음/만료/유효하지 않음, 또는 구글 access token 갱신 실패 등 구글 인증 자체 실패", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), + @ApiResponse(responseCode = "404", description = "연동된 캘린더가 없는 경우", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), + @ApiResponse(responseCode = "429", description = "구글 캘린더 API 요청이 일시적으로 제한된 경우", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDto.class))), + @ApiResponse(responseCode = "502", 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> syncCalendarEvents( + @Parameter(hidden = true) CustomUserDetails userDetails, + CalendarSyncRequest request + ); + @Operation( summary = "구글 캘린더 연동 해제", description = """ diff --git a/src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarSyncRequest.java b/src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarSyncRequest.java new file mode 100644 index 00000000..d319befa --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/dto/request/CalendarSyncRequest.java @@ -0,0 +1,11 @@ +package com.Timo.Timo.domain.calendar.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CalendarSyncRequest( + @Schema(description = "동기화 범위 필터 (DEFAULT: 기준일 ±7일, WEEK: 기준일부터 7일), 미입력 시 DEFAULT", example = "DEFAULT") + String filter, + + @Schema(description = "기준 날짜 (yyyy-MM-dd), 미입력 시 오늘", example = "2026-10-09") + String baseDate +) {} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java b/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java index c939e552..e0577c4e 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarSuccessCode.java @@ -13,6 +13,7 @@ public enum CalendarSuccessCode implements BaseSuccessCode { CALENDAR_CONNECTED(HttpStatus.CREATED, "구글 캘린더가 연동되었습니다."), CALENDAR_DISCONNECTED(HttpStatus.OK, "구글 캘린더 연동이 해제되었습니다."), CALENDAR_EVENTS_RETRIEVED(HttpStatus.OK, "캘린더 일정 조회 성공"), + CALENDAR_EVENTS_SYNCED(HttpStatus.OK, "캘린더 일정 동기화 성공"), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarEventSyncService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarEventSyncService.java new file mode 100644 index 00000000..e7afeba5 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarEventSyncService.java @@ -0,0 +1,68 @@ +package com.Timo.Timo.domain.calendar.service; + +import com.Timo.Timo.domain.calendar.client.GoogleOAuthClient; +import com.Timo.Timo.domain.calendar.dto.client.CalendarEventItem; +import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; +import com.Timo.Timo.domain.home.enums.HomeFilter; +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CalendarEventSyncService { + + private final UserRepository userRepository; + private final CalendarTokenService calendarTokenService; + private final GoogleOAuthClient googleOAuthClient; + private final CalendarTodoSynchronizer calendarTodoSynchronizer; + + public void syncEvents(Long userId, String filterValue, String baseDateValue) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + ZoneId userZone = ZoneId.of(user.getZoneId()); + + HomeFilter filter = parseFilter(filterValue); + LocalDate baseDate = parseBaseDate(baseDateValue, userZone); + LocalDate from = filter.rangeStart(baseDate); + LocalDate to = filter.rangeEnd(baseDate); + + String accessToken = calendarTokenService.ensureValidAccessToken(userId); + + Instant timeMin = from.atStartOfDay(userZone).toInstant(); + Instant timeMax = to.plusDays(1).atStartOfDay(userZone).toInstant(); + List items = googleOAuthClient.fetchEvents(accessToken, timeMin, timeMax); + + calendarTodoSynchronizer.synchronize(userId, items, from, to, userZone); + } + + private HomeFilter parseFilter(String filterValue) { + if (filterValue == null || filterValue.isBlank()) { + return HomeFilter.DEFAULT; + } + try { + return HomeFilter.valueOf(filterValue); + } catch (IllegalArgumentException exception) { + throw new CustomException(CalendarErrorCode.INVALID_FILTER_OR_DATE); + } + } + + private LocalDate parseBaseDate(String baseDateValue, ZoneId userZone) { + if (baseDateValue == null || baseDateValue.isBlank()) { + return LocalDate.now(userZone); + } + try { + return LocalDate.parse(baseDateValue); + } catch (DateTimeParseException exception) { + throw new CustomException(CalendarErrorCode.INVALID_FILTER_OR_DATE); + } + } +} diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index dcffd998..eb2f0629 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -8,6 +8,9 @@ import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; import com.Timo.Timo.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.exception.UserErrorCode; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.exception.CustomException; import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; @@ -22,6 +25,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -35,8 +39,19 @@ public class CalendarTodoSynchronizer { private final TodoInstanceRepository todoInstanceRepository; private final SubtaskCompletionRepository subtaskCompletionRepository; private final TimerRecordRepository timerRecordRepository; + private final UserRepository userRepository; - public void registerEvents( + @Transactional + public void synchronize( + Long userId, List items, LocalDate from, LocalDate to, ZoneId userZone + ) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + registerEvents(user, items, from, to, userZone); + } + + private void registerEvents( User user, List items, LocalDate from, LocalDate to, ZoneId userZone ) { List occurrences = toOccurrences(items, from, to, userZone); From 3949d061bafbf1f6f2f3c6fa90e46b27e76e8812 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 04:41:54 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat(calendar):=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EC=8B=9C=20=EC=98=A4=EB=8A=98=20=EC=9D=B4?= =?UTF-8?q?=ED=9B=84=20=EA=B5=AC=EA=B8=80=20=ED=88=AC=EB=91=90=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CalendarConnectionCommandService.java | 5 +++- .../calendar/service/CalendarService.java | 5 +++- .../service/CalendarTodoSynchronizer.java | 26 +++++++++++++++++-- .../todo/repository/TodoRepository.java | 11 ++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java index b561963e..95c7ec01 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java @@ -8,6 +8,7 @@ import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.global.exception.CustomException; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZoneOffset; @@ -23,6 +24,7 @@ public class CalendarConnectionCommandService { private final CalendarConnectionRepository calendarConnectionRepository; + private final CalendarTodoSynchronizer calendarTodoSynchronizer; @Transactional public CalendarConnectResponse saveConnection( @@ -52,7 +54,8 @@ public CalendarConnectResponse saveConnection( } @Transactional - public void deleteConnection(CalendarConnection calendarConnection) { + public void deleteConnection(CalendarConnection calendarConnection, LocalDate today) { + calendarTodoSynchronizer.removeEventsAfter(calendarConnection.getUser().getId(), today); calendarConnectionRepository.delete(calendarConnection); } diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java index 110dafc9..b257e0a7 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java @@ -19,6 +19,8 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; @@ -123,7 +125,8 @@ public CalendarDisconnectResponse disconnect(Long userId) { .build() ); - commandService.deleteConnection(calendarConnection); + ZoneId userZone = ZoneId.of(calendarConnection.getUser().getZoneId()); + commandService.deleteConnection(calendarConnection, LocalDate.now(userZone)); return CalendarDisconnectResponse.builder() .calendarConnected(false) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index eb2f0629..59ab7e63 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -3,6 +3,7 @@ import com.Timo.Timo.domain.calendar.dto.client.CalendarEventItem; import com.Timo.Timo.domain.calendar.utils.CalendarEventDateResolver; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; +import com.Timo.Timo.domain.timer.service.TimerService; import com.Timo.Timo.domain.todo.entity.Todo; import com.Timo.Timo.domain.todo.repository.SubtaskCompletionRepository; import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; @@ -40,6 +41,22 @@ public class CalendarTodoSynchronizer { private final SubtaskCompletionRepository subtaskCompletionRepository; private final TimerRecordRepository timerRecordRepository; private final UserRepository userRepository; + private final TimerService timerService; + + @Transactional + public void removeEventsAfter(Long userId, LocalDate date) { + userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + List todoIds = todoRepository.findGoogleEventsAfter(userId, date).stream() + .map(Todo::getId) + .toList(); + if (todoIds.isEmpty()) { + return; + } + + deleteTodos(todoIds); + } @Transactional public void synchronize( @@ -124,12 +141,17 @@ private void removeMissing( return; } + deleteTodos(deletableTodoIds); + } + + private void deleteTodos(List todoIds) { todoRepository.flush(); - for (Long todoId : deletableTodoIds) { + for (Long todoId : todoIds) { + timerService.deleteTimersByTodo(todoId); subtaskCompletionRepository.deleteByTodoId(todoId); todoInstanceRepository.deleteByTodoId(todoId); } - todoRepository.deleteAllById(deletableTodoIds); + todoRepository.deleteAllById(todoIds); } private List toOccurrences( diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 47432c76..f9ebfe9f 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -29,6 +29,17 @@ List findGoogleEventsInRange( @Param("to") LocalDate to ); + @Query(""" + select t from Todo t + where t.user.id = :userId + and t.externalEventId is not null + and t.startDate > :date + """) + List findGoogleEventsAfter( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); + @Query(""" select t from Todo t where t.user.id = :userId From 95cbddcf7267dc0263369feb7563e44e5d1f4e1a Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 04:54:17 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat(todo):=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EA=B5=AC=EA=B8=80=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EA=B5=AC=EB=B6=84=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/home/dto/response/HomeResponse.java | 6 ++++++ .../Timo/domain/home/dto/response/TodayResponse.java | 8 ++++++++ .../Timo/Timo/domain/home/mapper/HomeTodoMapper.java | 2 ++ .../domain/todo/dto/response/TodoDetailResponse.java | 10 ++++++++++ 4 files changed, 26 insertions(+) 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 773b726f..0d9e2f2c 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 @@ -78,6 +78,12 @@ public record TodoResponse( @JsonProperty("isRepeated") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean isRepeated, + @JsonProperty("isGoogleEvent") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isGoogleEvent, + @JsonProperty("isDeletedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDeletedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) TodoTimerStatus timerStatus, Integer sortOrder, diff --git a/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java b/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java index 32a95529..055f7fd6 100644 --- a/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java +++ b/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java @@ -59,6 +59,12 @@ public record TodayTodoResponse( @JsonProperty("isRepeated") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean isRepeated, + @JsonProperty("isGoogleEvent") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isGoogleEvent, + @JsonProperty("isDeletedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDeletedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMemo, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) @@ -78,6 +84,8 @@ public static TodayTodoResponse from(LocalDate date, TodoResponse todo) { todo.priority(), todo.tag(), todo.isRepeated(), + todo.isGoogleEvent(), + todo.isDeletedFromGoogle(), todo.hasMemo(), todo.timerStatus(), todo.sortOrder(), 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..2a97fa29 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 @@ -32,6 +32,8 @@ public TodoResponse toResponse(TodoContext context) { mapTag(context.tag()), hasMemo(todo, instance), todo.getRepeatType() != RepeatType.NONE, + todo.isGoogleEvent(), + todo.isDeletedFromGoogle(), resolveTimerStatus(instance), instance != null ? instance.getSortOrder() : context.defaultSortOrder(), mapSubtasks(todo.getSubtasks(), context.completedSubtaskIds()) 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..f513dc29 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 @@ -13,6 +13,8 @@ import com.Timo.Timo.domain.todo.enums.TodoTimerStatus; import com.Timo.Timo.domain.todo.enums.Weekday; +import com.fasterxml.jackson.annotation.JsonProperty; + import io.swagger.v3.oas.annotations.media.Schema; public record TodoDetailResponse( @@ -32,6 +34,12 @@ public record TodoDetailResponse( TagResponse tag, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) RepeatResponse repeat, + @JsonProperty("isGoogleEvent") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isGoogleEvent, + @JsonProperty("isDeletedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDeletedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) TodoTimerStatus timerStatus, String memo, @@ -53,6 +61,8 @@ public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate todo.getPriority() != null ? todo.getPriority().name() : null, TagResponse.from(tag), RepeatResponse.from(todo), + todo.isGoogleEvent(), + todo.isDeletedFromGoogle(), instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED, instance != null ? instance.resolveMemo() : todo.getMemo(), instance != null ? instance.getSortOrder() : null, From a09da6b98c9ebf18d19e3f4753207e96c06168f7 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 20:32:45 +0900 Subject: [PATCH 08/23] =?UTF-8?q?fix(calendar):=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=EB=90=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=80=EC=9E=A5=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/calendar/service/CalendarTodoSynchronizer.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index 59ab7e63..f2aeee82 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -1,6 +1,8 @@ package com.Timo.Timo.domain.calendar.service; import com.Timo.Timo.domain.calendar.dto.client.CalendarEventItem; +import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; +import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; import com.Timo.Timo.domain.calendar.utils.CalendarEventDateResolver; import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; import com.Timo.Timo.domain.timer.service.TimerService; @@ -42,6 +44,7 @@ public class CalendarTodoSynchronizer { private final TimerRecordRepository timerRecordRepository; private final UserRepository userRepository; private final TimerService timerService; + private final CalendarConnectionRepository calendarConnectionRepository; @Transactional public void removeEventsAfter(Long userId, LocalDate date) { @@ -65,6 +68,10 @@ public void synchronize( User user = userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + if (!calendarConnectionRepository.existsByUserId(userId)) { + throw new CustomException(CalendarErrorCode.CALENDAR_NOT_CONNECTED); + } + registerEvents(user, items, from, to, userZone); } From 711ee7ba0fff18cefd08031b1c748e39b93a560d Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 20:55:07 +0900 Subject: [PATCH 09/23] =?UTF-8?q?fix(calendar):=20=EA=B8=B4=20=EA=B5=AC?= =?UTF-8?q?=EA=B8=80=20=EC=9D=BC=EC=A0=95=20ID=EB=A5=BC=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=82=A4=20=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarTodoSynchronizer.java | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index f2aeee82..d6cd692d 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -14,12 +14,16 @@ import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; import com.Timo.Timo.global.exception.CustomException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -36,6 +40,7 @@ public class CalendarTodoSynchronizer { private static final int MAX_TITLE_LENGTH = 30; private static final int MAX_EXTERNAL_EVENT_ID_LENGTH = 255; + private static final String HASHED_ID_PREFIX = "sha256:"; private static final String EMPTY_TITLE = "제목 없음"; private final TodoRepository todoRepository; @@ -175,10 +180,7 @@ private List toOccurrences( if (date.isBefore(from) || date.isAfter(to)) { continue; } - String externalEventId = item.id() + "_" + date; - if (externalEventId.length() > MAX_EXTERNAL_EVENT_ID_LENGTH) { - continue; - } + String externalEventId = toExternalEventId(item.id(), date); occurrencesByExternalEventId.putIfAbsent( externalEventId, new EventOccurrence(externalEventId, title, date) ); @@ -190,6 +192,24 @@ externalEventId, new EventOccurrence(externalEventId, title, date) .toList(); } + private String toExternalEventId(String googleEventId, LocalDate date) { + String externalEventId = googleEventId + "_" + date; + if (externalEventId.length() <= MAX_EXTERNAL_EVENT_ID_LENGTH) { + return externalEventId; + } + return HASHED_ID_PREFIX + sha256Hex(externalEventId); + } + + private String sha256Hex(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private String toTitle(String summary) { if (summary == null || summary.isBlank()) { return EMPTY_TITLE; From 2df5fc75f39543ddc50054cd554c5cc890932276 Mon Sep 17 00:00:00 2001 From: jy000n Date: Sun, 11 Oct 2026 21:04:13 +0900 Subject: [PATCH 10/23] =?UTF-8?q?fix(todo):=20=EC=9D=BC=EC=A0=95=EC=9D=B4?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=B0=94=EB=80=90=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=EC=97=90=EB=A7=8C=20=EC=9A=A9=EB=9F=89=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/todo/service/TodoService.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 b3d0dc9f..acab4d48 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 @@ -214,7 +214,7 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { validateGoogleEventUpdatable(todo, request); - boolean changesTimerSensitiveFields = request.durationSeconds() != null || isScheduleChanged(request); + boolean changesTimerSensitiveFields = request.durationSeconds() != null || isScheduleChanged(todo, request); if (changesTimerSensitiveFields && timerService.hasActiveTimer(todoId)) { throw new CustomException(TodoErrorCode.TIMER_RUNNING); } @@ -298,15 +298,17 @@ private boolean isHistoryKeyChanged(Todo todo, TodoUpdateRequest request) { || (request.tagId() != null && !request.tagId().equals(todo.getTagId())); } - private boolean isScheduleChanged(TodoUpdateRequest request) { - return request.date() != null - || request.repeatType() != null - || request.repeatWeekdays() != null - || request.repeatDayOfMonth() != null; + private boolean isScheduleChanged(Todo todo, TodoUpdateRequest request) { + return (request.date() != null && !request.date().equals(todo.getStartDate())) + || (request.repeatType() != null && request.repeatType() != todo.getRepeatType()) + || (request.repeatWeekdays() != null + && !request.repeatWeekdays().equals(todo.getRepeatWeekdays())) + || (request.repeatDayOfMonth() != null + && !request.repeatDayOfMonth().equals(todo.getRepeatDayOfMonth())); } private void applyScheduleChange(Todo todo, TodoUpdateRequest request) { - if (!isScheduleChanged(request)) { + if (!isScheduleChanged(todo, request)) { return; } From 02bd072344898b8ed4663c4d0b820841e62a9681 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 14:59:05 +0900 Subject: [PATCH 11/23] =?UTF-8?q?fix(calendar):=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=ED=9B=84=20=EB=82=A8=EC=9D=80=20=EA=B5=AC?= =?UTF-8?q?=EA=B8=80=20=ED=88=AC=EB=91=90=EB=A5=BC=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/docs/CalendarControllerDocs.java | 4 ++++ .../service/CalendarConnectionCommandService.java | 2 +- .../calendar/service/CalendarTodoSynchronizer.java | 14 +++++++++----- .../domain/home/dto/response/HomeResponse.java | 3 +++ .../domain/home/dto/response/TodayResponse.java | 4 ++++ .../Timo/domain/home/mapper/HomeTodoMapper.java | 1 + .../Timo/domain/todo/docs/TodoControllerDocs.java | 3 ++- .../todo/dto/response/TodoDetailResponse.java | 4 ++++ .../com/Timo/Timo/domain/todo/entity/Todo.java | 12 ++++++++++++ .../domain/todo/repository/TodoRepository.java | 11 +++++++++++ .../Timo/Timo/domain/todo/service/TodoService.java | 2 +- 11 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java index 541aa66d..2a4b03fd 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java @@ -177,6 +177,10 @@ ResponseEntity> syncCalendarEvents( summary = "구글 캘린더 연동 해제", description = """ 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. + + 해제 시점 기준 오늘 이후의 구글 일정 투두는 삭제됩니다. + 오늘과 지난 구글 일정 투두는 유지되며 isDisconnectedFromGoogle=true로 표시되어 삭제할 수 있게 됩니다. + 다시 연동하여 동기화하면 구글에 남아 있는 일정은 다시 isDisconnectedFromGoogle=false로 표시됩니다. """, security = @SecurityRequirement(name = "bearerAuth") ) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java index 95c7ec01..46d2f21a 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java @@ -55,7 +55,7 @@ public CalendarConnectResponse saveConnection( @Transactional public void deleteConnection(CalendarConnection calendarConnection, LocalDate today) { - calendarTodoSynchronizer.removeEventsAfter(calendarConnection.getUser().getId(), today); + calendarTodoSynchronizer.handleDisconnect(calendarConnection.getUser().getId(), today); calendarConnectionRepository.delete(calendarConnection); } diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index d6cd692d..b2257687 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -52,18 +52,19 @@ public class CalendarTodoSynchronizer { private final CalendarConnectionRepository calendarConnectionRepository; @Transactional - public void removeEventsAfter(Long userId, LocalDate date) { + public void handleDisconnect(Long userId, LocalDate today) { userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - List todoIds = todoRepository.findGoogleEventsAfter(userId, date).stream() + List todoIds = todoRepository.findGoogleEventsAfter(userId, today).stream() .map(Todo::getId) .toList(); - if (todoIds.isEmpty()) { - return; + if (!todoIds.isEmpty()) { + deleteTodos(todoIds); } - deleteTodos(todoIds); + todoRepository.findGoogleEventsUpTo(userId, today) + .forEach(Todo::markDisconnectedFromGoogle); } @Transactional @@ -116,6 +117,9 @@ private void saveOrUpdate(User user, List occurrences) { if (existing.isDeletedFromGoogle()) { existing.unmarkDeletedFromGoogle(); } + if (existing.isDisconnectedFromGoogle()) { + existing.unmarkDisconnectedFromGoogle(); + } if (!existing.getTitle().equals(occurrence.title())) { existing.updateFields(null, occurrence.title(), null, null, null); } 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 0d9e2f2c..580497d9 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 @@ -84,6 +84,9 @@ public record TodoResponse( @JsonProperty("isDeletedFromGoogle") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean isDeletedFromGoogle, + @JsonProperty("isDisconnectedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDisconnectedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) TodoTimerStatus timerStatus, Integer sortOrder, diff --git a/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java b/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java index 055f7fd6..466d1d39 100644 --- a/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java +++ b/src/main/java/com/Timo/Timo/domain/home/dto/response/TodayResponse.java @@ -65,6 +65,9 @@ public record TodayTodoResponse( @JsonProperty("isDeletedFromGoogle") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean isDeletedFromGoogle, + @JsonProperty("isDisconnectedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDisconnectedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMemo, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) @@ -86,6 +89,7 @@ public static TodayTodoResponse from(LocalDate date, TodoResponse todo) { todo.isRepeated(), todo.isGoogleEvent(), todo.isDeletedFromGoogle(), + todo.isDisconnectedFromGoogle(), todo.hasMemo(), todo.timerStatus(), todo.sortOrder(), 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 2a97fa29..d13160fc 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 @@ -34,6 +34,7 @@ public TodoResponse toResponse(TodoContext context) { todo.getRepeatType() != RepeatType.NONE, todo.isGoogleEvent(), todo.isDeletedFromGoogle(), + todo.isDisconnectedFromGoogle(), resolveTimerStatus(instance), instance != null ? instance.getSortOrder() : context.defaultSortOrder(), mapSubtasks(todo.getSubtasks(), context.completedSubtaskIds()) 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 54d2aab2..6f5607f6 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 @@ -364,7 +364,8 @@ ResponseEntity> updateTodo( 해당 TODO에 실행 중이거나 일시정지된 타이머가 있으면 삭제할 수 없습니다. 구글 캘린더에서 가져온 일정은 삭제할 수 없습니다. - 단, 구글에서 삭제되었지만 타이머 기록이 있어 남아 있는 일정은 삭제할 수 있습니다. + 단, 구글에서 삭제되었지만 타이머 기록이 있어 남아 있는 일정(isDeletedFromGoogle=true)과 + 캘린더 연동 해제 후 남은 일정(isDisconnectedFromGoogle=true)은 삭제할 수 있습니다. Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ 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 f513dc29..076f0aa7 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 @@ -40,6 +40,9 @@ public record TodoDetailResponse( @JsonProperty("isDeletedFromGoogle") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean isDeletedFromGoogle, + @JsonProperty("isDisconnectedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDisconnectedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) TodoTimerStatus timerStatus, String memo, @@ -63,6 +66,7 @@ public static TodoDetailResponse of(Todo todo, TodoInstance instance, LocalDate RepeatResponse.from(todo), todo.isGoogleEvent(), todo.isDeletedFromGoogle(), + todo.isDisconnectedFromGoogle(), instance != null ? instance.getTimerStatus() : TodoTimerStatus.STOPPED, instance != null ? instance.resolveMemo() : todo.getMemo(), instance != null ? instance.getSortOrder() : null, 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 eddb87f3..ee90747a 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 @@ -117,6 +117,10 @@ public class Todo extends BaseTimeEntity { @Column(name = "deleted_from_google", nullable = false) private boolean deletedFromGoogle; + @ColumnDefault("false") + @Column(name = "disconnected_from_google", nullable = false) + private boolean disconnectedFromGoogle; + @Builder(access = AccessLevel.PRIVATE) private Todo( User user, @@ -307,6 +311,14 @@ public void unmarkDeletedFromGoogle() { this.deletedFromGoogle = false; } + public void markDisconnectedFromGoogle() { + this.disconnectedFromGoogle = true; + } + + public void unmarkDisconnectedFromGoogle() { + this.disconnectedFromGoogle = false; + } + private void addSubtask(Subtask subtask) { this.subtasks.add(subtask); subtask.assignTodo(this); diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index f9ebfe9f..64a2a3ce 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -40,6 +40,17 @@ List findGoogleEventsAfter( @Param("date") LocalDate date ); + @Query(""" + select t from Todo t + where t.user.id = :userId + and t.externalEventId is not null + and t.startDate <= :date + """) + List findGoogleEventsUpTo( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); + @Query(""" select t from Todo t where t.user.id = :userId 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 acab4d48..9685bb27 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 @@ -259,7 +259,7 @@ public void deleteTodo(Long userId, Long todoId) { Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); - if (todo.isGoogleEvent() && !todo.isDeletedFromGoogle()) { + if (todo.isGoogleEvent() && !todo.isDeletedFromGoogle() && !todo.isDisconnectedFromGoogle()) { throw new CustomException(TodoErrorCode.GOOGLE_EVENT_NOT_DELETABLE); } From 8ea33fe4696193dc412ec5852d2b9afc54fe0abc Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 15:33:52 +0900 Subject: [PATCH 12/23] =?UTF-8?q?fix(calendar):=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EC=8B=9C=20=ED=86=A0=ED=81=B0=20revoke=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EA=B3=BC=20=EC=97=B0=EA=B2=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=ED=95=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarConnectionCommandService.java | 13 +++++++++++++ .../domain/calendar/service/CalendarService.java | 13 ------------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java index 46d2f21a..e75f0fb1 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java @@ -4,8 +4,10 @@ import com.Timo.Timo.domain.calendar.dto.client.GoogleTokenResponse; import com.Timo.Timo.domain.calendar.dto.client.GoogleUserInfoResponse; import com.Timo.Timo.domain.calendar.entity.CalendarConnection; +import com.Timo.Timo.domain.calendar.entity.CalendarRevocationOutbox; import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; +import com.Timo.Timo.domain.calendar.repository.CalendarRevocationOutboxRepository; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.global.exception.CustomException; import java.time.LocalDate; @@ -24,6 +26,7 @@ public class CalendarConnectionCommandService { private final CalendarConnectionRepository calendarConnectionRepository; + private final CalendarRevocationOutboxRepository calendarRevocationOutboxRepository; private final CalendarTodoSynchronizer calendarTodoSynchronizer; @Transactional @@ -56,6 +59,16 @@ public CalendarConnectResponse saveConnection( @Transactional public void deleteConnection(CalendarConnection calendarConnection, LocalDate today) { calendarTodoSynchronizer.handleDisconnect(calendarConnection.getUser().getId(), today); + + String tokenToRevoke = calendarConnection.getRefreshToken() != null + ? calendarConnection.getRefreshToken() + : calendarConnection.getAccessToken(); + calendarRevocationOutboxRepository.save( + CalendarRevocationOutbox.builder() + .token(tokenToRevoke) + .build() + ); + calendarConnectionRepository.delete(calendarConnection); } diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java index b257e0a7..a93c6ea0 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java @@ -7,10 +7,8 @@ import com.Timo.Timo.domain.calendar.dto.client.GoogleTokenResponse; import com.Timo.Timo.domain.calendar.dto.client.GoogleUserInfoResponse; import com.Timo.Timo.domain.calendar.entity.CalendarConnection; -import com.Timo.Timo.domain.calendar.entity.CalendarRevocationOutbox; import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; -import com.Timo.Timo.domain.calendar.repository.CalendarRevocationOutboxRepository; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.domain.user.exception.UserErrorCode; import com.Timo.Timo.domain.user.repository.UserRepository; @@ -46,7 +44,6 @@ public class CalendarService { private final CalendarConnectionCommandService commandService; private final StringRedisTemplate redisTemplate; private final CalendarStateValidator calendarStateValidator; - private final CalendarRevocationOutboxRepository calendarRevocationOutboxRepository; @Value("${spring.security.oauth2.client.registration.google.client-id}") private String clientId; @@ -115,16 +112,6 @@ public CalendarDisconnectResponse disconnect(Long userId) { CalendarConnection calendarConnection = calendarConnectionRepository.findByUserId(userId) .orElseThrow(() -> new CustomException(CalendarErrorCode.CALENDAR_NOT_CONNECTED)); - String tokenToRevoke = calendarConnection.getRefreshToken() != null - ? calendarConnection.getRefreshToken() - : calendarConnection.getAccessToken(); - - calendarRevocationOutboxRepository.save( - CalendarRevocationOutbox.builder() - .token(tokenToRevoke) - .build() - ); - ZoneId userZone = ZoneId.of(calendarConnection.getUser().getZoneId()); commandService.deleteConnection(calendarConnection, LocalDate.now(userZone)); From 6027d4c211351bd55231b1acb26617f57991b8fe Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 16:41:14 +0900 Subject: [PATCH 13/23] =?UTF-8?q?refactor(todo):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=88=98=EC=A0=95=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=BC=EC=A0=95=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/Timo/Timo/domain/todo/service/TodoService.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 9685bb27..67388cee 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 @@ -282,11 +282,7 @@ private void validateGoogleEventUpdatable(Todo todo, TodoUpdateRequest request) } boolean changesTitle = request.title() != null && !request.title().equals(todo.getTitle()); - boolean changesSchedule = (request.date() != null && !request.date().equals(todo.getStartDate())) - || (request.repeatType() != null && request.repeatType() != todo.getRepeatType()) - || (request.repeatWeekdays() != null && !request.repeatWeekdays().equals(todo.getRepeatWeekdays())) - || (request.repeatDayOfMonth() != null - && !request.repeatDayOfMonth().equals(todo.getRepeatDayOfMonth())); + boolean changesSchedule = isScheduleChanged(todo, request); if (changesTitle || changesSchedule) { throw new CustomException(TodoErrorCode.GOOGLE_EVENT_NOT_MODIFIABLE); From 3b027d2464261f2ba726a15964317aaf182ed784 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 17:34:20 +0900 Subject: [PATCH 14/23] =?UTF-8?q?feat(todo):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=ED=88=AC=EB=91=90=20=EB=82=A0=EC=A7=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/Timo/Timo/domain/todo/entity/Todo.java | 8 ++++++++ .../com/Timo/Timo/domain/todo/entity/TodoInstance.java | 4 ++++ 2 files changed, 12 insertions(+) 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 ee90747a..32afad6e 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 @@ -319,6 +319,14 @@ public void unmarkDisconnectedFromGoogle() { this.disconnectedFromGoogle = false; } + public void moveGoogleEvent(LocalDate date, String externalEventId) { + this.startDate = date; + this.endDate = date; + this.externalEventId = externalEventId; + this.deletedFromGoogle = false; + this.disconnectedFromGoogle = false; + } + private void addSubtask(Subtask subtask) { this.subtasks.add(subtask); subtask.assignTodo(this); 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 0d8d44a2..441359d0 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 @@ -85,6 +85,10 @@ public void markIncomplete() { this.completed = false; } + public void moveTo(LocalDate date) { + this.date = date; + } + public void updateSortOrder(int sortOrder) { this.sortOrder = sortOrder; } From 9f1459c4469c42055e9d64cb2e514080fa546e13 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 19:36:22 +0900 Subject: [PATCH 15/23] =?UTF-8?q?feat(calendar):=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=A7=8C=20=EB=B0=94=EB=80=90=20=EA=B5=AC=EA=B8=80=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=EC=9D=80=20=EB=82=A0=EC=A7=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarTodoSynchronizer.java | 136 +++++++++++++++--- 1 file changed, 117 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index b2257687..e7b8f014 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -40,6 +40,7 @@ public class CalendarTodoSynchronizer { private static final int MAX_TITLE_LENGTH = 30; private static final int MAX_EXTERNAL_EVENT_ID_LENGTH = 255; + private static final int DATE_SUFFIX_LENGTH = 11; private static final String HASHED_ID_PREFIX = "sha256:"; private static final String EMPTY_TITLE = "제목 없음"; @@ -85,23 +86,124 @@ private void registerEvents( User user, List items, LocalDate from, LocalDate to, ZoneId userZone ) { List occurrences = toOccurrences(items, from, to, userZone); + Map existingByExternalEventId = findExisting(user.getId(), occurrences); - saveOrUpdate(user, occurrences); - removeMissing(user, occurrences, from, to); + List newOccurrences = occurrences.stream() + .filter(occurrence -> !existingByExternalEventId.containsKey(occurrence.externalEventId())) + .toList(); + List missingTodos = findMissing(user.getId(), occurrences, from, to); + + Set movedTodoIds = new HashSet<>(); + Set movedExternalEventIds = new HashSet<>(); + for (EventMove move : matchMoves(missingTodos, newOccurrences)) { + movedTodoIds.add(move.todo().getId()); + movedExternalEventIds.add(move.occurrence().externalEventId()); + if (!timerService.hasActiveTimer(move.todo().getId())) { + applyMove(move); + } + } + + List remainingOccurrences = occurrences.stream() + .filter(occurrence -> !movedExternalEventIds.contains(occurrence.externalEventId())) + .toList(); + List remainingMissingTodos = missingTodos.stream() + .filter(todo -> !movedTodoIds.contains(todo.getId())) + .toList(); + + saveOrUpdate(user, remainingOccurrences, existingByExternalEventId); + removeMissing(remainingMissingTodos); } - private void saveOrUpdate(User user, List occurrences) { + private Map findExisting(Long userId, List occurrences) { if (occurrences.isEmpty()) { - return; + return Map.of(); } List externalEventIds = occurrences.stream() .map(EventOccurrence::externalEventId) .toList(); - Map existingByExternalEventId = todoRepository - .findByUser_IdAndExternalEventIdIn(user.getId(), externalEventIds) + return todoRepository.findByUser_IdAndExternalEventIdIn(userId, externalEventIds) .stream() .collect(Collectors.toMap(Todo::getExternalEventId, Function.identity())); + } + + private List findMissing( + Long userId, List occurrences, LocalDate from, LocalDate to + ) { + Set currentExternalEventIds = occurrences.stream() + .map(EventOccurrence::externalEventId) + .collect(Collectors.toSet()); + + return todoRepository.findGoogleEventsInRange(userId, from, to).stream() + .filter(todo -> !currentExternalEventIds.contains(todo.getExternalEventId())) + .sorted(Comparator.comparing(Todo::getStartDate)) + .toList(); + } + + private List matchMoves(List missingTodos, List newOccurrences) { + Map> missingByGoogleEventId = new LinkedHashMap<>(); + for (Todo todo : missingTodos) { + String googleEventId = toGoogleEventId(todo.getExternalEventId()); + if (googleEventId != null) { + missingByGoogleEventId.computeIfAbsent(googleEventId, key -> new ArrayList<>()).add(todo); + } + } + + Map> newByGoogleEventId = new LinkedHashMap<>(); + for (EventOccurrence occurrence : newOccurrences) { + newByGoogleEventId + .computeIfAbsent(occurrence.googleEventId(), key -> new ArrayList<>()) + .add(occurrence); + } + + List moves = new ArrayList<>(); + missingByGoogleEventId.forEach((googleEventId, todos) -> { + List occurrences = newByGoogleEventId.get(googleEventId); + if (occurrences == null) { + return; + } + int pairCount = Math.min(todos.size(), occurrences.size()); + for (int i = 0; i < pairCount; i++) { + moves.add(new EventMove(todos.get(i), occurrences.get(i))); + } + }); + return moves; + } + + private void applyMove(EventMove move) { + Todo todo = move.todo(); + EventOccurrence occurrence = move.occurrence(); + LocalDate previousDate = todo.getStartDate(); + + todo.moveGoogleEvent(occurrence.date(), occurrence.externalEventId()); + if (!todo.getTitle().equals(occurrence.title())) { + todo.updateFields(null, occurrence.title(), null, null, null); + } + + if (todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), occurrence.date()).isEmpty()) { + todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), previousDate) + .ifPresent(instance -> instance.moveTo(occurrence.date())); + } + } + + private String toGoogleEventId(String externalEventId) { + if (externalEventId.startsWith(HASHED_ID_PREFIX)) { + return null; + } + + int separatorIndex = externalEventId.length() - DATE_SUFFIX_LENGTH; + if (separatorIndex <= 0 || externalEventId.charAt(separatorIndex) != '_') { + return null; + } + return externalEventId.substring(0, separatorIndex); + } + + private void saveOrUpdate( + User user, List occurrences, Map existingByExternalEventId + ) { + if (occurrences.isEmpty()) { + return; + } List newestFirst = new ArrayList<>(occurrences); Collections.reverse(newestFirst); @@ -126,17 +228,7 @@ private void saveOrUpdate(User user, List occurrences) { } } - private void removeMissing( - User user, List occurrences, LocalDate from, LocalDate to - ) { - Set currentExternalEventIds = occurrences.stream() - .map(EventOccurrence::externalEventId) - .collect(Collectors.toSet()); - - List missingTodos = todoRepository.findGoogleEventsInRange(user.getId(), from, to) - .stream() - .filter(todo -> !currentExternalEventIds.contains(todo.getExternalEventId())) - .toList(); + private void removeMissing(List missingTodos) { if (missingTodos.isEmpty()) { return; } @@ -186,7 +278,7 @@ private List toOccurrences( } String externalEventId = toExternalEventId(item.id(), date); occurrencesByExternalEventId.putIfAbsent( - externalEventId, new EventOccurrence(externalEventId, title, date) + externalEventId, new EventOccurrence(item.id(), externalEventId, title, date) ); } } @@ -226,7 +318,13 @@ private String toTitle(String summary) { return title.substring(0, title.offsetByCodePoints(0, MAX_TITLE_LENGTH)); } - private record EventOccurrence(String externalEventId, String title, LocalDate date) { + private record EventOccurrence( + String googleEventId, String externalEventId, String title, LocalDate date + ) { + + } + + private record EventMove(Todo todo, EventOccurrence occurrence) { } } From 1e193bf2ed926ab57c1b8681b01713366768be3e Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 19:41:06 +0900 Subject: [PATCH 16/23] =?UTF-8?q?docs(calendar):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EB=82=A0=EC=A7=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=A9=EC=8B=9D=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/domain/calendar/docs/CalendarControllerDocs.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java index 2a4b03fd..53297e88 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java @@ -147,7 +147,10 @@ ResponseEntity> getCalendarEvents( filter, baseDate 미입력 시 각각 DEFAULT, 오늘 날짜가 기본값으로 사용됩니다. 홈 조회(/api/v1/home, /api/v1/home/today)를 호출하기 전에 이 API를 먼저 호출해야 구글 일정이 함께 조회됩니다. - 구글에서 새로 생긴 일정은 투두로 저장되고, 제목/날짜가 바뀐 일정은 갱신됩니다. + 구글에서 새로 생긴 일정은 투두로 저장되고, 제목이 바뀐 일정은 제목이 갱신됩니다. + 날짜가 바뀐 일정은 기존 투두가 새 날짜로 이동하며, 티모에서 설정한 소요 시간·태그·아이콘·메모·하위 태스크·완료 상태와 타이머 기록은 그대로 유지됩니다. + 해당 투두의 타이머가 실행 중이거나 일시정지 상태이면 이동하지 않고, 타이머를 종료한 뒤 다음 동기화 때 이동합니다. + 구글에서 삭제된 일정은 타이머 기록이 없으면 삭제되고, 기록이 있으면 삭제되지 않고 구글에서 삭제된 일정으로 표시됩니다. 소요 시간은 0으로 저장되며, 제목이 30자를 넘으면 30자까지만 저장됩니다. (1차 스프린트 임시 해결책) """, From 2c4208c85c8acea6a90cf80442450a9ccd78764f Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 20:16:32 +0900 Subject: [PATCH 17/23] =?UTF-8?q?fix(calendar):=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=EC=8B=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EC=9D=B4=20=EC=9E=88=EB=8A=94=20=EA=B5=AC?= =?UTF-8?q?=EA=B8=80=20=ED=88=AC=EB=91=90=EB=8A=94=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/docs/CalendarControllerDocs.java | 5 +++-- .../service/CalendarTodoSynchronizer.java | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java index 53297e88..f7b415ef 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java @@ -181,8 +181,9 @@ ResponseEntity> syncCalendarEvents( description = """ 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. - 해제 시점 기준 오늘 이후의 구글 일정 투두는 삭제됩니다. - 오늘과 지난 구글 일정 투두는 유지되며 isDisconnectedFromGoogle=true로 표시되어 삭제할 수 있게 됩니다. + 해제 시점 기준 오늘 이후의 구글 일정 투두 중 타이머 기록이 없는 투두는 삭제됩니다. + 타이머 기록이 있거나 타이머가 실행 중인 투두는 삭제되지 않고 남으며, 오늘과 지난 구글 일정 투두도 유지됩니다. + 남은 구글 일정 투두는 isDisconnectedFromGoogle=true로 표시되어 삭제할 수 있게 됩니다. 다시 연동하여 동기화하면 구글에 남아 있는 일정은 다시 isDisconnectedFromGoogle=false로 표시됩니다. """, security = @SecurityRequirement(name = "bearerAuth") diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index e7b8f014..5b2cf0ee 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -57,11 +57,23 @@ public void handleDisconnect(Long userId, LocalDate today) { userRepository.findByIdForUpdate(userId) .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - List todoIds = todoRepository.findGoogleEventsAfter(userId, today).stream() - .map(Todo::getId) - .toList(); - if (!todoIds.isEmpty()) { - deleteTodos(todoIds); + List futureTodos = todoRepository.findGoogleEventsAfter(userId, today); + if (!futureTodos.isEmpty()) { + Set recordedTodoIds = new HashSet<>(timerRecordRepository.findTodoIdsHavingRecords( + futureTodos.stream().map(Todo::getId).toList() + )); + + List deletableTodoIds = new ArrayList<>(); + for (Todo todo : futureTodos) { + if (recordedTodoIds.contains(todo.getId())) { + todo.markDisconnectedFromGoogle(); + continue; + } + deletableTodoIds.add(todo.getId()); + } + if (!deletableTodoIds.isEmpty()) { + deleteTodos(deletableTodoIds); + } } todoRepository.findGoogleEventsUpTo(userId, today) From 3f7e0bf9c56eb1103ab00a2655420450de9493ef Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 20:23:14 +0900 Subject: [PATCH 18/23] =?UTF-8?q?fix(calendar):=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=A1=9C=20=EC=A0=9C=EB=AA=A9=EC=9D=B4=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EB=A9=B4=20AI=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EB=B2=84=EC=A0=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarTodoSynchronizer.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index 5b2cf0ee..493514a0 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -1,5 +1,6 @@ package com.Timo.Timo.domain.calendar.service; +import com.Timo.Timo.domain.ai.service.AiHistoryCacheService; import com.Timo.Timo.domain.calendar.dto.client.CalendarEventItem; import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; @@ -51,6 +52,7 @@ public class CalendarTodoSynchronizer { private final UserRepository userRepository; private final TimerService timerService; private final CalendarConnectionRepository calendarConnectionRepository; + private final AiHistoryCacheService aiHistoryCacheService; @Transactional public void handleDisconnect(Long userId, LocalDate today) { @@ -105,13 +107,14 @@ private void registerEvents( .toList(); List missingTodos = findMissing(user.getId(), occurrences, from, to); + boolean titleChanged = false; Set movedTodoIds = new HashSet<>(); Set movedExternalEventIds = new HashSet<>(); for (EventMove move : matchMoves(missingTodos, newOccurrences)) { movedTodoIds.add(move.todo().getId()); movedExternalEventIds.add(move.occurrence().externalEventId()); if (!timerService.hasActiveTimer(move.todo().getId())) { - applyMove(move); + titleChanged |= applyMove(move); } } @@ -122,8 +125,12 @@ private void registerEvents( .filter(todo -> !movedTodoIds.contains(todo.getId())) .toList(); - saveOrUpdate(user, remainingOccurrences, existingByExternalEventId); + titleChanged |= saveOrUpdate(user, remainingOccurrences, existingByExternalEventId); removeMissing(remainingMissingTodos); + + if (titleChanged) { + aiHistoryCacheService.bumpUserHistoryVersionAfterCommit(user.getId()); + } } private Map findExisting(Long userId, List occurrences) { @@ -182,13 +189,14 @@ private List matchMoves(List missingTodos, List instance.moveTo(occurrence.date())); } + return titleChanged; } private String toGoogleEventId(String externalEventId) { @@ -210,11 +219,12 @@ private String toGoogleEventId(String externalEventId) { return externalEventId.substring(0, separatorIndex); } - private void saveOrUpdate( + private boolean saveOrUpdate( User user, List occurrences, Map existingByExternalEventId ) { + boolean titleChanged = false; if (occurrences.isEmpty()) { - return; + return titleChanged; } List newestFirst = new ArrayList<>(occurrences); @@ -236,8 +246,10 @@ private void saveOrUpdate( } if (!existing.getTitle().equals(occurrence.title())) { existing.updateFields(null, occurrence.title(), null, null, null); + titleChanged = true; } } + return titleChanged; } private void removeMissing(List missingTodos) { From c1ea8dfa7143473e5955189ad576698c0a64bf98 Mon Sep 17 00:00:00 2001 From: jy000n Date: Mon, 12 Oct 2026 22:49:30 +0900 Subject: [PATCH 19/23] =?UTF-8?q?fix(calendar):=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EB=82=A0=EC=97=90=20=EA=B1=B8=EC=B9=9C=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=EC=9D=80=20=EB=82=A0=EC=A7=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=A7=A4=EC=B9=AD=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarTodoSynchronizer.java | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index 493514a0..728f84e4 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -110,7 +110,7 @@ private void registerEvents( boolean titleChanged = false; Set movedTodoIds = new HashSet<>(); Set movedExternalEventIds = new HashSet<>(); - for (EventMove move : matchMoves(missingTodos, newOccurrences)) { + for (EventMove move : matchMoves(missingTodos, occurrences, newOccurrences)) { movedTodoIds.add(move.todo().getId()); movedExternalEventIds.add(move.occurrence().externalEventId()); if (!timerService.hasActiveTimer(move.todo().getId())) { @@ -159,7 +159,12 @@ private List findMissing( .toList(); } - private List matchMoves(List missingTodos, List newOccurrences) { + private List matchMoves( + List missingTodos, List occurrences, List newOccurrences + ) { + Map occurrenceCountByGoogleEventId = occurrences.stream() + .collect(Collectors.groupingBy(EventOccurrence::googleEventId, Collectors.counting())); + Map> missingByGoogleEventId = new LinkedHashMap<>(); for (Todo todo : missingTodos) { String googleEventId = toGoogleEventId(todo.getExternalEventId()); @@ -177,13 +182,15 @@ private List matchMoves(List missingTodos, List moves = new ArrayList<>(); missingByGoogleEventId.forEach((googleEventId, todos) -> { - List occurrences = newByGoogleEventId.get(googleEventId); - if (occurrences == null) { + List candidates = newByGoogleEventId.get(googleEventId); + if (candidates == null) { return; } - int pairCount = Math.min(todos.size(), occurrences.size()); - for (int i = 0; i < pairCount; i++) { - moves.add(new EventMove(todos.get(i), occurrences.get(i))); + boolean singleDayEvent = todos.size() == 1 + && candidates.size() == 1 + && occurrenceCountByGoogleEventId.getOrDefault(googleEventId, 0L) == 1L; + if (singleDayEvent) { + moves.add(new EventMove(todos.get(0), candidates.get(0))); } }); return moves; From 8fd2222640e1629aa385a2f813899ca1ed819858 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 13 Oct 2026 21:20:51 +0900 Subject: [PATCH 20/23] =?UTF-8?q?fix(calendar):=20=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=8B=9C=20=EA=B5=AC=EA=B8=80=20=ED=88=AC=EB=91=90?= =?UTF-8?q?=EC=9D=98=20=EC=97=B0=EB=8F=99=20=ED=95=B4=EC=A0=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=A5=BC=20=EC=9D=BC=EA=B4=84=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CalendarConnectionCommandService.java | 4 ++++ .../Timo/domain/todo/repository/TodoRepository.java | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java index e75f0fb1..99e0b621 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java @@ -8,6 +8,7 @@ import com.Timo.Timo.domain.calendar.exception.CalendarErrorCode; import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; import com.Timo.Timo.domain.calendar.repository.CalendarRevocationOutboxRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; import com.Timo.Timo.domain.user.entity.User; import com.Timo.Timo.global.exception.CustomException; import java.time.LocalDate; @@ -28,6 +29,7 @@ public class CalendarConnectionCommandService { private final CalendarConnectionRepository calendarConnectionRepository; private final CalendarRevocationOutboxRepository calendarRevocationOutboxRepository; private final CalendarTodoSynchronizer calendarTodoSynchronizer; + private final TodoRepository todoRepository; @Transactional public CalendarConnectResponse saveConnection( @@ -49,6 +51,8 @@ public CalendarConnectResponse saveConnection( throw new CustomException(CalendarErrorCode.CALENDAR_ALREADY_CONNECTED); } + todoRepository.clearDisconnectedFromGoogleByUserId(user.getId(), LocalDateTime.now()); + return CalendarConnectResponse.builder() .calendarConnected(true) .calendarEmail(calendarConnection.getCalendarEmail()) diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 2dc8f927..2e9a8692 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -70,6 +70,19 @@ long countByUser_IdAndStartDateLessThanEqualAndEndDateGreaterThanEqual( Long userId, LocalDate to, LocalDate from ); + @Modifying(flushAutomatically = true) + @Query(""" + update Todo t + set t.disconnectedFromGoogle = false, t.updatedAt = :now + where t.user.id = :userId + and t.externalEventId is not null + and t.disconnectedFromGoogle = true + """) + int clearDisconnectedFromGoogleByUserId( + @Param("userId") Long userId, + @Param("now") LocalDateTime now + ); + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update Todo t set t.tagId = null, t.updatedAt = :now where t.tagId = :tagId") int clearTagByTagId(@Param("tagId") Long tagId, @Param("now") LocalDateTime now); From 77071846732ad26012846bbaa1aa1f0f31d519c8 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 13 Oct 2026 21:30:41 +0900 Subject: [PATCH 21/23] =?UTF-8?q?refactor(calendar):=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=ED=95=B4=EC=A0=9C=20=EC=8B=9C=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EA=B5=AC=EA=B8=80=20=ED=88=AC=EB=91=90=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B2=8C=ED=81=AC=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/service/CalendarTodoSynchronizer.java | 4 ++-- .../Timo/domain/todo/repository/TodoRepository.java | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index 728f84e4..0df12d2a 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -19,6 +19,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; @@ -78,8 +79,7 @@ public void handleDisconnect(Long userId, LocalDate today) { } } - todoRepository.findGoogleEventsUpTo(userId, today) - .forEach(Todo::markDisconnectedFromGoogle); + todoRepository.markDisconnectedFromGoogleUpTo(userId, today, LocalDateTime.now()); } @Transactional diff --git a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 2e9a8692..c673f21c 100644 --- a/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java +++ b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java @@ -42,15 +42,19 @@ List findGoogleEventsAfter( @Param("date") LocalDate date ); + @Modifying(flushAutomatically = true) @Query(""" - select t from Todo t + update Todo t + set t.disconnectedFromGoogle = true, t.updatedAt = :now where t.user.id = :userId and t.externalEventId is not null and t.startDate <= :date + and t.disconnectedFromGoogle = false """) - List findGoogleEventsUpTo( + int markDisconnectedFromGoogleUpTo( @Param("userId") Long userId, - @Param("date") LocalDate date + @Param("date") LocalDate date, + @Param("now") LocalDateTime now ); @Query(""" From eebd9d38049b3e7e1166fce7fdfe592b9228f439 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 13 Oct 2026 22:33:50 +0900 Subject: [PATCH 22/23] =?UTF-8?q?fix(calendar):=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EA=B0=80=20=EC=9D=B4=EB=8F=99=EB=90=9C=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=ED=88=AC=EB=91=90=EB=8A=94=20=EC=83=88=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EC=9D=98=20=EB=A7=A8=20=EC=9C=84=EB=A1=9C=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/service/CalendarTodoSynchronizer.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index 0df12d2a..233539cd 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -8,6 +8,7 @@ import com.Timo.Timo.domain.timer.repository.TimerRecordRepository; import com.Timo.Timo.domain.timer.service.TimerService; 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; @@ -209,11 +210,18 @@ private boolean applyMove(EventMove move) { if (todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), occurrence.date()).isEmpty()) { todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), previousDate) - .ifPresent(instance -> instance.moveTo(occurrence.date())); + .ifPresent(instance -> moveInstanceToTop(instance, todo.getUser().getId(), occurrence.date())); } return titleChanged; } + private void moveInstanceToTop(TodoInstance instance, Long userId, LocalDate date) { + todoInstanceRepository.findDailyInstances(userId, date) + .forEach(other -> other.updateSortOrder(other.getSortOrder() + 1)); + instance.moveTo(date); + instance.updateSortOrder(0); + } + private String toGoogleEventId(String externalEventId) { if (externalEventId.startsWith(HASHED_ID_PREFIX)) { return null; From e132b8a8a651672e09953b48ef28d457457612e1 Mon Sep 17 00:00:00 2001 From: jy000n Date: Wed, 14 Oct 2026 02:52:00 +0900 Subject: [PATCH 23/23] =?UTF-8?q?fix(calendar):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EB=82=A0=EC=A7=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=8B=9C=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=9D=98=20=EB=8C=80=EC=83=81=20=EB=82=A0=EC=A7=9C=EB=8F=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/service/CalendarTodoSynchronizer.java | 1 + .../timer/repository/TimerRecordRepository.java | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java index 233539cd..c3d3e6e9 100644 --- a/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -203,6 +203,7 @@ private boolean applyMove(EventMove move) { LocalDate previousDate = todo.getStartDate(); todo.moveGoogleEvent(occurrence.date(), occurrence.externalEventId()); + timerRecordRepository.moveTargetDate(todo.getId(), previousDate, occurrence.date()); boolean titleChanged = !todo.getTitle().equals(occurrence.title()); if (titleChanged) { todo.updateFields(null, occurrence.title(), null, null, null); 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 8889573a..e4f4b78f 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,19 @@ boolean existsByTodo_IdAndTargetDateAndStatusIn( @Query("delete from TimerRecord r where r.todo.id = :todoId") void deleteByTodoId(@Param("todoId") Long todoId); + @Modifying(flushAutomatically = true) + @Query(""" + update TimerRecord r + set r.targetDate = :toDate + where r.todo.id = :todoId + and r.targetDate = :fromDate + """) + int moveTargetDate( + @Param("todoId") Long todoId, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate + ); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select t from TimerRecord t where t.id = :id") Optional findByIdForUpdate(@Param("id") Long id);