Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d1516af
feat(todo): 구글 일정 구분 컬럼 추가
Jy000n Oct 9, 2026
48ec0f3
feat(todo): 구글 일정 투두 수정/삭제 차단
Jy000n Oct 10, 2026
ea7e903
feat(calendar): 구글 일정을 투두로 저장
Jy000n Oct 10, 2026
1e0a4e5
feat(calendar): 구글에서 삭제된 일정 반영
Jy000n Oct 10, 2026
6aced2a
feat(calendar): 구글 일정 동기화 API 추가
Jy000n Oct 10, 2026
3949d06
feat(calendar): 연동 해제 시 오늘 이후 구글 투두 삭제
Jy000n Oct 10, 2026
95cbddc
feat(todo): 조회 응답에 구글 일정 구분 필드 추가
Jy000n Oct 10, 2026
a09da6b
fix(calendar): 연동 해제된 사용자는 동기화 결과를 저장하지 않음
Jy000n Oct 11, 2026
711ee7b
fix(calendar): 긴 구글 일정 ID를 누락하지 않도록 키 생성 방식 변경
Jy000n Oct 11, 2026
2df5fc7
fix(todo): 일정이 실제로 바뀐 경우에만 용량 검사 실행
Jy000n Oct 11, 2026
02bd072
fix(calendar): 연동 해제 후 남은 구글 투두를 삭제할 수 있도록 상태 필드 추가
Jy000n Oct 12, 2026
8ea33fe
fix(calendar): 연동 해제 시 토큰 revoke 기록과 연결 삭제를 한 트랜잭션으로 처리
Jy000n Oct 12, 2026
6027d4c
refactor(todo): 구글 일정 수정 검증에서 일정 변경 판단 로직을 재사용
Jy000n Oct 12, 2026
3b027d2
feat(todo): 구글 투두 날짜 이동 메서드 추가
Jy000n Oct 12, 2026
9f1459c
feat(calendar): 날짜만 바뀐 구글 일정은 날짜 이동 방식으로 변경
Jy000n Oct 12, 2026
1e193bf
docs(calendar): 구글 일정 날짜 변경 처리 방식 문서 반영
Jy000n Oct 12, 2026
2c4208c
fix(calendar): 연동 해제 시 타이머 기록이 있는 구글 투두는 삭제하지 않고 남김
Jy000n Oct 12, 2026
3f7e0bf
fix(calendar): 동기화로 제목이 바뀌면 AI 히스토리 캐시 버전 갱신
Jy000n Oct 12, 2026
9baa0d8
chore: develop 브랜치와의 conflict 해결
Jy000n Oct 12, 2026
c1ea8df
fix(calendar): 여러 날에 걸친 구글 일정은 날짜 이동 매칭에서 제외
Jy000n Oct 12, 2026
8fd2222
fix(calendar): 재연동 시 구글 투두의 연동 해제 표시를 일괄 해제
Jy000n Oct 13, 2026
7707184
refactor(calendar): 연동 해제 시 지난 구글 투두 표시를 벌크 업데이트로 처리
Jy000n Oct 13, 2026
eebd9d3
fix(calendar): 날짜가 이동된 구글 투두는 새 날짜의 맨 위로 배치
Jy000n Oct 13, 2026
e132b8a
fix(calendar): 구글 일정 날짜 이동 시 타이머 기록의 대상 날짜도 함께 이동
Jy000n Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +37,7 @@ public class CalendarController implements CalendarControllerDocs {

private final CalendarService calendarService;
private final CalendarEventQueryService calendarEventQueryService;
private final CalendarEventSyncService calendarEventSyncService;

@Override
@GetMapping("/authorize")
Expand Down Expand Up @@ -90,4 +94,21 @@ public ResponseEntity<BaseResponse<CalendarEventsResponse>> getCalendarEvents(
BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_EVENTS_RETRIEVED, response)
);
}

