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..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 @@ -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,10 +135,56 @@ 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 = """ 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. + + 해제 시점 기준 오늘 이후의 구글 일정 투두 중 타이머 기록이 없는 투두는 삭제됩니다. + 타이머 기록이 있거나 타이머가 실행 중인 투두는 삭제되지 않고 남으며, 오늘과 지난 구글 일정 투두도 유지됩니다. + 남은 구글 일정 투두는 isDisconnectedFromGoogle=true로 표시되어 삭제할 수 있게 됩니다. + 다시 연동하여 동기화하면 구글에 남아 있는 일정은 다시 isDisconnectedFromGoogle=false로 표시됩니다. """, security = @SecurityRequirement(name = "bearerAuth") ) 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/CalendarConnectionCommandService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java index b561963e..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 @@ -4,10 +4,14 @@ 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.todo.repository.TodoRepository; 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 +27,9 @@ public class CalendarConnectionCommandService { private final CalendarConnectionRepository calendarConnectionRepository; + private final CalendarRevocationOutboxRepository calendarRevocationOutboxRepository; + private final CalendarTodoSynchronizer calendarTodoSynchronizer; + private final TodoRepository todoRepository; @Transactional public CalendarConnectResponse saveConnection( @@ -44,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()) @@ -52,7 +61,18 @@ public CalendarConnectResponse saveConnection( } @Transactional - public void deleteConnection(CalendarConnection calendarConnection) { + 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/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/CalendarService.java b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java index 110dafc9..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; @@ -19,6 +17,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; @@ -44,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; @@ -113,17 +112,8 @@ 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() - ); - - 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 new file mode 100644 index 00000000..c3d3e6e9 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/calendar/service/CalendarTodoSynchronizer.java @@ -0,0 +1,370 @@ +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; +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.entity.TodoInstance; +import com.Timo.Timo.domain.todo.repository.SubtaskCompletionRepository; +import com.Timo.Timo.domain.todo.repository.TodoInstanceRepository; +import com.Timo.Timo.domain.todo.repository.TodoRepository; +import com.Timo.Timo.domain.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.nio.charset.StandardCharsets; +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; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +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; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@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 int DATE_SUFFIX_LENGTH = 11; + private static final String HASHED_ID_PREFIX = "sha256:"; + private static final String EMPTY_TITLE = "제목 없음"; + + private final TodoRepository todoRepository; + private final TodoInstanceRepository todoInstanceRepository; + private final SubtaskCompletionRepository subtaskCompletionRepository; + private final TimerRecordRepository timerRecordRepository; + private final UserRepository userRepository; + private final TimerService timerService; + private final CalendarConnectionRepository calendarConnectionRepository; + private final AiHistoryCacheService aiHistoryCacheService; + + @Transactional + public void handleDisconnect(Long userId, LocalDate today) { + userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); + + 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.markDisconnectedFromGoogleUpTo(userId, today, LocalDateTime.now()); + } + + @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)); + + if (!calendarConnectionRepository.existsByUserId(userId)) { + throw new CustomException(CalendarErrorCode.CALENDAR_NOT_CONNECTED); + } + + 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); + Map existingByExternalEventId = findExisting(user.getId(), occurrences); + + List newOccurrences = occurrences.stream() + .filter(occurrence -> !existingByExternalEventId.containsKey(occurrence.externalEventId())) + .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, occurrences, newOccurrences)) { + movedTodoIds.add(move.todo().getId()); + movedExternalEventIds.add(move.occurrence().externalEventId()); + if (!timerService.hasActiveTimer(move.todo().getId())) { + titleChanged |= applyMove(move); + } + } + + List remainingOccurrences = occurrences.stream() + .filter(occurrence -> !movedExternalEventIds.contains(occurrence.externalEventId())) + .toList(); + List remainingMissingTodos = missingTodos.stream() + .filter(todo -> !movedTodoIds.contains(todo.getId())) + .toList(); + + titleChanged |= saveOrUpdate(user, remainingOccurrences, existingByExternalEventId); + removeMissing(remainingMissingTodos); + + if (titleChanged) { + aiHistoryCacheService.bumpUserHistoryVersionAfterCommit(user.getId()); + } + } + + private Map findExisting(Long userId, List occurrences) { + if (occurrences.isEmpty()) { + return Map.of(); + } + + List externalEventIds = occurrences.stream() + .map(EventOccurrence::externalEventId) + .toList(); + 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 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()); + 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 candidates = newByGoogleEventId.get(googleEventId); + if (candidates == null) { + return; + } + 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; + } + + private boolean applyMove(EventMove move) { + Todo todo = move.todo(); + EventOccurrence occurrence = move.occurrence(); + 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); + } + + if (todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), occurrence.date()).isEmpty()) { + todoInstanceRepository.findByTodo_IdAndDate(todo.getId(), previousDate) + .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; + } + + int separatorIndex = externalEventId.length() - DATE_SUFFIX_LENGTH; + if (separatorIndex <= 0 || externalEventId.charAt(separatorIndex) != '_') { + return null; + } + return externalEventId.substring(0, separatorIndex); + } + + private boolean saveOrUpdate( + User user, List occurrences, Map existingByExternalEventId + ) { + boolean titleChanged = false; + if (occurrences.isEmpty()) { + return titleChanged; + } + + 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.isDeletedFromGoogle()) { + existing.unmarkDeletedFromGoogle(); + } + if (existing.isDisconnectedFromGoogle()) { + existing.unmarkDisconnectedFromGoogle(); + } + if (!existing.getTitle().equals(occurrence.title())) { + existing.updateFields(null, occurrence.title(), null, null, null); + titleChanged = true; + } + } + return titleChanged; + } + + private void removeMissing(List missingTodos) { + 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; + } + + deleteTodos(deletableTodoIds); + } + + private void deleteTodos(List todoIds) { + todoRepository.flush(); + for (Long todoId : todoIds) { + timerService.deleteTimersByTodo(todoId); + subtaskCompletionRepository.deleteByTodoId(todoId); + todoInstanceRepository.deleteByTodoId(todoId); + } + todoRepository.deleteAllById(todoIds); + } + + 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 = toExternalEventId(item.id(), date); + occurrencesByExternalEventId.putIfAbsent( + externalEventId, new EventOccurrence(item.id(), externalEventId, title, date) + ); + } + } + + return occurrencesByExternalEventId.values().stream() + .sorted(Comparator.comparing(EventOccurrence::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; + } + + 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 googleEventId, String externalEventId, String title, LocalDate date + ) { + + } + + private record EventMove(Todo todo, EventOccurrence occurrence) { + + } +} 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..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 @@ -78,6 +78,15 @@ 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, + @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 32a95529..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 @@ -59,6 +59,15 @@ 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, + @JsonProperty("isDisconnectedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDisconnectedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMemo, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) @@ -78,6 +87,9 @@ public static TodayTodoResponse from(LocalDate date, TodoResponse todo) { todo.priority(), todo.tag(), 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 a647a0d1..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 @@ -32,6 +32,9 @@ public TodoResponse toResponse(TodoContext context) { mapTag(context.tag()), hasMemo(todo, instance), 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/timer/repository/TimerRecordRepository.java b/src/main/java/com/Timo/Timo/domain/timer/repository/TimerRecordRepository.java index dcb13d4f..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 @@ -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); @@ -26,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); 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..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 @@ -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,10 @@ ResponseEntity> updateTodo( TODO와 연결된 하위 태스크, 반복 규칙을 함께 삭제합니다. 해당 TODO에 실행 중이거나 일시정지된 타이머가 있으면 삭제할 수 없습니다. + 구글 캘린더에서 가져온 일정은 삭제할 수 없습니다. + 단, 구글에서 삭제되었지만 타이머 기록이 있어 남아 있는 일정(isDeletedFromGoogle=true)과 + 캘린더 연동 해제 후 남은 일정(isDisconnectedFromGoogle=true)은 삭제할 수 있습니다. + Swagger UI 오른쪽 위의 Authorize 버튼을 눌러 유효한 Access Token을 입력해야 합니다. """ ) @@ -387,7 +394,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/dto/response/TodoDetailResponse.java b/src/main/java/com/Timo/Timo/domain/todo/dto/response/TodoDetailResponse.java index eb7a35fa..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 @@ -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,15 @@ 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, + @JsonProperty("isDisconnectedFromGoogle") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + boolean isDisconnectedFromGoogle, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) TodoTimerStatus timerStatus, String memo, @@ -53,6 +64,9 @@ 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(), + 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 ba7a67fe..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 @@ -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,17 @@ 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; + + @ColumnDefault("false") + @Column(name = "disconnected_from_google", nullable = false) + private boolean disconnectedFromGoogle; + @Builder(access = AccessLevel.PRIVATE) private Todo( User user, @@ -117,7 +134,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; @@ -132,6 +150,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( @@ -263,6 +299,34 @@ public List getRepeatWeekdays() { return Collections.unmodifiableList(repeatWeekdays); } + public boolean isGoogleEvent() { + return externalEventId != null; + } + + public void markDeletedFromGoogle() { + this.deletedFromGoogle = true; + } + + public void unmarkDeletedFromGoogle() { + this.deletedFromGoogle = false; + } + + public void markDisconnectedFromGoogle() { + this.disconnectedFromGoogle = true; + } + + 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; } 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/repository/TodoRepository.java b/src/main/java/com/Timo/Timo/domain/todo/repository/TodoRepository.java index 24516ea3..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 @@ -2,6 +2,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -16,6 +17,46 @@ 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 + 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 + and t.externalEventId is not null + and t.startDate > :date + """) + List findGoogleEventsAfter( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); + + @Modifying(flushAutomatically = true) + @Query(""" + 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 + """) + int markDisconnectedFromGoogleUpTo( + @Param("userId") Long userId, + @Param("date") LocalDate date, + @Param("now") LocalDateTime now + ); + @Query(""" select t from Todo t where t.user.id = :userId @@ -33,6 +74,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); 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 7857d34a..fbb331e0 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 @@ -213,7 +213,9 @@ public void updateTodo(Long userId, Long todoId, TodoUpdateRequest request) { Todo todo = todoRepository.findByIdAndUser_Id(todoId, userId) .orElseThrow(() -> new CustomException(TodoErrorCode.TODO_NOT_FOUND)); - boolean changesTimerSensitiveFields = request.durationSeconds() != null || isScheduleChanged(request); + validateGoogleEventUpdatable(todo, request); + + boolean changesTimerSensitiveFields = request.durationSeconds() != null || isScheduleChanged(todo, request); if (changesTimerSensitiveFields && timerService.hasActiveTimer(todoId)) { throw new CustomException(TodoErrorCode.TIMER_RUNNING); } @@ -258,6 +260,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() && !todo.isDisconnectedFromGoogle()) { + throw new CustomException(TodoErrorCode.GOOGLE_EVENT_NOT_DELETABLE); + } + if (timerService.hasActiveTimer(todoId)) { throw new CustomException(TodoErrorCode.TIMER_RUNNING); } @@ -271,6 +277,19 @@ 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 = isScheduleChanged(todo, request); + + 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())); @@ -281,15 +300,17 @@ public void removeTagFromTodos(Long tagId) { todoRepository.clearTagByTagId(tagId, LocalDateTime.now()); } - 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; }