Conversation
…ript-MOA-756 [feat] 프로퍼티 온보딩 스크립트 추가
…elop-page-MOA-757 [feature] 홍보게시판 관리 개발자 페이지 기능 추가
Notion 연결 키를 clubId 기준으로 전환하고, 클럽 상세 및 공개 calendar-events API에서 저장된 Notion 데이터베이스 일정을 조회하도록 확장합니다. Made-with: Cursor
saveNotionConnection과 saveDatabaseId에서 findById → save 패턴을 MongoTemplate의 upsert/$set 및 updateFirst/$set으로 교체하여 동시 요청 시 필드 덮어쓰기 문제를 제거 Made-with: Cursor
AES-GCM에서 고정 IV를 반복 사용 시 기밀성/무결성이 손상되는 문제 수정. encrypt는 SecureRandom으로 랜덤 IV를 생성해 v2: 포맷으로 저장하고, decrypt는 v2: 접두사 유무로 레거시 경로 폴백을 지원해 기존 데이터 호환 유지. 지원서 개인정보(ClubApply) 및 Notion 토큰 암호화 경로 모두 해당. Made-with: Cursor
Notion API 호출 성공 이후에 saveDatabaseId를 수행하도록 순서 변경. 잘못된 databaseId가 저장되어 이후 캘린더 조회가 오염되는 문제 방지. Made-with: Cursor
요청 횟수 50회 초과로 루프 강제 종료 시 has_more=false로 덮어쓰던 문제 수정. 실제 has_more/next_cursor를 유지하고 partial=true 플래그를 추가해 클라이언트가 데이터가 절사되었음을 인지할 수 있도록 개선. getDatabases, getDatabasePages 두 메서드 모두 적용. Made-with: Cursor
…vent-api-MOA-779 [feature] Google 캘린더 이벤트 조회 API 추가
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 22 minutes and 54 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (11)
Warning
|
| Cohort / File(s) | Summary |
|---|---|
Infisical 및 빌드 설정 backend/.gitignore, backend/.infisical.json, backend/build.gradle, backend/gradle/infisical.gradle |
Infisical 환경 기반 설정 관리 통합: 개발/스테이징/프로덕션 태스크 추가, Firebase 설정 및 application.yml 동적 생성. Gradle 태스크 등록 방식 현대화, Firebase Admin 의존성 업그레이드(9.7.0 → 9.8.0). |
문서 및 개발 가이드 backend/CLAUDE.md, backend/docs/frontend-google-calendar-integration.md, backend/docs/notion-integration-decisions-2026-03-22.md, backend/docs/superpowers/specs/2026-03-30-google-calendar-integration-design.md, docs/draft/promotion-admin-create-plan-2026-03-29.md |
개발자 저장소 가이드, Google/Notion 캘린더 통합 문서, 의사결정 기록, 프로모션 관리 구현 계획 추가. |
Google Calendar 통합 backend/src/main/java/moadong/calendar/google/config/GoogleCalendarProperties.java, backend/src/main/java/moadong/calendar/google/controller/GoogleOAuthController.java, backend/src/main/java/moadong/calendar/google/entity/GoogleConnection.java, backend/src/main/java/moadong/calendar/google/payload/.../*.java, backend/src/main/java/moadong/calendar/google/repository/GoogleConnectionRepository.java, backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java |
Google OAuth 인증 및 캘린더 API 통합: 설정 속성, REST 컨트롤러(인증 URL 생성, 토큰 교환, 캘린더 목록/선택/조회), MongoDB 엔티티, 토큰 관리 및 갱신 로직, 암호화된 토큰 저장. |
Notion OAuth 통합 backend/src/main/java/moadong/calendar/notion/config/NotionProperties.java, backend/src/main/java/moadong/calendar/notion/controller/NotionOAuthController.java, backend/src/main/java/moadong/calendar/notion/entity/NotionConnection.java, backend/src/main/java/moadong/calendar/notion/payload/.../*.java, backend/src/main/java/moadong/calendar/notion/repository/NotionConnectionRepository.java, backend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.java |
Notion OAuth 인증 및 데이터 조회: 설정 속성, REST 컨트롤러, MongoDB 엔티티, 페이지/데이터베이스 조회 로직, 암호화된 토큰 저장. |
캘린더 집계 서비스 backend/src/main/java/moadong/calendar/service/CalendarAggregationService.java |
Notion 및 Google 캘린더 이벤트 단일 끝점 제공: 소스별 독립적 실패 격리, 정렬 및 병합. |
클럽 프로필 통합 backend/src/main/java/moadong/club/controller/ClubProfileController.java, backend/src/main/java/moadong/club/payload/dto/ClubCalendarEventResult.java, backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java, backend/src/main/java/moadong/club/payload/response/ClubCalendarEventsResponse.java, backend/src/main/java/moadong/club/service/ClubProfileService.java |
클럽 프로필 응답에 캘린더 이벤트 필드 추가: source 필드(NOTION/GOOGLE 구분), 집계된 이벤트 제공, 캘린더 연결 여부 플래그. |
프로모션 관리 기능 backend/src/main/java/moadong/club/controller/PromotionArticleController.java, backend/src/main/java/moadong/club/payload/dto/PromotionArticleCreateResultDto.java, backend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.java, backend/src/main/java/moadong/club/service/PromotionArticleService.java |
프로모션 생성 시 articleId 반환: 요청 검증 완화(images: @NotNull), 서비스 반환값 추가. |
미디어 업로드 서비스 리팩토링 backend/src/main/java/moadong/media/controller/PromotionImageController.java, backend/src/main/java/moadong/media/dto/PromotionImageUploadResponse.java, backend/src/main/java/moadong/media/service/BannerImageUploadService.java, backend/src/main/java/moadong/media/service/PromotionImageUploadService.java, backend/src/main/java/moadong/media/service/R2ImageUploadService.java, backend/src/main/java/moadong/media/util/ClubImageUtil.java |
S3 직접 업로드에서 R2ImageUploadService 위임으로 전환: 새로운 PromotionImageController/Service 추가, 파일 검증 및 키 생성 로직 중앙화. 로케일 안전성 개선(toLowerCase → toLowerCase(Locale.ROOT)). |
암호화 및 보안 backend/src/main/java/moadong/global/exception/ErrorCode.java, backend/src/main/java/moadong/global/util/AESCipher.java |
AES/GCM 암호화 개선: 고정 IV 취약점 수정(동적 랜덤 IV, v2: 프리픽스), 하위 호환성 유지. Notion/Google 통합 오류 코드 추가(950xx, 960xx). |
테스트 추가 backend/src/test/java/moadong/calendar/service/CalendarAggregationServiceTest.java, backend/src/test/java/moadong/club/service/PromotionArticleServiceTest.java, backend/src/test/java/moadong/fixture/GoogleConnectionFixture.java, backend/src/test/java/moadong/media/service/BannerImageUploadServiceTest.java, backend/src/test/java/moadong/media/service/PromotionImageUploadServiceTest.java, backend/src/test/java/moadong/media/service/R2ImageUploadServiceTest.java |
캘린더 집계, 프로모션 생성, 미디어 업로드 테스트 추가. R2 업로드로 S3 의존성 제거, 로케일 처리 검증 추가. GoogleConnection 픽스처 추가. |
개발자 포털 UI backend/src/main/resources/static/dev/index.html |
프로모션 게시판 관리 섹션 추가: 목록/선택/편집 폼, 이미지 미리보기, CRUD 상호작용, 이미지 업로드 워크플로우. 더티 상태 감지, 유효성 검증, 네비게이션 경고. |
Sequence Diagram(s)
sequenceDiagram
participant User as 사용자
participant FE as 프론트엔드
participant Controller as GoogleOAuthController
participant Service as GoogleOAuthService
participant DB as MongoDB
participant GoogleAPI as Google API
User->>FE: OAuth 인증 시작 (state 제공)
FE->>Controller: GET /oauth/authorize?state=...
Controller->>Service: getAuthorizeUrl(state)
Service->>FE: Google 인증 URL 반환
FE->>GoogleAPI: 리다이렉트 & 사용자 승인
GoogleAPI-->>FE: code 포함 콜백
FE->>Controller: POST /oauth/token { code }
Controller->>Service: exchangeCode(user, request)
Service->>GoogleAPI: code → access/refresh token
GoogleAPI-->>Service: tokens + email
Service->>Service: AES 암호화
Service->>DB: GoogleConnection 저장 (암호화 토큰)
DB-->>Service: 완료
Service-->>Controller: email 반환
Controller-->>FE: { email }
FE->>Controller: GET /calendars
Controller->>Service: getCalendars(user)
Service->>DB: 저장된 토큰 조회
Service->>Service: 복호화
Service->>GoogleAPI: 캘린더 목록 요청
GoogleAPI-->>Service: 캘린더 목록
Service-->>Controller: { calendars: [...] }
Controller-->>FE: 캘린더 표시
sequenceDiagram
participant User as 사용자
participant FE as 프론트엔드
participant CalendarAgg as CalendarAggregationService
participant NotionService as NotionOAuthService
participant GoogleService as GoogleOAuthService
participant DB as MongoDB
User->>FE: 클럽 캘린더 이벤트 조회
FE->>CalendarAgg: getAggregatedEvents(clubId)
par 병렬 요청
CalendarAgg->>NotionService: getClubCalendarEvents(clubId)
NotionService->>DB: 저장된 Notion 토큰 조회
NotionService-->>CalendarAgg: Notion 이벤트 리스트 (source: "NOTION")
and
CalendarAgg->>GoogleService: getClubCalendarEvents(clubId)
GoogleService->>DB: 저장된 Google 토큰 조회
GoogleService-->>CalendarAgg: Google 이벤트 리스트 (source: "GOOGLE")
end
CalendarAgg->>CalendarAgg: 병합 & start 기준 정렬
CalendarAgg-->>FE: 통합 이벤트 리스트
FE->>User: 캘린더 표시 (Notion/Google 이벤트 혼합)
sequenceDiagram
participant User as 사용자
participant FE as 프론트엔드
participant PromotionCtrl as PromotionImageController
participant PromotionService as PromotionImageUploadService
participant R2Service as R2ImageUploadService
participant DB as MongoDB
participant R2 as Cloudflare R2
User->>FE: 프로모션 이미지 업로드
FE->>PromotionCtrl: POST /{articleId}/upload { file }
PromotionCtrl->>PromotionService: upload(articleId, file)
PromotionService->>DB: PromotionArticle 존재 확인
alt 미존재
DB-->>PromotionService: 없음
PromotionService-->>PromotionCtrl: 예외: PROMOTION_ARTICLE_NOT_FOUND
else 존재
DB-->>PromotionService: 존재
PromotionService->>PromotionService: S3 키 생성 (promotion/articles/{id}/...)
PromotionService->>R2Service: upload(file, bucket, endpoint, key)
R2Service->>R2Service: 파일 검증 (크기, 확장자, MIME)
R2Service->>R2: putObject(key, file)
R2-->>R2Service: 성공
R2Service-->>PromotionService: CDN URL
PromotionService-->>PromotionCtrl: PromotionImageUploadResponse
end
PromotionCtrl-->>FE: { imageUrl }
FE->>User: 이미지 미리보기 표시
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~75 minutes
Possibly related PRs
- [BE] Release #1166: ClubProfileService 수정으로 클럽 프로필에 새로운 데이터 집계 통합 (캘린더 집계 vs. 이미지 URL 해석)
- [feature] Notion OAuth 백엔드 연동 추가 및 서버 저장 토큰 기반 페이지 조회 구현 #1351: Notion OAuth 백엔드 통합 (컨트롤러, 서비스, 엔티티), AESCipher v2 IV 변경, 오류 코드 추가와 동일 구현
- [feature] 홍보게시판 관리 개발자 페이지 기능 추가 #1348: 미디어 업로드 컴포넌트 (PromotionImageController, R2ImageUploadService, BannerImageUploadService 리팩토링) 및 관련 테스트 동일 구현
Suggested reviewers
- lepitaaar
- Zepelown
- oesnuj
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Title check | PR 제목 'Develop/be'는 개발 브랜치 이름일 뿐, 변경사항의 실제 내용을 설명하지 못합니다. | 제목을 변경하여 주요 변경사항을 명확하게 표현하세요. 예: 'Notion/Google Calendar 통합 및 프로모션 이미지 업로드 기능 추가' | |
| Docstring Coverage | Docstring coverage is 1.59% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (1 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing Touches
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Commit unit tests in branch
develop/be
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (10)
backend/src/main/java/moadong/global/util/AESCipher.java (1)
28-28:null또는 빈 문자열 입력 시 방어 로직 검토
encrypt()와decrypt()메서드에서null또는 빈 문자열 입력에 대한 검증이 없습니다. 상위 호출부에서 검증이 보장된다면 문제없지만, 방어적으로 유틸리티 레벨에서 처리하는 것을 고려해볼 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/global/util/AESCipher.java` at line 28, In AESCipher, add defensive input validation to both encrypt(String text) and decrypt(String text): if the argument is null return null immediately, if it's an empty string return an empty string, otherwise proceed with encryption/decryption; ensure the checks are the first lines in the encrypt and decrypt methods so behavior is consistent and avoids NPEs or unnecessary processing.backend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.java (1)
16-16:images에 원소 단위 검증을 추가하는 것을 권장합니다.Line 16은 리스트 자체만 검증하므로
""같은 무의미한 값이 통과할 수 있습니다. 비즈니스 정책상 빈 리스트를 허용하더라도 원소는@NotBlank로 제한하는 편이 안전합니다.검증 보강 예시
-public record PromotionArticleCreateRequest( +public record PromotionArticleCreateRequest( `@NotBlank` String clubId, `@NotBlank` String title, `@NotBlank` String location, `@NotNull` Instant eventStartDate, `@NotNull` Instant eventEndDate, `@NotBlank` String description, - `@NotNull` List<String> images + `@NotNull` List<@NotBlank String> images ) { }As per coding guidelines "Spring Boot + Gradle 기반 백엔드에서 예외 처리와 검증은 기존 프로젝트 방식과 일관되게 맞춘다".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.java` at line 16, The images field in PromotionArticleCreateRequest is only validated as a non-null list, so empty or blank strings can pass; update the field to validate elements by using an element-level constraint (e.g., change List<String> images to List<@NotBlank String> images) and add the corresponding import for `@NotBlank` (or javax.validation equivalent) so each entry must be non-blank while keeping the list-level constraint (`@NotNull` or `@NotEmpty`) as required by your business rules; adjust any DTO construction/tests that create requests to satisfy the new element validation.backend/src/test/java/moadong/calendar/service/CalendarAggregationServiceTest.java (1)
93-124:hasAnyCalendarConnection테스트 케이스가 충분합니다.Notion만, Google만, 둘 다 미연결 케이스가 잘 검증되었습니다. 완전성을 위해 둘 다 연결된 경우의 테스트도 추가하면 좋겠습니다.
✅ 둘 다 연결된 케이스 테스트 추가
`@Test` `@DisplayName`("둘 다 연결된 경우 true를 반환한다") void hasAnyCalendarConnection_bothConnected_returnsTrue() { when(notionOAuthService.hasCalendarConnection(CLUB_ID)).thenReturn(true); when(googleOAuthService.hasCalendarConnection(CLUB_ID)).thenReturn(true); boolean result = calendarAggregationService.hasAnyCalendarConnection(CLUB_ID); assertThat(result).isTrue(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/test/java/moadong/calendar/service/CalendarAggregationServiceTest.java` around lines 93 - 124, Add a test to CalendarAggregationServiceTest verifying the case when both providers are connected: mock notionOAuthService.hasCalendarConnection(CLUB_ID) and googleOAuthService.hasCalendarConnection(CLUB_ID) to return true, call calendarAggregationService.hasAnyCalendarConnection(CLUB_ID), and assert the result is true (e.g., name the test hasAnyCalendarConnection_bothConnected_returnsTrue and include a `@DisplayName` "둘 다 연결된 경우 true를 반환한다").backend/CLAUDE.md (1)
50-61: Markdown 코드 블록에 언어 지정이 누락되었습니다.정적 분석 도구에서 경고한 대로, fenced code block에 언어를 지정하면 일관성과 가독성이 향상됩니다.
📝 수정 제안
-``` +```text src/main/java/moadong/ ├── club/ # 동아리 CRUD, 모집, 지원서🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/CLAUDE.md` around lines 50 - 61, The fenced code block in backend/CLAUDE.md is missing a language identifier; update the block that contains the tree starting with "src/main/java/moadong/" to use an explicit language tag (e.g., ```text or ```bash) so the markdown renderer and linters correctly recognize the content and remove the static-analysis warning.backend/src/main/java/moadong/calendar/service/CalendarAggregationService.java (2)
25-39: 예외 처리 범위를 좁히고 로그에 예외 객체를 포함해 주세요.현재는
Exception을 광범위하게 삼키고 메시지만 남겨 장애 원인 추적이 어렵습니다. 통합 실패 격리는 유지하되, 예상 가능한 예외 타입으로 좁히고log.warn(..., e)형태로 스택 정보를 함께 남기는 쪽이 운영 안정성에 유리합니다.As per coding guidelines "Spring Boot + Gradle 기반 백엔드에서 예외 처리와 검증은 기존 프로젝트 방식과 일관되게 맞춘다".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/calendar/service/CalendarAggregationService.java` around lines 25 - 39, Narrow the catch blocks around notionOAuthService.getClubCalendarEvents(clubId) and googleOAuthService.getClubCalendarEvents(clubId) to handle specific expected exceptions instead of catching Exception, and change the log.warn calls to pass the exception object so the stacktrace is recorded (e.g., log.warn("... clubId={}, message={}", clubId, e.getMessage(), e)); keep the per-service isolation but replace broad catches with the concrete exception types thrown by those methods (or a small set of relevant runtime exceptions) and include the exception variable in the log.warn to preserve stack traces.
41-45: 문자열 정렬 대신 시간 타입 기준 정렬을 권장합니다.
start를 문자열로 비교하면 포맷이 섞일 때(날짜-only vs datetime/offset) 시간순 정렬이 깨질 수 있습니다. 파싱 가능한 시간 타입(LocalDate,OffsetDateTime,Instant)으로 정규화 후 정렬하는 쪽이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/calendar/service/CalendarAggregationService.java` around lines 41 - 45, The current sort in CalendarAggregationService uses Comparator.comparing(ClubCalendarEventResult::start, Comparator.nullsLast(String::compareTo)), which compares start as raw strings and breaks when formats vary; replace it by normalizing start into a temporal type (e.g., Instant or OffsetDateTime) and sorting by that Instant while still handling nulls. Implement a small parser method (e.g., parseStartToInstant(String start) in CalendarAggregationService or a utility) that tries OffsetDateTime.parse, then falls back to LocalDate.atStartOfDay(ZoneOffset.UTC) or a configurable zone, and return Instant; then change the comparator to Comparator.comparing(e -> parseStartToInstant(e.start()), Comparator.nullsLast(Comparator.naturalOrder())). Ensure parsing exceptions are caught and treated (e.g., return null) so nullsLast still applies.backend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.java (3)
142-230: 페이지네이션 로직 중복을 리팩토링하는 것을 권장합니다.
getRecentPages,getDatabases,getDatabasePages메서드에서 페이지네이션 처리 로직이 거의 동일합니다. 공통 헬퍼 메서드로 추출하면 유지보수성이 향상됩니다.♻️ 제안된 리팩토링 예시
private Map<String, Object> paginateNotionApi( String clubId, java.util.function.BiFunction<String, String, Map<String, Object>> apiCaller, String accessToken) { String nextCursor = null; List<Object> allResults = new ArrayList<>(); int requestCount = 0; boolean hasMore = false; boolean partial = false; while (true) { Map<String, Object> responseBody = apiCaller.apply(accessToken, nextCursor); requestCount++; Object resultsObj = responseBody.get("results"); if (resultsObj instanceof List<?> resultList) { allResults.addAll(resultList); } hasMore = Boolean.TRUE.equals(responseBody.get("has_more")); Object cursorObj = responseBody.get("next_cursor"); nextCursor = cursorObj instanceof String cursor && StringUtils.hasText(cursor) ? cursor : null; if (!hasMore || !StringUtils.hasText(nextCursor)) { break; } if (requestCount >= 50) { partial = true; log.warn("Notion 페이지네이션 요청 상한 도달. clubId={}, collected={}", clubId, allResults.size()); break; } } Map<String, Object> aggregated = new LinkedHashMap<>(); aggregated.put("object", "list"); aggregated.put("results", allResults); aggregated.put("has_more", partial && hasMore); aggregated.put("next_cursor", partial ? nextCursor : null); aggregated.put("total_results", allResults.size()); if (partial) { aggregated.put("partial", true); } return aggregated; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.java` around lines 142 - 230, The pagination logic in getRecentPages and getDatabases (and getDatabasePages) is duplicated; extract it into a single helper (e.g., paginateNotionApi) that accepts clubId, the accessToken and a BiFunction<String accessToken, String nextCursor, Map<String,Object>> apiCaller, then replace the loops in getRecentPages/getDatabases/getDatabasePages to call paginateNotionApi and return its aggregated map; ensure the helper reuses the same variables (nextCursor, allResults, requestCount, hasMore, partial), logs the same warning message, and uses searchNotionPages/searchNotionDatabases (or the appropriate API method) as the apiCaller.
532-548: 레거시 연결 마이그레이션 후 원본 데이터 정리를 고려해보세요.마이그레이션 시 새로운 연결을 저장하지만, 이전 레거시 연결(userId 기반)은 삭제되지 않습니다. 의도된 동작이라면 무시해도 되지만, 중복 데이터가 남을 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.java` around lines 532 - 548, The migration in findLegacyNotionConnectionAndMigrate creates a new NotionConnection from the legacy record but leaves the legacy (userId-keyed) row behind; update this method to remove or mark the legacy entry after successful save (e.g., call notionConnectionRepository.delete(legacy) or deleteById(legacy.getId())) and perform the save+delete inside a transaction to avoid data loss; ensure you reference the legacy variable returned from notionConnectionRepository.findById and only delete it after notionConnectionRepository.save(migrated) completes successfully, and handle/propagate exceptions appropriately.
353-421:searchNotionPages와searchNotionDatabases메서드 통합을 고려해보세요.두 메서드는 filter 값만 다르고 나머지 로직이 동일합니다. 파라미터로 filter 타입을 받는 공통 메서드로 통합할 수 있습니다.
♻️ 제안된 리팩토링
-private Map<String, Object> searchNotionPages(String notionAccessToken, String startCursor) { +private Map<String, Object> searchNotion(String notionAccessToken, String startCursor, String filterValue) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(notionAccessToken); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Notion-Version", notionProperties.version()); Map<String, Object> body = new LinkedHashMap<>(); - body.put("filter", Map.of("property", "object", "value", "page")); + body.put("filter", Map.of("property", "object", "value", filterValue)); body.put("sort", Map.of("direction", "descending", "timestamp", "last_edited_time")); body.put("page_size", 100); // ... rest of the method }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.java` around lines 353 - 421, Both searchNotionPages and searchNotionDatabases duplicate the same HTTP request logic; extract a single helper (e.g., searchNotionByObjectFilter) that takes the notionAccessToken and a filterValue string ("page" or "database") plus optional startCursor, builds headers and the request body with body.put("filter", Map.of("property","object","value", filterValue)), performs the restTemplate.exchange call, handles null responseBody and HttpStatusCodeException the same way, and then have searchNotionPages and searchNotionDatabases delegate to that helper (preserving existing log messages and thrown RestApiException/ErrorCode.NOTION_SEARCH_FAILED behavior and the pagination page_size/start_cursor handling).backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java (1)
504-548:NotionOAuthService와 중복되는 헬퍼 메서드들이 있습니다.
requireAuthenticatedClubId와asString메서드가 두 서비스에 동일하게 존재합니다. 공통 유틸리티 클래스나 추상 베이스 클래스로 추출하는 것을 고려해보세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java` around lines 504 - 548, The requireAuthenticatedClubId and asString methods are duplicated in GoogleOAuthService and NotionOAuthService; extract them into a shared utility or base class and update both services to call the shared implementations. Create either a static helper like OAuthHelpers.requireAuthenticatedClubId(ClubRepository, CustomUserDetails) and OAuthHelpers.asString(Object) preserving current behavior and exceptions, or introduce an AbstractOAuthService that holds a protected ClubRepository and implements protected String requireAuthenticatedClubId(CustomUserDetails) and protected String asString(Object); then remove the duplicate methods from GoogleOAuthService and NotionOAuthService and ensure dependency injection (clubRepository) is wired if you choose the abstract-base approach.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/docs/frontend-google-calendar-integration.md`:
- Line 199: The markdown file frontend-google-calendar-integration.md contains
triple-backtick fence code blocks with ASCII UI mockups that lack a language
identifier (MD040); update each unlabeled block to use a language identifier by
changing the opening fence from ``` to ```text for all UI mockup blocks (the
three ASCII calendar/mockup blocks), so they become fenced as "text" and the
markdownlint MD040 warning is resolved.
In `@backend/src/main/java/moadong/calendar/google/entity/GoogleConnection.java`:
- Around line 35-42: The updateTokens method in GoogleConnection currently
unconditionally overwrites encryptedRefreshToken which can erase a valid stored
refresh token when Google omits refresh_token on non-reconsent flows; change
updateTokens to only set this.encryptedRefreshToken when the
encryptedRefreshToken parameter is non-null and non-empty (e.g.,
StringUtils.hasText/enforce a null/empty check), while still updating
encryptedAccessToken, email, tokenExpiresAt and updatedAt as before; also review
GoogleOAuthService.saveGoogleConnection() usage to ensure it passes null when no
new refresh token is available so the guarded update in GoogleConnection
preserves the existing refresh token.
In
`@backend/src/main/java/moadong/calendar/google/payload/request/GoogleCalendarSelectRequest.java`:
- Around line 6-7: GoogleCalendarSelectRequest currently declares an unused
`@NotBlank` String calendarId while selectCalendar() uses the path variable
calendarId; either remove calendarId from the DTO (keep only String
calendarName) to avoid unnecessary validation, or add explicit validation in the
controller/service selectCalendar(...) to compare the path variable calendarId
with request.getCalendarId() and throw a BadRequestException if they differ;
update tests and DTO usages accordingly and ensure `@NotBlank` is removed if you
choose to delete the field.
In
`@backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java`:
- Around line 316-319: The warning is logged too early while the loop may still
continue; update the condition in the pagination loop (references: page,
MAX_GOOGLE_PAGE_REQUESTS, nextPageToken, pageToken) to only warn when you've
reached the page limit and there actually is another page to fetch — e.g. change
the check to require nextPageToken != null (if page == MAX_GOOGLE_PAGE_REQUESTS
- 1 && nextPageToken != null) before calling log.warn(...), so the warning
reflects that further pages exist but won't be fetched.
- Around line 167-170: The warning about hitting the page limit is logged too
early; in GoogleOAuthService (look for MAX_GOOGLE_PAGE_REQUESTS, page,
pageToken, nextPageToken) change the log placement so it is emitted only when
you actually stop paging (i.e., right before you break/return because the max
pages was reached or when you detect that nextPageToken is non-null but page ==
MAX_GOOGLE_PAGE_REQUESTS - 1 and you will not request another page). Concretely,
remove the current log from the `if (page == MAX_GOOGLE_PAGE_REQUESTS - 1)` spot
and add it immediately where you decide to stop paging (the break/return branch
that follows detection of the page limit), keeping the same message and
including clubId and MAX_GOOGLE_PAGE_REQUESTS.
In `@backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java`:
- Around line 41-43: The current ClubDetailedResult.of(Club,
List<ClubCalendarEventResult>) calls !calendarEvents.isEmpty() and can NPE if
calendarEvents is null; update this method to normalize calendarEvents before
checking/forwarding (e.g., replace the direct call with a null-safe check or
assign calendarEvents =
Optional.ofNullable(calendarEvents).orElse(Collections.emptyList())) and then
call the overloaded of(Club, List<ClubCalendarEventResult>, boolean) with the
normalized list and its isEmpty() result so the null case is handled safely.
In `@backend/src/main/java/moadong/club/service/ClubProfileService.java`:
- Around line 93-94: The code is using
calendarAggregationService.hasAnyCalendarConnection(club.getId()) to populate
hasCalendarEvents, but that method only checks for connections not actual event
existence; update ClubProfileService to pass the correct boolean into
ClubDetailedResult.of by either (A) changing the field name to reflect
connection status (e.g., hasCalendarConnection) where
ClubDetailedResult.of(club, List.of(), hasCalendarConnection) is used, or (B)
compute a real event-existence flag by calling the appropriate
calendarAggregationService method that checks for events (e.g.,
hasAnyCalendarEvents or a count method) and pass that boolean as
hasCalendarEvents; adjust both locations (the lines around hasCalendarEvents and
the other occurrence at lines ~102-103) and ensure the ClubDetailedResult
factory/field name matches the chosen semantics.
In `@backend/src/main/java/moadong/global/exception/ErrorCode.java`:
- Around line 86-87: The two ErrorCode enum entries NOTION_SEARCH_FAILED and
NOTION_DATABASE_QUERY_FAILED are mapped to HttpStatus.BAD_REQUEST but represent
upstream Notion failures and should be 5xx; update their HttpStatus from
BAD_REQUEST to HttpStatus.BAD_GATEWAY to match GOOGLE_API_FAILED and consistent
retry/alert semantics (also check and adjust any related entry 95-95 similarly
if present).
In `@backend/src/main/java/moadong/media/service/BannerImageUploadService.java`:
- Around line 23-30: The upload method in BannerImageUploadService currently
falls back to "image" when file.getOriginalFilename() is null, producing keys
without extensions (e.g., "web/image"); update upload(MultipartFile file,
PlatformType type) to preserve/append a valid file extension: first try to
extract the extension from the original filename, and if absent, map
file.getContentType() to a reasonable extension (use a small mapping or MimeType
utilities) and append it to the sanitized base name; ensure sanitizeFilename is
applied after you build the filename-with-extension and then use that result
when creating key passed to r2ImageUploadService.upload (keep bannerBucketName
and bannerViewEndpoint usage unchanged).
In
`@backend/src/main/java/moadong/media/service/PromotionImageUploadService.java`:
- Around line 43-46: buildPromotionImageKey currently calls
file.getOriginalFilename() directly which can throw NPE before
R2ImageUploadService's validation runs; change the method to null-safe read of
the filename (e.g., treat file == null or file.getOriginalFilename() == null as
an empty string) before cleaning and sanitizing so the code consistently follows
the common validation path in R2ImageUploadService; update logic around
StringUtils.cleanPath and sanitizeFilename to operate on the null-safe filename
and keep method name buildPromotionImageKey and helper sanitizeFilename
unchanged.
In `@backend/src/main/resources/static/dev/index.html`:
- Around line 1690-1709: The create/edit flow is not atomic:
createPromotionArticleRequest is called separately from the image upload (from
normalizePromotionPayload), leaving orphaned articles or files on partial
failure; fix by making the server handle article+uploads in one endpoint or
implement client-side compensation: in isPromotionCreateMode() after calling
createPromotionArticleRequest include uploaded file references in the same
request or, if you must upload afterwards, call a rollback API (e.g.,
deletePromotionArticleRequest) to remove the newly created article when the
subsequent upload fails; similarly in edit mode ensure the PUT
(updatePromotionArticleRequest) and file uploads are performed atomically or, on
upload failure, call deleteUploadedFilesRequest for any newly uploaded files and
revert the article update; update error handling around
normalizePromotionPayload, createPromotionArticleRequest and the edit-path PUT
to trigger these compensating calls and surface errors via setPromotionBanner.
---
Nitpick comments:
In `@backend/CLAUDE.md`:
- Around line 50-61: The fenced code block in backend/CLAUDE.md is missing a
language identifier; update the block that contains the tree starting with
"src/main/java/moadong/" to use an explicit language tag (e.g., ```text or
```bash) so the markdown renderer and linters correctly recognize the content
and remove the static-analysis warning.
In
`@backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java`:
- Around line 504-548: The requireAuthenticatedClubId and asString methods are
duplicated in GoogleOAuthService and NotionOAuthService; extract them into a
shared utility or base class and update both services to call the shared
implementations. Create either a static helper like
OAuthHelpers.requireAuthenticatedClubId(ClubRepository, CustomUserDetails) and
OAuthHelpers.asString(Object) preserving current behavior and exceptions, or
introduce an AbstractOAuthService that holds a protected ClubRepository and
implements protected String requireAuthenticatedClubId(CustomUserDetails) and
protected String asString(Object); then remove the duplicate methods from
GoogleOAuthService and NotionOAuthService and ensure dependency injection
(clubRepository) is wired if you choose the abstract-base approach.
In
`@backend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.java`:
- Around line 142-230: The pagination logic in getRecentPages and getDatabases
(and getDatabasePages) is duplicated; extract it into a single helper (e.g.,
paginateNotionApi) that accepts clubId, the accessToken and a BiFunction<String
accessToken, String nextCursor, Map<String,Object>> apiCaller, then replace the
loops in getRecentPages/getDatabases/getDatabasePages to call paginateNotionApi
and return its aggregated map; ensure the helper reuses the same variables
(nextCursor, allResults, requestCount, hasMore, partial), logs the same warning
message, and uses searchNotionPages/searchNotionDatabases (or the appropriate
API method) as the apiCaller.
- Around line 532-548: The migration in findLegacyNotionConnectionAndMigrate
creates a new NotionConnection from the legacy record but leaves the legacy
(userId-keyed) row behind; update this method to remove or mark the legacy entry
after successful save (e.g., call notionConnectionRepository.delete(legacy) or
deleteById(legacy.getId())) and perform the save+delete inside a transaction to
avoid data loss; ensure you reference the legacy variable returned from
notionConnectionRepository.findById and only delete it after
notionConnectionRepository.save(migrated) completes successfully, and
handle/propagate exceptions appropriately.
- Around line 353-421: Both searchNotionPages and searchNotionDatabases
duplicate the same HTTP request logic; extract a single helper (e.g.,
searchNotionByObjectFilter) that takes the notionAccessToken and a filterValue
string ("page" or "database") plus optional startCursor, builds headers and the
request body with body.put("filter", Map.of("property","object","value",
filterValue)), performs the restTemplate.exchange call, handles null
responseBody and HttpStatusCodeException the same way, and then have
searchNotionPages and searchNotionDatabases delegate to that helper (preserving
existing log messages and thrown RestApiException/ErrorCode.NOTION_SEARCH_FAILED
behavior and the pagination page_size/start_cursor handling).
In
`@backend/src/main/java/moadong/calendar/service/CalendarAggregationService.java`:
- Around line 25-39: Narrow the catch blocks around
notionOAuthService.getClubCalendarEvents(clubId) and
googleOAuthService.getClubCalendarEvents(clubId) to handle specific expected
exceptions instead of catching Exception, and change the log.warn calls to pass
the exception object so the stacktrace is recorded (e.g., log.warn("...
clubId={}, message={}", clubId, e.getMessage(), e)); keep the per-service
isolation but replace broad catches with the concrete exception types thrown by
those methods (or a small set of relevant runtime exceptions) and include the
exception variable in the log.warn to preserve stack traces.
- Around line 41-45: The current sort in CalendarAggregationService uses
Comparator.comparing(ClubCalendarEventResult::start,
Comparator.nullsLast(String::compareTo)), which compares start as raw strings
and breaks when formats vary; replace it by normalizing start into a temporal
type (e.g., Instant or OffsetDateTime) and sorting by that Instant while still
handling nulls. Implement a small parser method (e.g.,
parseStartToInstant(String start) in CalendarAggregationService or a utility)
that tries OffsetDateTime.parse, then falls back to
LocalDate.atStartOfDay(ZoneOffset.UTC) or a configurable zone, and return
Instant; then change the comparator to Comparator.comparing(e ->
parseStartToInstant(e.start()),
Comparator.nullsLast(Comparator.naturalOrder())). Ensure parsing exceptions are
caught and treated (e.g., return null) so nullsLast still applies.
In
`@backend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.java`:
- Line 16: The images field in PromotionArticleCreateRequest is only validated
as a non-null list, so empty or blank strings can pass; update the field to
validate elements by using an element-level constraint (e.g., change
List<String> images to List<@NotBlank String> images) and add the corresponding
import for `@NotBlank` (or javax.validation equivalent) so each entry must be
non-blank while keeping the list-level constraint (`@NotNull` or `@NotEmpty`) as
required by your business rules; adjust any DTO construction/tests that create
requests to satisfy the new element validation.
In `@backend/src/main/java/moadong/global/util/AESCipher.java`:
- Line 28: In AESCipher, add defensive input validation to both encrypt(String
text) and decrypt(String text): if the argument is null return null immediately,
if it's an empty string return an empty string, otherwise proceed with
encryption/decryption; ensure the checks are the first lines in the encrypt and
decrypt methods so behavior is consistent and avoids NPEs or unnecessary
processing.
In
`@backend/src/test/java/moadong/calendar/service/CalendarAggregationServiceTest.java`:
- Around line 93-124: Add a test to CalendarAggregationServiceTest verifying the
case when both providers are connected: mock
notionOAuthService.hasCalendarConnection(CLUB_ID) and
googleOAuthService.hasCalendarConnection(CLUB_ID) to return true, call
calendarAggregationService.hasAnyCalendarConnection(CLUB_ID), and assert the
result is true (e.g., name the test
hasAnyCalendarConnection_bothConnected_returnsTrue and include a `@DisplayName` "둘
다 연결된 경우 true를 반환한다").
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 0db5f813-8799-46f2-bf59-061405d186c3
📒 Files selected for processing (51)
backend/.gitignorebackend/.infisical.jsonbackend/CLAUDE.mdbackend/build.gradlebackend/docs/frontend-google-calendar-integration.mdbackend/docs/notion-integration-decisions-2026-03-22.mdbackend/docs/superpowers/specs/2026-03-30-google-calendar-integration-design.mdbackend/gradle/infisical.gradlebackend/src/main/java/moadong/calendar/google/config/GoogleCalendarProperties.javabackend/src/main/java/moadong/calendar/google/controller/GoogleOAuthController.javabackend/src/main/java/moadong/calendar/google/entity/GoogleConnection.javabackend/src/main/java/moadong/calendar/google/payload/dto/GoogleTokenApiResponse.javabackend/src/main/java/moadong/calendar/google/payload/request/GoogleCalendarSelectRequest.javabackend/src/main/java/moadong/calendar/google/payload/request/GoogleTokenExchangeRequest.javabackend/src/main/java/moadong/calendar/google/payload/response/GoogleTokenExchangeResponse.javabackend/src/main/java/moadong/calendar/google/repository/GoogleConnectionRepository.javabackend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.javabackend/src/main/java/moadong/calendar/notion/config/NotionProperties.javabackend/src/main/java/moadong/calendar/notion/controller/NotionOAuthController.javabackend/src/main/java/moadong/calendar/notion/entity/NotionConnection.javabackend/src/main/java/moadong/calendar/notion/payload/dto/NotionTokenApiResponse.javabackend/src/main/java/moadong/calendar/notion/payload/request/NotionTokenExchangeRequest.javabackend/src/main/java/moadong/calendar/notion/payload/response/NotionTokenExchangeResponse.javabackend/src/main/java/moadong/calendar/notion/repository/NotionConnectionRepository.javabackend/src/main/java/moadong/calendar/notion/service/NotionOAuthService.javabackend/src/main/java/moadong/calendar/service/CalendarAggregationService.javabackend/src/main/java/moadong/club/controller/ClubProfileController.javabackend/src/main/java/moadong/club/controller/PromotionArticleController.javabackend/src/main/java/moadong/club/payload/dto/ClubCalendarEventResult.javabackend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.javabackend/src/main/java/moadong/club/payload/dto/PromotionArticleCreateResultDto.javabackend/src/main/java/moadong/club/payload/request/PromotionArticleCreateRequest.javabackend/src/main/java/moadong/club/payload/response/ClubCalendarEventsResponse.javabackend/src/main/java/moadong/club/service/ClubProfileService.javabackend/src/main/java/moadong/club/service/PromotionArticleService.javabackend/src/main/java/moadong/global/exception/ErrorCode.javabackend/src/main/java/moadong/global/util/AESCipher.javabackend/src/main/java/moadong/media/controller/PromotionImageController.javabackend/src/main/java/moadong/media/dto/PromotionImageUploadResponse.javabackend/src/main/java/moadong/media/service/BannerImageUploadService.javabackend/src/main/java/moadong/media/service/PromotionImageUploadService.javabackend/src/main/java/moadong/media/service/R2ImageUploadService.javabackend/src/main/java/moadong/media/util/ClubImageUtil.javabackend/src/main/resources/static/dev/index.htmlbackend/src/test/java/moadong/calendar/service/CalendarAggregationServiceTest.javabackend/src/test/java/moadong/club/service/PromotionArticleServiceTest.javabackend/src/test/java/moadong/fixture/GoogleConnectionFixture.javabackend/src/test/java/moadong/media/service/BannerImageUploadServiceTest.javabackend/src/test/java/moadong/media/service/PromotionImageUploadServiceTest.javabackend/src/test/java/moadong/media/service/R2ImageUploadServiceTest.javadocs/draft/promotion-admin-create-plan-2026-03-29.md
backend/src/main/java/moadong/calendar/google/entity/GoogleConnection.java
Show resolved
Hide resolved
backend/src/main/java/moadong/calendar/google/payload/request/GoogleCalendarSelectRequest.java
Outdated
Show resolved
Hide resolved
backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java
Outdated
Show resolved
Hide resolved
backend/src/main/java/moadong/calendar/google/service/GoogleOAuthService.java
Outdated
Show resolved
Hide resolved
backend/src/main/java/moadong/club/service/ClubProfileService.java
Outdated
Show resolved
Hide resolved
backend/src/main/java/moadong/media/service/BannerImageUploadService.java
Show resolved
Hide resolved
backend/src/main/java/moadong/media/service/PromotionImageUploadService.java
Show resolved
Hide resolved
[Fix] pr review 1385
🚀 릴리즈 PR
📦 버전 정보
💾 BE/💻 FE🚨 MAJOR/➕ MINOR/🔧 PATCHvX.Y.Z📖 버전 라벨 선택 가이드 (Semantic Versioning)
🚨 MAJORv1.0.0→v2.0.0➕ MINORv1.0.0→v1.1.0🔧 PATCHv1.0.0→v1.0.1📋 포함된 변경사항
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항