@Override
@PostMapping("/events/sync")
public ResponseEntity<BaseResponse<Object>> 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())
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -134,10 +135,56 @@ ResponseEntity<BaseResponse<CalendarEventsResponse>> 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<BaseResponse<Object>> syncCalendarEvents(
@Parameter(hidden = true) CustomUserDetails userDetails,
CalendarSyncRequest request
);

@Operation(
summary = "구글 캘린더 연동 해제",
description = """
연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다.

해제 시점 기준 오늘 이후의 구글 일정 투두 중 타이머 기록이 없는 투두는 삭제됩니다.
타이머 기록이 있거나 타이머가 실행 중인 투두는 삭제되지 않고 남으며, 오늘과 지난 구글 일정 투두도 유지됩니다.
남은 구글 일정 투두는 isDisconnectedFromGoogle=true로 표시되어 삭제할 수 있게 됩니다.
다시 연동하여 동기화하면 구글에 남아 있는 일정은 다시 isDisconnectedFromGoogle=false로 표시됩니다.
""",
security = @SecurityRequirement(name = "bearerAuth")
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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())
Expand All @@ -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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

이제 투두 정리, revoke 기록, 연결 삭제가 같이 커밋되고 같이 롤백되네용 굿입니당!

CalendarRevocationOutbox.builder()
.token(tokenToRevoke)
.build()
);

calendarConnectionRepository.delete(calendarConnection);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<CalendarEventItem> items = googleOAuthClient.fetchEvents(accessToken, timeMin, timeMax);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

구글 API 호출은 트랜잭션 밖에서 하고 저장만 synchronize 트랜잭션에서 처리하면 외부 호출 동안 DB 커넥션이랑 락을 안 잡고 있는 구조가 되네용 굿입니당


calendarTodoSynchronizer.synchronize(userId, items, from, to, userZone);
}

private HomeFilter parseFilter(String filterValue) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p4) HomeFilter.from과 같은 로직이 한 번 더 구현되어 있는 것 같습니다.

차이는 에러 코드뿐인 것 같아보이네요. parseBaseDate와 구글 호출 범위를 계산하는 부분도 CalendarEventQueryService와 같아서, 기존 GET /events를 지울 때 함께 정리하면 좋을 것 같습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

맞습니다. 말씀 주신대로 이번 PR에서는 그대로 두고, 기존 조회 API GET /events를 제거할 때 parseBaseDate 범위 계산과 함께 정리하겠습니다!

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p3) disconnect가 트랜잭션 밖이라 revoke outbox 저장이 먼저 커밋되고 그 다음 deleteConnection이 따로 실행되는 구조인 것 같아요. 원래는 연결 삭제만 해서 실패할 일이 거의 없었는데 이번에 사용자 락 + 투두 삭제가 같이 들어가면서 여기서 실패할 여지가 좀 생긴 것 같습니당

그러면 토큰은 revoke되는데 연결은 남아서 사용자는 연동된 상태로 보이는데 동기화는 401이 나는 상황이 될 수 있을 것 같아요. 기존 순서라 이번 PR 범위 밖일 수도 있는데 한 번 봐주시면 좋을 것 같아용 (제가 틀릴수도... ㅎㅎ)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

아주 날카로운 지적이셨습니다ㅎㅎ
disconnect에서 revoke outbox 저장이 먼저 커밋되고 deleteConnection이 별도 트랜잭션으로 실행되고 있어서, 락/투두 삭제가 들어가면서 연결 삭제가 실패하면 토큰만 revoke 되고 연결은 남을 수 있었습니다.

outbox 저장을 CalendarConnectionCommanService.deleteConnection 의 트랜잭션 안으로 옮겨서, 투두 정리 + outbox 저장 + 연결 삭제가 한 번에 커밋/롤백되도록 수정하였습니다.


return CalendarDisconnectResponse.builder()
.calendarConnected(false)
Expand Down
Loading
Loading