Feat/sw 67 - 검색 필터링 기능 구현 #42
Conversation
- init_postgres.sql: folder_type 컬럼 추가 및 시스템 폴더 유니크 제약 설정 - MemberFolder.java: folderType 필드 및 markAsUnorganized 메서드 추가 - MemberFolderServiceImpl.java: 미분류 폴더 자동 생성 및 보장 로직 구현 - CustomOAuth2MemberService.java: 소셜 가입 시 기본 폴더 생성 연동 - FolderErrorCode.java: 시스템 폴더 관련 에러 코드 추가 - MemberFolderResponses.java: DTO에 folderType 필드 추가 - FolderCard.tsx: 시스템 폴더 제어 제한 및 UI 반영
- BookmarkSearchResponse: 검색 성능 확보를 위한 모델링: 북마크 목록과 함께 검색어에 매칭되는 상위 폴더 ID 목록을 한 번의 요청으로 반환하기 위한 응담용 DTO 정의. - BookmarkServiceImpl: 서비스 로직 고도화: 단순 북마크 조회를 넘어 검색어와 매칭되는 북마크가 속한 모든 상위 폴더 ID를 중복 없이 추출하여 O(M) 수준의 폴더 필터링 기반 마련. - MybatisBookmarkDao: 데이터 소스 접근 계층 효율화: 검색어 기반으로 매칭되는 폴더의 계층 구조를 탐색하고 유효한 폴더 ID 리스트를 반환하는 전용 DB 접근 인터페이스 추가. - bookmark-mapper.xml: SQL 쿼리 성능 최적화: 동적 ORDER BY 처리 및 복합 검색 조건에 따른 북마크 및 매칭 폴더 ID 추출 로직의 MyBatis Mapper 구현. - BookmarkApiController: API 인터페이스 최적화: 최적화된 검색 결과 DTO를 클라이언트에 전달하기 위한 엔드포인트 반환 타입 변경 및 데이터 무결성 검증.
- RightPanel: 사용자 인터페이스 반응성 개선: 잦은 상태 변경에 따른 불필요한 API 요청 및 렌더링 부하를 방지하기 위해 검색어 입력창에 디바운스(Debounce) 메커니즘을 적용하여 쾌적한 UX 최적화. - folderStore: 상태 관리 아키텍처 정립: 검색어, 검색 범위(Folders/All) 상태를 중앙 집중적으로 제어하기 위한 Zustand 스토어 프로퍼티 설계 및 액션 추가. (Saved today 관련 필드 제외) - linkStore: 검색 컨텍스트 연동 강화: 사이드바의 특정 폴더 내 북마크 목록과 메인 페이지의 폴더 목록 검색어 상태를 실시간으로 동기화하여 서비스 전반의 검색 일관성 확보. - bookmarkApi: API 통신 모듈 고도화: 백엔드의 BookmarkSearchResponse 구조 변경에 대응하기 위한 React Query 훅의 응답 타입 정의 수정 및 데이터 패칭 안정성 개선. - my-links_search_feature_plan.md: 기술 설계 문서화: 검색 기능의 전반적인 데이터 흐름, 디바운싱 전략 및 폴더-북마크 매칭 알고리즘에 대한 상세 기술 사양 명세화.
- page.tsx: 인터페이스 글로벌화 수행: My-Links 페이지 내 모든 한국어 레이블을 영문(Recently Added 등)으로 변환하여 시각적 전문성 및 서비스 확장성 확보. (Saved today 관련 로직 제외) - page.tsx: 데이터 정렬 및 시각적 계층화 최적화: 미분류(Unorganized) 시스템 폴더를 목록 최상단에 고정 배치하고, 다른 폴더들을 최신순/이름순으로 정렬하는 직관적인 레이아웃 로직 구현. - 04. unorganized_folder_introduction_plan.md: 시스템 폴더 아키텍처 설계 문서화: 미분류 폴더 도입 배경, DB 유니크 제약 사항 및 시스템 자동 생성 로직에 대한 상세 기술 명세 수립.
- dateUtils: 공통 유틸리티 생성: 날짜 비교 로직의 중복을 제거하고 코드 재사용성을 높이기 위해 'isCreatedToday' 헬퍼 함수를 별도 유틸리티 파일로 분리 및 외부 모듈화. - RightPanel: 링크 필터링 기능 신규 도입: 'savedTodayFilter' 상태에 따른 실시간 클라이언트 사이드 필터링 로직을 신규 적용하여, 오늘 저장된 링크만 선별적으로 확인할 수 있는 기능 구현. - MyLinksPage: 전역 필터링 UX 최적화: 'Saved today' 필터 활성화 시 기존 폴더 선택 상태를 초기화(null 처리)함으로써 사용자에게 오늘 저장된 모든 링크를 전역적으로 조망할 수 있는 직관적인 뷰 모드 제공. - MyLinksPage: 코드 클린업: 로컬에 중복 정의되었던 날짜 확인 함수를 제거하고 'dateUtils' 공통 모듈을 참조하도록 리팩토링 수행. - globals: 시각적 피드백 일관성 확보: 모든 'button' 태그 및 'role=button' 요소에 'cursor-pointer' 스타일을 전역 적용하여 클릭 가능한 요소에 대한 사용자 인터랙션 인지 기능 강화.
init_postgres.sql: 데이터 구조 확장: 북마크별 조회수(view_count)와 마지막 읽은 시간(last_viewed_at) 컬럼을 추가하여 읽음 상태 추적 기반 마련. BookmarkApiController: API 기능 확장: 링크 읽음 처리 엔드포인트 추가 및 검색 API에 '미열람 항목만 보기' 필터링 파라미터 연동. BookmarkServiceImpl: 비즈니스 로직 구현: 링크 클릭 시 읽음 상태 업데이트 처리 및 조건에 따른 미열람 데이터 필터링 로직 적용. bookmark-mapper.xml: 쿼리 최적화: 조회수 기반 필터링 처리를 위한 동적 SQL 및 조회수 업데이트(Update) 쿼리 정의.
folderStore: 검색 및 필터 기능 확대: 아직 읽지 않은 링크만 모아보는 필터와 '링크 전용' 검색 기능을 추가하여 정보 접근성 개선. bookmarkApi: 실시간 읽음 표시 연동: 링크 클릭 시 즉시 읽음 상태로 변경되고, 목록이 자동으로 업데이트되도록 비동기 데이터 동기화 구현. SortDropdown: 컴포넌트 활용도 증대: 드롭다운의 좌우 정렬 방향 선택 기능과 버튼 모양을 자유롭게 바꿀 수 있는 커스텀 구조 적용. bookmark.ts: 데이터 규격 정의: 새로 추가된 조회수, 읽은 시간 등 서버 데이터 형식을 타입 시스템에 반영하여 코드 안정성 확보.
page.tsx: 검색 편의성 강화: 검색창에서 '전체, 폴더, 링크' 중 범위를 골라 검색할 수 있는 기능을 추가하고, 안 읽은 링크만 모아보는 버튼을 배치하여 검색 경험 개선. RightPanel: UI 심미성 개선: 닫기 버튼들을 세련된 원형 배지 스타일로 통일하고 회전 애니메이션을 추가하여 프리미엄 디자인 구현. FolderCard: 레이아웃 최적화: 검색 결과 및 폴더 선택 시의 시각적 효과를 강화하고 컴포넌트 내부 아이콘과 텍스트 정렬 상태를 정밀하게 조정.
folderStore: 상태 관리 확장: 시스템 미분류(Unorganized) 폴더만 선별하여 표시하기 위한 'unorganizedFilter' 상태 및 토글 액션 추가. page.tsx: 미분류 필터 UI 구현: 필터 섹션에 'Unorganized' 버튼을 추가하고, 필터 활성화 시 시스템 폴더만 필터링하여 그리드에 표시하는 로직 적용. page.tsx: 검색 결과 헤더 개선: 미분류 필터 활성화 시 검색 결과 상단에 'Unorganized: N folders found' 형태의 가이드 문구가 표시되도록 처리. RightPanel: 사용자 인지 편의성 개선: 현재 적용된 필터(Unread, Saved today, Unorganized) 정보를 우측 패널 헤더 제목에 실시간으로 결합하여 표시하는 동적 타이틀 로직 구현. RightPanel: 시각적 일관성 확보: 폴더 선택 여부와 필터 활성화 조합에 따라 '폴더명 + 필터명' 또는 '필터명'만 표시되도록 조건부 렌더링 최적화.
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Walkthrough이 변경 사항은 Sequence Diagram(s)sequenceDiagram
actor User
participant MyLinksPage
participant RightPanel
participant LinkStore
participant BookmarkAPI
participant Backend
User->>MyLinksPage: 검색 쿼리 입력 또는 필터 토글
MyLinksPage->>LinkStore: setSearchQuery() / 필터 상태 변경
MyLinksPage->>MyLinksPage: searchScope, searchQuery, 활성 필터로 계산
MyLinksPage->>BookmarkAPI: useBookmarks() 호출<br/>(query, unreadOnly, savedTodayOnly)
BookmarkAPI->>Backend: GET /api/bookmarks?query=...&filters
Backend->>Backend: 검색 조건 적용, 매칭 폴더 ID 추출
Backend-->>BookmarkAPI: BookmarkSearchResponse<br/>(bookmarks, matchingFolderIds, totalCount)
BookmarkAPI-->>MyLinksPage: matchingFolders, searchResultFolders 계산
MyLinksPage-->>User: 검색 결과 UI 표시
sequenceDiagram
actor User
participant RightPanel
participant LinkItem
participant BookmarkAPI
participant Backend
User->>LinkItem: 링크 열기/클릭
LinkItem->>RightPanel: onOpenLink(bookmarkData) 콜백
RightPanel->>BookmarkAPI: useRecordBookmarkView() 뮤테이션
BookmarkAPI->>Backend: PATCH /api/bookmarks/{bookmarkId}/read
Backend->>Backend: viewCount 증가, lastViewedAt 갱신
Backend-->>BookmarkAPI: 성공 응답
BookmarkAPI->>BookmarkAPI: React Query 캐시 갱신<br/>(viewCount, lastViewedAt)
BookmarkAPI->>BookmarkAPI: unreadOnly 필터 쿼리 무효화
RightPanel-->>User: URL 열기
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
70001f7 to
db6d505
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 70001f735e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| import { ThemeProvider } from "@/components/providers/ThemeProvider"; | ||
|
|
||
| import { AgentationProvider } from "@/components/providers/AgentationProvider"; | ||
| import { Agentation } from "agentation"; |
There was a problem hiding this comment.
Move agentation to runtime deps or load it dynamically
The agentation module is imported statically in layout.tsx, so the module must resolve at build time even though rendering is gated by isDev. Because this package was added under devDependencies, builds that install/prune to production deps before next build will fail with module resolution errors. Either move agentation to dependencies or use a dynamic import that is only evaluated in development.
Useful? React with 👍 / 👎.
| rightPanelOpen | ||
| ? 'xl:w-[700px] xl:bg-white xl:dark:bg-[#0a0a0b] xl:border-l xl:border-gray-200 xl:dark:border-white/[0.08]' | ||
| : '' | ||
| ? 'w-[700px] bg-white dark:bg-[#0a0a0b] border-l border-gray-200 dark:border-white/[0.08]' | ||
| : 'bg-transparent' |
There was a problem hiding this comment.
Make the header right spacer responsive
The header now always applies a fixed w-[700px] block whenever rightPanelOpen is true, but the right panel itself is hidden below xl. In those viewports, this still reserves 700px and compresses/overflows the header content, creating a mobile/tablet layout regression. The width reservation should be gated to the same breakpoint as the panel.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db6d50580d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_owner_unorganized | ||
| ON "member_folder" ("owner_member_id") | ||
| WHERE folder_type = 'UNORGANIZED' AND deleted_at IS NULL; |
There was a problem hiding this comment.
Add idempotent folder_type migration before index creation
On upgraded databases where member_folder already exists, this script never adds folder_type (the table is only created IF NOT EXISTS), but it immediately creates an index that references that column. In that environment the migration fails at startup with column "folder_type" does not exist, blocking deployment. Add ALTER TABLE "member_folder" ADD COLUMN IF NOT EXISTS "folder_type" ... (and related backfill/constraint handling) before this index.
Useful? React with 👍 / 👎.
| folderId: selectedFolderId, | ||
| sort: backendSort, | ||
| query: linkSearchQuery, | ||
| unreadOnly: unreadFilter || undefined, | ||
| }); |
There was a problem hiding this comment.
Apply unorganized filter to RightPanel bookmark query
This request never uses unorganizedFilter, so when the filter is enabled and selectedFolderId is cleared (as done in my-links/page.tsx), the panel fetches links from all folders while the title shows Unorganized. That produces incorrect results for users who toggle the filter without manually selecting the unorganized folder. Resolve by deriving the unorganized folder ID from myFolders and passing it as folderId whenever unorganizedFilter is active.
Useful? React with 👍 / 👎.
folderStore.ts: 필터 버튼 토글 시 Zustand 최신 state 기준으로 nextValue 계산 및 필터 활성화 시 selectedFolderId 초기화 처리로 렌더 시점 stale 값 참조 가능성 제거. page.tsx: Saved Today와 Unread 필터의 좌측 폴더 결과를 bookmarks 배열 재계산 대신 백엔드 matchingFolderIds 기준으로 변경하여 우측 링크 패널과 동일 조건 사용. page.tsx: 미분류 필터 활성화 시 미분류 폴더 ID를 bookmarkParams.folderId로 전달하여 좌측 폴더 결과와 우측 링크 조회 조건 동기화. folder.ts, FolderCard.tsx, SaveLinkDialog.tsx, RightPanel.tsx: UNORGANIZED 문자열 직접 비교를 FOLDER_TYPE 상수 비교로 통일하여 폴더 타입 비교 안정성 확보. page.tsx: 필터와 검색 조건별 emptyFilteredMessage 분기 추가로 좌측 빈 상태 안내 개선.
bookmarkApi.ts: 북마크 목록 요청에 savedTodayOnly, limit, offset query parameter 전달 로직 추가 및 useInfiniteBookmarks hook 도입으로 페이지 단위 조회 지원. bookmark.ts: BookmarkSearchParams에 savedTodayOnly, limit, offset 필드 추가 및 BookmarkSearchResponse에 totalCount 필드 추가로 페이징 응답 타입 확장. RightPanel.tsx: useInfiniteBookmarks 기반 목록 조회와 IntersectionObserver 하단 감지 로직 추가로 우측 패널 무한 스크롤 적용. RightPanel.tsx: 여러 페이지의 bookmarks를 flatMap으로 병합하고 totalCount 기준 링크 개수 표시로 제한 조회 환경의 목록 카운트 정합성 확보.
BookmarkRequests.java: SearchDto query trim 처리 및 역슬래시, 퍼센트, 언더스코어 escape 적용으로 LIKE 와일드카드 입력에 의한 의도치 않은 검색 범위 확장 방지. BookmarkRequests.java: unreadOnly와 savedTodayOnly 요청 값을 Boolean.TRUE.equals로 Command에 전달하여 null 요청값의 false 의미 정규화. BookmarkRequests.java, BookmarkSearchCommand.java: savedTodayOnly, limit, offset 검색 조건 추가 및 limit 기본값/최대값, offset 최소값 정규화로 조회 조건 안정성 확보. bookmark-mapper.xml: LOWER(display_title) LIKE 조건에 ESCAPE '\' 적용 및 Asia/Seoul 기준 savedTodayOnly 일자 범위 조건 추가로 검색/날짜 필터 정합성 확보. bookmark-mapper.xml: bookmarkSearchPredicates 공통 SQL 분리 및 selectBookmarkList, selectMatchingFolderIds, countBookmarkList 조건 일관성 확보. bookmark-mapper.xml: selectBookmarkList를 북마크 row 제한 후 tag join하는 subquery 구조로 변경하여 태그 join으로 인한 limit 왜곡 방지. BookmarkDao.java: 다중 파라미터 DAO 메서드에 @Param 명시 추가로 MyBatis mapper 파라미터 이름 매핑 안정성 확보. MybatisBookmarkDao.java, BookmarkServiceImpl.java, BookmarkSearchResponse.java: countBookmarkList 조회 경로와 totalCount 응답 추가로 제한 조회의 전체 개수 제공.
BookmarkRequests.java: CreateDto public 필드를 private 필드와 @Getter, @NoArgsConstructor, @AllArgsConstructor 기반 구조로 변경하여 요청 DTO 직접 필드 접근 제거. BookmarkRequests.java: UpdateDto public 필드를 private 필드와 getter-only 구조로 변경하여 setter 노출 범위 축소. BookmarkRequests.java: SearchDto는 @ModelAttribute query parameter 바인딩 안정성을 위해 @Getter/@Setter 유지 및 요청 DTO별 setter 노출 범위 분리. BookmarkApiController.java: 북마크 생성 요청 처리에서 request 필드 직접 접근을 getter 호출로 변경하여 DTO 캡슐화 규칙 준수.
bookmarkApi.ts: useRecordBookmarkView 성공 시 전체 ['bookmarks'] 쿼리 무조건 invalidate 제거 및 캐시 내 대상 bookmark만 로컬 갱신. bookmarkApi.ts: 일반 북마크 목록 캐시는 viewCount + 1, lastViewedAt 현재 시간 갱신으로 서버의 누적 조회수 정책과 프론트 캐시 의미 일치. bookmarkApi.ts: unreadOnly 쿼리는 읽은 bookmark를 캐시에서 제거하고 totalCount를 감소시킨 뒤 unread 쿼리만 좁게 invalidate하여 불필요한 전체 재조회 방지. bookmarkApi.ts: 일반 응답과 useInfiniteQuery 응답을 모두 처리하는 cache data 타입 가드 추가로 기존 목록 조회와 무한 스크롤 조회의 읽음 상태 갱신 지원.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c8a0f3b61c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| SET view_count = view_count + 1, | ||
| last_viewed_at = now(), | ||
| updated_at = now(), | ||
| updated_by_member_id = #{memberId} |
There was a problem hiding this comment.
Stop bumping updated_at when marking a bookmark read
This update runs on every link open and currently writes updated_at = now(), but list queries are sorted by msl.updated_at for both Newest and Oldest modes. As a result, simply viewing a bookmark changes its sort position, causing confusing reordering and unstable pagination (items can shift between offsets while the user scrolls). recordView should only update read-tracking fields (view_count, last_viewed_at) and leave updated_at unchanged.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 32
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (6)
frontend/src/components/dialogs/SaveLinkDialog.tsx (1)
246-249: 🧹 Nitpick | 🔵 Trivial
tags를 콤마로 직렬화 — 태그명에 콤마 포함 시 손실 위험 (현 변경 외 기존 이슈).이 PR의 직접 변경은 아니지만,
selectedTags.join(',')은 사용자가 입력한 태그명에,가 포함되면 서버에서 분리 시 깨집니다. 본 PR의 Quick Save 경로에서도 동일한 직렬화를 사용하므로, 추후 배열 전송(tags: string[])으로 전환하거나 입력 단계에서 콤마를 검증/거절하는 것을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/components/dialogs/SaveLinkDialog.tsx` around lines 246 - 249, 현재 SaveLinkDialog.tsx에서 요청 페이로드를 만들 때 tags를 selectedTags.join(',')로 직렬화하면 태그명에 콤마가 포함될 경우 분해 시 데이터가 깨집니다; 수정 방법은 페이로드에서 tags를 문자열이 아닌 배열으로 전달하도록 변경하거나(권장) 입력 단계에서 콤마를 금지/검증해 전송 전 문제가 없게 하는 것입니다 — 구체적으로 SaveLinkDialog 컴포넌트의 페이로드 생성 부분(현재 memberFolderId, note, tags가 설정되는 곳)에서 tags: selectedTags.join(',')를 tags: selectedTags로 교체하고, 서버/클라이언트 타입(예: API 호출 함수 및 처리 로직)이 string[]를 받도록 맞추거나 대안으로 selectedTags 값을 전송 전에 검증/이스케이프하여 콤마가 포함된 태그를 거부하거나 안전하게 인코딩하도록 구현하세요; Quick Save 경로도 동일하게 처리해 일관성 있게 반영하세요.src/main/resources/db/init_postgres.sql (3)
671-679: 🧹 Nitpick | 🔵 Trivial조회/필터 인덱스 추가 검토 권장.
view_count = 0(unreadOnly) 및created_at범위(savedTodayOnly),member_folder_id + member_saved_link_id정렬 등 새 검색 쿼리들이 추가되는데, 사용자별 큰 데이터셋에서는 다음 인덱스가 도움이 됩니다.
view_count = 0부분 인덱스:(created_by_member_id) WHERE view_count = 0 AND deleted_at IS NULL- 정렬 최적화:
(created_by_member_id, updated_at DESC, member_saved_link_id DESC) WHERE deleted_at IS NULL현재
idx_member_saved_link_created_at은 사용자 필터와 결합되지 않아 큰 테이블에서 비효율적일 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/resources/db/init_postgres.sql` around lines 671 - 679, The current set of indexes on member_saved_link (e.g., idx_member_saved_link_created_at) doesn't support the new queries that filter by created_by_member_id with view_count = 0 or that sort by member_folder_id/updated_at/member_saved_link_id; add two focused partial/composite indexes: 1) a partial index on (created_by_member_id) with WHERE view_count = 0 AND deleted_at IS NULL to accelerate unreadOnly queries, and 2) a composite index on (created_by_member_id, updated_at DESC, member_saved_link_id DESC) WITH WHERE deleted_at IS NULL to serve savedTodayOnly and sort-by-member-folder queries (replace or drop the standalone idx_member_saved_link_created_at if redundant). Ensure the new indexes reference the table member_saved_link and are created with IF NOT EXISTS semantics consistent with the existing DDL.
96-115:⚠️ Potential issue | 🔴 Critical기존 DB 마이그레이션 누락:
folder_type컬럼 및 CHECK 제약 idempotent ALTER가 필요합니다.
CREATE TABLE IF NOT EXISTS는 테이블이 이미 존재하면 새 컬럼/제약을 적용하지 않습니다. 따라서 기존 DB에는folder_type컬럼과ck_member_folder_typeCHECK 제약이 추가되지 않아,MemberFolder엔티티(@Column(name = "folder_type", nullable = false))나getOrCreateUnorganizedFolderId코드 경로에서 컬럼 누락 오류가 발생합니다.view_count/last_viewed_at와 동일하게 idempotentALTER를 추가해 주세요.🛠️ 제안 마이그레이션
+-- (idempotent) 기존 DB에 대해 folder_type 컬럼/제약 추가 +ALTER TABLE "member_folder" ADD COLUMN IF NOT EXISTS "folder_type" varchar(20) DEFAULT 'CUSTOM' NOT NULL; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ck_member_folder_type' + ) THEN + ALTER TABLE "member_folder" + ADD CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED')); + END IF; +END $$;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/resources/db/init_postgres.sql` around lines 96 - 115, The migration is missing idempotent ALTERs to add the folder_type column and its CHECK constraint to existing member_folder tables; update the SQL to include ALTER TABLE "member_folder" ADD COLUMN IF NOT EXISTS "folder_type" varchar(20) DEFAULT 'CUSTOM' NOT NULL and add the ck_member_folder_type constraint only if it doesn’t exist (use a conditional DO/PLPGSQL check against pg_constraint or add constraint name guard) so existing DBs gain the column and CHECK without error; reference the "member_folder" table, the "folder_type" column, the ck_member_folder_type constraint, and ensure this fixes errors seen by the MemberFolder entity and getOrCreateUnorganizedFolderId code paths.
391-420:⚠️ Potential issue | 🟠 Major
ck_member_saved_link_view_count_nonnegCHECK 및 FK도 기존 DB에는 적용되지 않습니다.
view_count/last_viewed_at컬럼은 라인 419-420의ALTER ... ADD COLUMN IF NOT EXISTS로 보강되지만, 라인 412의ck_member_saved_link_view_count_nonnegCHECK 제약은 기존 테이블(이미 생성된 DB)에는 추가되지 않습니다. 또한 라인 414-415에 형식이 변경된 FK 제약도 기존 DB에는 영향을 주지 않습니다. idempotent 보강이 필요합니다.🛠️ 제안 마이그레이션
ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL; ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "last_viewed_at" timestamptz; +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'ck_member_saved_link_view_count_nonneg' + ) THEN + ALTER TABLE "member_saved_link" + ADD CONSTRAINT ck_member_saved_link_view_count_nonneg CHECK (view_count >= 0); + END IF; +END $$;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main/resources/db/init_postgres.sql` around lines 391 - 420, The existing ALTER statements add columns but do not add the CHECK/FK constraints to pre-existing DBs; add idempotent checks that create the missing constraints (ck_member_saved_link_view_count_nonneg, ck_member_saved_link_category_score, ck_member_saved_link_category_source and the FK constraints fk_member_saved_link_link_id, fk_member_saved_link_link_enrichment_id, fk_member_saved_link_member_folder_id) only if they do not already exist: implement a DO $$ BEGIN ... END$$ block (or equivalent) that queries pg_constraint/pg_class/pg_namespace to test for each constraint name and runs ALTER TABLE "member_saved_link" ADD CONSTRAINT ... when absent, ensuring the same constraint definitions as in the CREATE TABLE block so existing databases get the CHECKs and FKs applied idempotently.frontend/src/components/my-links/RightPanel.tsx (1)
230-237: 🧹 Nitpick | 🔵 Trivial클릭 시
recordViewMutation이 실패하면 사용자는 알 수 없습니다.
onOpenLink?.(data)직후window.open이 실행되어 fire-and-forget 패턴이지만, 네트워크 단절·서버 오류로 mutation이 실패하면 view_count/last_viewed_at이 업데이트되지 않은 채 사용자는 정상 동작으로 인지합니다. 무한스크롤 캐시 업데이트가 onSuccess에서 일어나므로 실패 시 read-state UI도 갱신되지 않습니다. 실패 시 조용히 재시도하거나 토스트 등 최소한의 피드백을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/components/my-links/RightPanel.tsx` around lines 230 - 237, The click handler in RightPanel calls onOpenLink and then immediately window.open, so failures from recordViewMutation (the view-recording mutation invoked elsewhere) are silent and the infinite-scroll cache onSuccess won't run; change the flow in the onClick handler (the branch using isBulkEditMode/onToggleSelect/onOpenLink/window.open) to invoke the recordViewMutation first and await its result (or at least attach .then/.catch) before opening the link via window.open, and on error show a toast or enqueue a retry instead of silently proceeding; ensure you still call onToggleSelect in bulk mode and preserve onSuccess cache updates tied to recordViewMutation.frontend/src/components/my-links/FolderCard.tsx (1)
179-195: 🧹 Nitpick | 🔵 Trivial시스템 폴더 비활성화 버튼에 a11y 보강을 권장합니다.
disabled속성으로 클릭은 잘 차단되지만, 시스템(미분류) 폴더에서 Move/Delete가 왜 막혔는지에 대한 안내가 시각/스크린리더 사용자에게 전달되지 않습니다.title/aria-label로 사유를 노출하면 UX가 개선됩니다.♻️ 제안 변경
<button onClick={(e) => handleMenuItemClick('move', e)} disabled={isSystemFolder} + title={isSystemFolder ? '미분류 폴더는 이동할 수 없습니다.' : undefined} + aria-label={isSystemFolder ? '이동 불가 (미분류 폴더)' : 'Move'} className="w-full text-left ..." > ... <button onClick={(e) => handleMenuItemClick('delete', e)} disabled={isSystemFolder} + title={isSystemFolder ? '미분류 폴더는 삭제할 수 없습니다.' : undefined} + aria-label={isSystemFolder ? '삭제 불가 (미분류 폴더)' : 'Delete'} className="w-full text-left ..." >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@frontend/src/components/my-links/FolderCard.tsx` around lines 179 - 195, The Move/Delete buttons (handlers: handleMenuItemClick, prop/flag: isSystemFolder) disable interaction for system folders but lack accessible explanation; update the button markup to, when isSystemFolder is true, include a descriptive title and aria-label (or aria-describedby to a hidden explanatory element) such as "Unavailable for system folder" and add aria-disabled="true" so screen readers are informed while preserving the disabled attribute; ensure the explanatory text is programmatically associated (aria-describedby) or included in the label for both the Move and Delete buttons.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/Plan/03`. my-links_search_feature_plan.md:
- Around line 365-372: Add an empty line before and after the fenced code block
that contains handleFolderClick (the ```typescript ... ``` block) so the
markdown has a blank line separating the list item and the code fence; update
the markdown around the handleFolderClick snippet to insert one blank line above
the opening ```typescript and one blank line below the closing ``` to resolve
MD031 warnings and ensure proper rendering.
- Line 390: The table row for `SEARCH_VIEW_FILTER_CHANGE` breaks because the
inline code span contains pipe characters which the Markdown table parser treats
as cell separators; fix by replacing the inline backticked fragment `전체 | 폴더 |
링크` with a form that prevents table parsing (e.g. use an HTML code tag: <code>전체
| 폴더 | 링크</code> or replace the pipes with the HTML entity |), ensuring the
row remains three columns and the header aligns correctly.
In `@docs/Plan/04`. unorganized_folder_introduction_plan.md:
- Around line 260-264: Add a blank line before and after the fenced code block
containing the targetFolderId assignment (the ```tsx ... ``` block) to satisfy
MD031, and normalize the ordered lists in the ranges called out (lines ~283-315)
so numbering is consistent: either restart each step-list at 1 (use explicit
"1." for each new subsection) or convert the section headers between them into
regular paragraphs to maintain a single continuing ordered list (addresses
MD029). Ensure you apply the same chosen approach across all affected step lists
for consistency.
- Around line 178-188: Update the document to consistently use the folder_type
enum approach instead of is_system: change the mention at the
MemberFolderResponses addition to state a folderType field (matching the code
block), replace the index name uq_member_folder_owner_system with
uq_member_folder_owner_unorganized, update the repository call
findByOwnerMemberIdAndIsSystemTrue to
findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED), change
references to MemberFolderResponses.isSystem to
MemberFolderResponses.folderType, replace the sentence about identifying system
folders by is_system with identifying unorganized folders by
folder_type='UNORGANIZED', and correct the field type declaration note to use
the FolderType enum (folder.domain.FolderType) instead of String.
In `@frontend/src/app/globals.css`:
- Around line 168-170: The rule applying cursor-pointer to all buttons causes
disabled controls to show a pointer cursor; change the selector from "button,
[role=\"button\"]" to exclude disabled states (e.g.,
button:not(:disabled):not([aria-disabled=\"true\"]),
[role=\"button\"]:not(:disabled):not([aria-disabled=\"true\"])) so only
interactive buttons get cursor-pointer; update the CSS rule where "button,
[role=\"button\"]" is defined and keep the same `@apply` cursor-pointer
declaration under the new selector.
In `@frontend/src/app/my-links/page.tsx`:
- Around line 187-189: The search input currently calls setSearchQuery on every
keystroke (value={searchQuery} onChange={(e) =>
setSearchQuery(e.target.value)}), causing immediate store updates and backend
calls; add a 250ms debounce so updates to the store or the actual search trigger
are delayed—either debounce the onChange handler in the MyLinks page component
or implement a debounced action in the store (matching RightPanel’s ~250ms
behavior) so setSearchQuery is invoked only after the user pauses typing, and
ensure any existing subscribers use the debounced value for triggering backend
searches.
- Around line 303-310: The current inline sort on searchResultFolders runs every
render; memoize and deduplicate the sort logic by extracting it into a reusable
helper or memoized value: create a function (e.g., sortFoldersByUnorganized)
that implements the FOLDER_TYPE.UNORGANIZED comparison and replace both inline
sort blocks with a useMemo hook that returns the sorted array (e.g., const
sortedSearchResultFolders = useMemo(() =>
sortFoldersByUnorganized(searchResultFolders), [searchResultFolders]) ) and pass
sortedSearchResultFolders into the map that renders <FolderCard
key={folder.memberFolderId} folder={folder} /> so the sort only runs when
searchResultFolders changes.
- Around line 416-425: The UNORGANIZED-first comparison used in the inline sort
where FolderCard is rendered is duplicated elsewhere; extract that logic into a
single helper (e.g., sortFoldersWithUnorganizedFirst) that accepts two folder
items and the folderSort key, move the comparison of FOLDER_TYPE.UNORGANIZED and
the folderSort branches (checking folderSort === 'recently' and 'a-z' and
default) into that helper, and replace the inline sort call with
.sort(sortFoldersWithUnorganizedFirst) so both occurrences reuse the same
function and remain consistent.
- Around line 84-93: The page triggers excessive search requests because
searchQuery is used immediately in bookmarkParams for useBookmarks; add a
debouncedSearchQuery (250ms like RightPanel) and use that for
bookmarkParams.query (or apply a store-level debounce) so rapid keystrokes don’t
change the query key on every keystroke. Also prevent the unorganized race by
tightening the fetch enablement: change shouldFetchBookmarks (or the enabled
option passed to useBookmarks) to include the condition !unorganizedFilter ||
unorganizedFolderId != null and only set bookmarkParams.folderId when
unorganizedFolderId is defined so you don’t fetch all folders while the folder
list is still loading.
- Around line 64-70: Split the combined effect into two: (1) an effect that
depends only on searchScope which, when searchScope === 'all' || 'links', calls
setSelectedFolderId(null) to clear the folder selection once on scope change
(use the existing setSelectedFolderId reference); (2) an effect that depends on
searchQuery which updates the store with
useLinkStore.getState().setSearchQuery(searchQuery) but first checks the current
store value (useLinkStore.getState().searchQuery) and only calls setSearchQuery
if it differs to avoid redundant updates/re-renders; keep references to
useEffect, searchScope, searchQuery, useLinkStore.getState().setSearchQuery and
setSelectedFolderId to locate the code.
In `@frontend/src/components/dialogs/SaveLinkDialog.tsx`:
- Around line 281-287: The fallbackFolderId can become null when useFolders
hasn't returned data yet (folders is undefined), so either prevent the action by
disabling the Save button while folders are loading or guard in handleSave:
check fallbackFolderId after computing it (using selectedFolderId, folders,
FOLDER_TYPE.UNORGANIZED) and if it's null, show a toast/error and return early
instead of calling saveBookmark; alternatively update the Save button's disabled
prop to include the folders loading flag from useFolders so save cannot be
clicked until folders are ready.
In `@frontend/src/components/my-links/FolderCard.tsx`:
- Around line 79-96: The click handler handleCardClick currently deselects when
currentSelectedFolderId === folder.memberFolderId but still runs
setLinkSearchQuery(searchQuery) and useUIStore.getState().toggleRightPanel(true)
unconditionally; change the logic so that only the "select" branch (the else
branch where you call setSelectedFolderId(folder.memberFolderId)) performs
setLinkSearchQuery(searchQuery) and toggleRightPanel(true), while the "deselect"
branch should only call setSelectedFolderId(null) and not open the panel or sync
the search query; update handleCardClick accordingly to keep deselect behavior
inert and selection behavior performing query sync and panel open.
In `@frontend/src/components/my-links/RightPanel.tsx`:
- Around line 642-656: emptyLinksMessage currently prioritizes filter-only
branches so when linkSearchQuery is present but combined with
unreadFilter/savedTodayFilter/unorganizedFilter you get "No unread links" etc.,
losing the search context; update the logic in the emptyLinksMessage computation
(symbol: emptyLinksMessage) to either (1) check linkSearchQuery.trim() first and
return a message that includes the search context (e.g., "No links match your
search" or "No links match your search and active filters"), or (2) construct a
dynamic message by reading the active filter flags (unreadFilter,
savedTodayFilter, unorganizedFilter, selectedTags) and linkSearchQuery and
concatenating them into a single user-facing string so search+filter
combinations are represented consistently.
- Around line 565-580: When unorganizedFilter is true but unorganizedFolderId is
undefined the component currently falls back to selectedFolderId and fires the
wrong query; fix by computing an effectiveFolderId and disabling the query until
the unorganizedFolderId is known: create a variable (e.g. effectiveFolderId)
that is unorganizedFilter ? unorganizedFolderId : selectedFolderId, and call
useInfiniteBookmarks with that effectiveFolderId and an enabled flag (e.g.
enabled: !unorganizedFilter || unorganizedFolderId !== undefined) so the hook
does not run while folder id for the unorganized filter is loading.
In `@frontend/src/components/ui/SortDropdown.tsx`:
- Around line 69-94: The inline arrow passed as toggle to renderTrigger is
recreated each render causing unnecessary re-renders; stabilize it by creating a
memoized callback using React's useCallback (e.g., const toggle = useCallback(()
=> hasOptions && setIsOpen(prev => !prev), [hasOptions, setIsOpen])) and pass
that toggle to renderTrigger (and also reuse it for the button onClick) so the
toggle function identity remains stable across renders while still referencing
selectedOption/isOpen via props or state.
- Line 34: The SortDropdown component's align prop default was changed from
'right' to 'left', which alters placement for callers that don't pass align;
either restore the original default to 'right' in the SortDropdown component
(reset the align default value/prop type to 'right') or, if left alignment is
intended, update each call site (RightPanel.tsx and the two page.tsx usages) to
explicitly pass align="right" and verify rendering; locate SortDropdown and its
align prop to apply the chosen fix so existing layouts remain unchanged.
In `@frontend/src/lib/api/bookmarkApi.ts`:
- Around line 80-108: The cache patch in updateReadStateInResponse currently
sets lastViewedAt using the client clock (new Date().toISOString()), which can
diverge from the server's now(); change the flow to use server-provided
timestamps instead: update useRecordBookmarkView to return { viewCount: number;
lastViewedAt: string } and, inside updateReadStateInResponse, apply those
returned values (viewCount and lastViewedAt) when bookmark.bookmarkId matches
instead of generating a local ISO timestamp; alternatively, if keeping
optimistic updates, use the server response to overwrite the optimistic client
value as soon as the mutation completes so the cache always reflects server
time.
- Around line 162-176: Offset-based next-page calculation in
useInfiniteBookmarks (getNextPageParam using allPages.length * limit) can lead
to duplicate/missing items if bookmarks change concurrently; migrate to keyset
pagination by returning a cursor from fetchBookmarks (e.g., lastSeen
member_saved_link_id or (updated_at, member_saved_link_id)) and have
getNextPageParam return that cursor (e.g., lastPage.nextCursor) instead of
computing offset, update fetchBookmarks and useInfiniteBookmarks to
accept/propagate the cursor, and adjust backend to return nextCursor; also be
aware that useRecordBookmarkView's invalidateQueries in v5 will refetch active
infinite-query pages—if you want to avoid refetching deep pages, use more
targeted invalidation (invalidate only the first page's queryKey or update cache
via queryClient.setQueryData) or pass a refetch option to limit which pages are
re-fetched.
In `@frontend/src/lib/dateUtils.ts`:
- Line 6: The createdDate constructed from createdAtStr can become Invalid Date;
after const createdDate = new Date(createdAtStr) in dateUtils.ts validate it
(e.g., check Number.isNaN(createdDate.getTime()) / isNaN(createdDate.getTime()))
and handle the case (throw a clear Error, return null/undefined, or fall back to
a default) so downstream comparisons in functions that use createdDate don't
silently fail; update the code paths that reference createdDate to expect the
chosen failure mode.
- Around line 6-12: isCreatedToday currently compares createdAtStr to today's
date using local-time getters which can misclassify dates when server and client
timezones differ; update isCreatedToday to perform the comparison in UTC by
using Date.prototype.getUTCFullYear(), getUTCMonth(), and getUTCDate() for both
the parsed createdDate and the current date (or explicitly convert both dates to
UTC before comparing) so the function reliably compares calendar day in UTC
rather than local time.
In `@frontend/src/lib/store/folderStore.ts`:
- Around line 5-16: The union type 'folders' | 'all' | 'links' is duplicated in
the FolderStore interface; create a single type alias (e.g., SearchScope) and
replace the repeated literal unions in the property searchScope and the setter
signature setSearchScope to use that alias so future changes are centralized and
consistent (update any other occurrences of the same literal union if present).
- Around line 31-51: The three toggle handlers (toggleSavedTodayFilter,
toggleUnreadFilter, toggleUnorganizedFilter) duplicate the same logic clearing
selectedFolderId when a filter becomes true; extract a shared helper (e.g.,
toggleFilter) that accepts the current state and a filter key (typed against
FolderStore's filter keys) and returns the updated partial state ({ [key]:
nextValue, selectedFolderId: nextValue ? null : state.selectedFolderId }), then
have each toggleX call set(state => toggleFilter(state, 'savedTodayFilter' |
'unreadFilter' | 'unorganizedFilter' as appropriate)); ensure the helper's
generic typing matches FolderStore keys so the returned Pick type is correct and
replace the three in-place bodies with calls to this helper.
In `@src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java`:
- Around line 31-37: The new DAO methods incrementViewCount and
deleteBookmarkTags use (bookmarkId, memberId) parameter order which is
inconsistent with other DAO methods; change their signatures to (memberId, Long
bookmarkId) and update their `@Param` annotations accordingly, then update all
callers (e.g., BookmarkServiceImpl and any service/tests that invoke
incrementViewCount or deleteBookmarkTags) to pass memberId first to maintain
interface consistency and avoid confusion.
In `@src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java`:
- Around line 106-117: The selectBookmarkList method in BookmarkServiceImpl
makes three DAO calls that should be executed in a single read-only transaction
to provide a consistent snapshot; add the `@Transactional`(readOnly = true)
annotation to the selectBookmarkList(BookmarkSearchCommand) method (class:
BookmarkServiceImpl, method: selectBookmarkList) so selectBookmarkList,
selectMatchingFolderIds and countBookmarkList run under one read-only
transaction and avoid inconsistent paging/counts under concurrent writes.
- Around line 167-174: The current recordView method calls
bookmarkDao.incrementViewCount(bookmarkId, memberId) and treats affected==0 as
BOOKMARK_NOT_FOUND, which conflates missing bookmark, soft-deleted bookmark, and
ownership violations; change recordView to first call a read method (e.g.,
bookmarkDao.selectBookmark(memberId, bookmarkId) or a new
selectBookmarkById(bookmarkId)) to determine existence, ownership, and
deleted_at state, then: if the bookmark doesn't exist or is soft-deleted throw
BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND), if it exists but
memberId is not the owner throw
BookmarkException.of(BookmarkErrorCode.FORBIDDEN) (or appropriate 403 code),
otherwise call incrementViewCount; keep exception types as
BookmarkException/BookmarkErrorCode to match existing handling.
In
`@src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java`:
- Around line 21-26: The BookmarkSearchCommand class relies on `@Builder.Default`
for limit/offset and callers might bypass the builder (causing defaults to be
ignored); ensure the class cannot be instantiated directly with nulls by
removing/avoiding public all-args construction and providing/forcing a single
creation path—either make the constructor private and add a public static
factory (e.g., BookmarkSearchCommand.of(...) or from(BookmarkRequests.SearchDto)
) or document/enforce using BookmarkSearchCommand.builder() in all call sites
(including BookmarkRequests.SearchDto#toCommand); update usages to call the
builder or the new factory so unreadOnly, savedTodayOnly, limit and offset
always get proper defaults.
In `@src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java`:
- Around line 29-30: The DAO method
findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName has a misleading
parameter name folderName that disagrees with
existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName's normalizedFolderName
contract; rename the parameter in
findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName to
normalizedFolderName so the method signature clearly documents that callers must
pass the normalized value (update the method declaration and any implementing
interfaces/uses accordingly to keep names consistent).
In `@src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java`:
- Around line 49-52: The markAsUnorganized() method should guard against
repeated calls by checking the current folderType; inside
MemberFolder.markAsUnorganized() add a conditional that if this.folderType ==
FolderType.UNORGANIZED either return immediately (preferred for idempotence) or
throw an IllegalStateException with a clear message (if you want to enforce
single-use), and update the method javadoc to document the chosen behavior so
callers know whether repeated calls are no-ops or errors.
In `@src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java`:
- Around line 107-113: listRootFolders was changed to a read-write transaction
because it always calls getOrCreateUnorganizedFolderId, causing side-effecting
writes on a simple read; change the flow so listRootFolders remains read-only
(annotate with `@Transactional`(readOnly = true)) and only when the "unorganized"
folder is missing invoke a separate write transaction (move creation logic into
a new method annotated with `@Transactional`(propagation =
Propagation.REQUIRES_NEW) or call a service method that does so) — keep
validateOwner and the read call
memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull in the
read-only method and delegate folder creation to the new requires-new method
that performs getOrCreateUnorganizedFolderId behavior.
- Around line 233-240: The save() call in MemberFolderServiceImpl's Step 2 path
(when sameName.isPresent()) can raise DataIntegrityViolationException under race
with Step 3; wrap the existingFolder.markAsUnorganized() +
memberFolderJpaRepository.save(existingFolder) in the same
DataIntegrityViolationException handling used for Step 3 so the exception is
caught and handled consistently (use the same catch logic currently used around
the Step 3 insert), referencing memberFolderJpaRepository.save,
markAsUnorganized, UNORGANIZED_FOLDER_NAME and DataIntegrityViolationException
to locate and mirror the protection.
In `@src/main/resources/mapper/bookmark-mapper.xml`:
- Around line 170-172: The current MyBatis XML uses loose checks like <if
test="unreadOnly"> and <if test="savedTodayOnly"> which treat null and false the
same and can allow nulls from other call sites; update these conditions to
explicit boolean checks (e.g., <if test="unreadOnly == true"> and <if
test="savedTodayOnly == true">) so the mapper only activates when the Boolean is
true, and validate usages of BookmarkSearchCommand to ensure it supplies
non-null Booleans or rely on these explicit checks to guard against nulls.
- Around line 181-191: The incrementViewCount update currently sets updated_at
and updated_by_member_id which corrupts the "Newest" ordering used by
selectBookmarkList (msl.updated_at DESC) and overwrites audit data; modify the
update statement with id="incrementViewCount" so it only increments view_count
and sets last_viewed_at (and any necessary where clauses) — remove updated_at =
now() and updated_by_member_id = #{memberId} to preserve modification
timestamps/audit and keep selectBookmarkList semantics correct.
---
Outside diff comments:
In `@frontend/src/components/dialogs/SaveLinkDialog.tsx`:
- Around line 246-249: 현재 SaveLinkDialog.tsx에서 요청 페이로드를 만들 때 tags를
selectedTags.join(',')로 직렬화하면 태그명에 콤마가 포함될 경우 분해 시 데이터가 깨집니다; 수정 방법은 페이로드에서
tags를 문자열이 아닌 배열으로 전달하도록 변경하거나(권장) 입력 단계에서 콤마를 금지/검증해 전송 전 문제가 없게 하는 것입니다 —
구체적으로 SaveLinkDialog 컴포넌트의 페이로드 생성 부분(현재 memberFolderId, note, tags가 설정되는 곳)에서
tags: selectedTags.join(',')를 tags: selectedTags로 교체하고, 서버/클라이언트 타입(예: API 호출 함수
및 처리 로직)이 string[]를 받도록 맞추거나 대안으로 selectedTags 값을 전송 전에 검증/이스케이프하여 콤마가 포함된 태그를
거부하거나 안전하게 인코딩하도록 구현하세요; Quick Save 경로도 동일하게 처리해 일관성 있게 반영하세요.
In `@frontend/src/components/my-links/FolderCard.tsx`:
- Around line 179-195: The Move/Delete buttons (handlers: handleMenuItemClick,
prop/flag: isSystemFolder) disable interaction for system folders but lack
accessible explanation; update the button markup to, when isSystemFolder is
true, include a descriptive title and aria-label (or aria-describedby to a
hidden explanatory element) such as "Unavailable for system folder" and add
aria-disabled="true" so screen readers are informed while preserving the
disabled attribute; ensure the explanatory text is programmatically associated
(aria-describedby) or included in the label for both the Move and Delete
buttons.
In `@frontend/src/components/my-links/RightPanel.tsx`:
- Around line 230-237: The click handler in RightPanel calls onOpenLink and then
immediately window.open, so failures from recordViewMutation (the view-recording
mutation invoked elsewhere) are silent and the infinite-scroll cache onSuccess
won't run; change the flow in the onClick handler (the branch using
isBulkEditMode/onToggleSelect/onOpenLink/window.open) to invoke the
recordViewMutation first and await its result (or at least attach .then/.catch)
before opening the link via window.open, and on error show a toast or enqueue a
retry instead of silently proceeding; ensure you still call onToggleSelect in
bulk mode and preserve onSuccess cache updates tied to recordViewMutation.
In `@src/main/resources/db/init_postgres.sql`:
- Around line 671-679: The current set of indexes on member_saved_link (e.g.,
idx_member_saved_link_created_at) doesn't support the new queries that filter by
created_by_member_id with view_count = 0 or that sort by
member_folder_id/updated_at/member_saved_link_id; add two focused
partial/composite indexes: 1) a partial index on (created_by_member_id) with
WHERE view_count = 0 AND deleted_at IS NULL to accelerate unreadOnly queries,
and 2) a composite index on (created_by_member_id, updated_at DESC,
member_saved_link_id DESC) WITH WHERE deleted_at IS NULL to serve savedTodayOnly
and sort-by-member-folder queries (replace or drop the standalone
idx_member_saved_link_created_at if redundant). Ensure the new indexes reference
the table member_saved_link and are created with IF NOT EXISTS semantics
consistent with the existing DDL.
- Around line 96-115: The migration is missing idempotent ALTERs to add the
folder_type column and its CHECK constraint to existing member_folder tables;
update the SQL to include ALTER TABLE "member_folder" ADD COLUMN IF NOT EXISTS
"folder_type" varchar(20) DEFAULT 'CUSTOM' NOT NULL and add the
ck_member_folder_type constraint only if it doesn’t exist (use a conditional
DO/PLPGSQL check against pg_constraint or add constraint name guard) so existing
DBs gain the column and CHECK without error; reference the "member_folder"
table, the "folder_type" column, the ck_member_folder_type constraint, and
ensure this fixes errors seen by the MemberFolder entity and
getOrCreateUnorganizedFolderId code paths.
- Around line 391-420: The existing ALTER statements add columns but do not add
the CHECK/FK constraints to pre-existing DBs; add idempotent checks that create
the missing constraints (ck_member_saved_link_view_count_nonneg,
ck_member_saved_link_category_score, ck_member_saved_link_category_source and
the FK constraints fk_member_saved_link_link_id,
fk_member_saved_link_link_enrichment_id, fk_member_saved_link_member_folder_id)
only if they do not already exist: implement a DO $$ BEGIN ... END$$ block (or
equivalent) that queries pg_constraint/pg_class/pg_namespace to test for each
constraint name and runs ALTER TABLE "member_saved_link" ADD CONSTRAINT ... when
absent, ensuring the same constraint definitions as in the CREATE TABLE block so
existing databases get the CHECKs and FKs applied idempotently.
🪄 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: Repository UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9c26487f-b5b2-47f5-82c7-c44bd7a5dde8
📒 Files selected for processing (34)
docs/Plan/03. my-links_search_feature_plan.mddocs/Plan/04. unorganized_folder_introduction_plan.mdfrontend/src/app/globals.cssfrontend/src/app/my-links/page.tsxfrontend/src/components/dialogs/SaveLinkDialog.tsxfrontend/src/components/my-links/FolderCard.tsxfrontend/src/components/my-links/RightPanel.tsxfrontend/src/components/ui/SortDropdown.tsxfrontend/src/lib/api/bookmarkApi.tsfrontend/src/lib/dateUtils.tsfrontend/src/lib/store/folderStore.tsfrontend/src/lib/store/linkStore.tsfrontend/src/lib/types/bookmark.tsfrontend/src/lib/types/folder.tssrc/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.javasrc/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.javasrc/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.javasrc/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.javasrc/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.javasrc/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.javasrc/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.javasrc/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.javasrc/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.javasrc/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.javasrc/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.javasrc/main/java/com/web/SearchWeb/folder/domain/FolderType.javasrc/main/java/com/web/SearchWeb/folder/domain/MemberFolder.javasrc/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.javasrc/main/java/com/web/SearchWeb/folder/service/MemberFolderService.javasrc/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.javasrc/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.javasrc/main/java/com/web/SearchWeb/mypage/controller/MyPageController.javasrc/main/resources/db/init_postgres.sqlsrc/main/resources/mapper/bookmark-mapper.xml
| - 좌측 전체검색의 Folders 섹션에서 폴더를 클릭 시: | ||
| ```typescript | ||
| handleFolderClick(folderId) { | ||
| bookmarkStore.setSelectedFolder(folderId); | ||
| bookmarkStore.setLinkSearchQuery(currentSearchQuery); // 현재 검색어 자동 입력 | ||
| } | ||
| ``` | ||
| - 우측 패널 링크 검색창에는 `linkSearchQuery` 상태가 반영되어 미리 입력된 상태로 표시 |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
코드 펜스 위아래에 빈 줄을 추가해 주세요.
handleFolderClick 코드 블록 앞뒤로 빈 줄이 없어 markdownlint MD031 경고가 발생합니다. 일부 렌더러에서 리스트 항목과 펜스가 올바르게 분리되지 않을 수 있습니다.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 366-366: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
[warning] 371-371: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/Plan/03`. my-links_search_feature_plan.md around lines 365 - 372, Add an
empty line before and after the fenced code block that contains
handleFolderClick (the ```typescript ... ``` block) so the markdown has a blank
line separating the list item and the code fence; update the markdown around the
handleFolderClick snippet to insert one blank line above the opening
```typescript and one blank line below the closing ``` to resolve MD031 warnings
and ensure proper rendering.
| | `SEARCH_RESULT_CLICK_LINK` | 검색 결과에서 링크 클릭 | P0 | | ||
| | `SEARCH_CONTEXT_AUTO_INPUT` | 폴더 클릭 → 우측 패널 검색어 자동 입력 | P0 | | ||
| | `SEARCH_RESULT_CLICK_INCLUDED_FOLDER` | 링크 카드 내부 포함 폴더 클릭 | P1 | | ||
| | `SEARCH_VIEW_FILTER_CHANGE` | `전체 | 폴더 | 링크` 보기 전환 | P1 | |
There was a problem hiding this comment.
테이블 행이 깨져 렌더링됩니다.
인라인 코드 블록 내부의 | 문자는 마크다운 테이블 파서에서 셀 구분자로 처리되어, 이 행이 5개 셀로 인식되고 헤더(3열)와 어긋납니다. (markdownlint MD056에서도 동일하게 지적)
♻️ 제안 변경
-| `SEARCH_VIEW_FILTER_CHANGE` | `전체 | 폴더 | 링크` 보기 전환 | P1 |
+| `SEARCH_VIEW_FILTER_CHANGE` | `전체 \| 폴더 \| 링크` 보기 전환 | P1 |📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | `SEARCH_VIEW_FILTER_CHANGE` | `전체 | 폴더 | 링크` 보기 전환 | P1 | | |
| | `SEARCH_VIEW_FILTER_CHANGE` | `전체 \| 폴더 \| 링크` 보기 전환 | P1 | |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 390-390: Table column count
Expected: 3; Actual: 5; Too many cells, extra data will be missing
(MD056, table-column-count)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/Plan/03`. my-links_search_feature_plan.md at line 390, The table row for
`SEARCH_VIEW_FILTER_CHANGE` breaks because the inline code span contains pipe
characters which the Markdown table parser treats as cell separators; fix by
replacing the inline backticked fragment `전체 | 폴더 | 링크` with a form that
prevents table parsing (e.g. use an HTML code tag: <code>전체 | 폴더 | 링크</code> or
replace the pipes with the HTML entity |), ensuring the row remains three
columns and the header aligns correctly.
| `MemberFolderController` 자체에는 변경이 없다 (서비스 가드가 도메인 규칙을 책임짐). 단, 응답 DTO `MemberFolderResponses` 에 `isSystem` 필드를 추가하여 프론트에 노출한다. | ||
|
|
||
| ```java | ||
| public class MemberFolderResponses { | ||
| public Long memberFolderId; | ||
| public Long ownerMemberId; | ||
| public Long parentFolderId; | ||
| public String folderName; | ||
| public String description; | ||
| public String folderType; // ← 추가 | ||
| public static MemberFolderResponses from(MemberFolder f) { ... } |
There was a problem hiding this comment.
문서 내부 용어가 is_system/folder_type 사이에서 혼재
이 계획서는 "시스템 폴더 식별"을 is_system 불리언으로 시작했다가 후반부에서 folder_type 열거형으로 옮겨갔지만, 문서 내부 표현이 일관되게 갱신되지 않았습니다. 실제 구현(MemberFolder.folderType, FolderType.UNORGANIZED, 인덱스 uq_member_folder_owner_unorganized, 응답 DTO의 folderType)을 기준으로 다음을 정정해 주세요.
- 라인 178: "MemberFolderResponses 에
isSystem필드를 추가" →folderType필드(라인 187 코드 블록과 일관) - 라인 213:
uq_member_folder_owner_system→uq_member_folder_owner_unorganized - 라인 279:
findByOwnerMemberIdAndIsSystemTrue→findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED) - 라인 300:
MemberFolderResponses.isSystem→folderType - 라인 326: "시스템 폴더는
is_system으로만 식별" →folder_type='UNORGANIZED'로 식별 - 라인 110:
private String folderType;→ 실제 구현은FolderType열거형(folder.domain.FolderType)
Also applies to: 213-213, 279-279, 300-300, 326-326
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/Plan/04`. unorganized_folder_introduction_plan.md around lines 178 -
188, Update the document to consistently use the folder_type enum approach
instead of is_system: change the mention at the MemberFolderResponses addition
to state a folderType field (matching the code block), replace the index name
uq_member_folder_owner_system with uq_member_folder_owner_unorganized, update
the repository call findByOwnerMemberIdAndIsSystemTrue to
findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED), change
references to MemberFolderResponses.isSystem to
MemberFolderResponses.folderType, replace the sentence about identifying system
folders by is_system with identifying unorganized folders by
folder_type='UNORGANIZED', and correct the field type declaration note to use
the FolderType enum (folder.domain.FolderType) instead of String.
| ```tsx | ||
| const targetFolderId = selectedFolderId | ||
| ?? folders?.find(f => f.folderType === 'UNORGANIZED')?.memberFolderId | ||
| ?? null; | ||
| ``` |
There was a problem hiding this comment.
markdownlint 경고: 코드펜스 공백/순번 부여 정리
정적 분석에서 다음이 보고됩니다.
- MD031 (라인 260, 264): 코드펜스 위/아래에 빈 줄 누락.
- MD029 (라인 284-287, 295-296, 300-301, 305-308, 312-315): 단계별 순번이 1단계 안에서 4부터 시작되도록 적혀 있어 정렬 자동 번호와 어긋납니다.
각 단계별 리스트를 1부터 다시 시작하거나, 전체 순번을 한 흐름으로 유지하려면 단계 간 헤더를 일반 단락으로 바꾸는 방향 중 하나로 통일해 주세요.
Also applies to: 283-315
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 260-260: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
[warning] 264-264: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@docs/Plan/04`. unorganized_folder_introduction_plan.md around lines 260 -
264, Add a blank line before and after the fenced code block containing the
targetFolderId assignment (the ```tsx ... ``` block) to satisfy MD031, and
normalize the ordered lists in the ranges called out (lines ~283-315) so
numbering is consistent: either restart each step-list at 1 (use explicit "1."
for each new subsection) or convert the section headers between them into
regular paragraphs to maintain a single continuing ordered list (addresses
MD029). Ensure you apply the same chosen approach across all affected step lists
for consistency.
| button, [role="button"] { | ||
| @apply cursor-pointer; | ||
| } |
There was a problem hiding this comment.
비활성화된 버튼에도 포인터 커서가 적용됩니다.
button/[role="button"]에 무조건 cursor-pointer를 적용하면 disabled 또는 aria-disabled="true" 상태의 버튼에서도 포인터 커서가 표시되어, 클릭 가능한 요소처럼 보이는 UX 문제가 발생합니다. (예: SaveLinkDialog의 Save 버튼이 disabled 상태일 때)
♻️ 제안 변경
- button, [role="button"] {
- `@apply` cursor-pointer;
- }
+ button:not(:disabled):not([aria-disabled="true"]),
+ [role="button"]:not([aria-disabled="true"]) {
+ `@apply` cursor-pointer;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| button, [role="button"] { | |
| @apply cursor-pointer; | |
| } | |
| button:not(:disabled):not([aria-disabled="true"]), | |
| [role="button"]:not([aria-disabled="true"]) { | |
| `@apply` cursor-pointer; | |
| } |
🧰 Tools
🪛 Biome (2.4.12)
[error] 169-169: Tailwind-specific syntax is disabled.
(parse)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@frontend/src/app/globals.css` around lines 168 - 170, The rule applying
cursor-pointer to all buttons causes disabled controls to show a pointer cursor;
change the selector from "button, [role=\"button\"]" to exclude disabled states
(e.g., button:not(:disabled):not([aria-disabled=\"true\"]),
[role=\"button\"]:not(:disabled):not([aria-disabled=\"true\"])) so only
interactive buttons get cursor-pointer; update the CSS rule where "button,
[role=\"button\"]" is defined and keep the same `@apply` cursor-pointer
declaration under the new selector.
| /** 기존 폴더를 시스템 폴더(UNORGANIZED)로 마킹. 이름 충돌 흡수(absorb) 경로에서 사용. */ | ||
| public void markAsUnorganized() { | ||
| this.folderType = FolderType.UNORGANIZED; | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
markAsUnorganized() 멱등성 가드 추가를 검토하세요.
이미 UNORGANIZED인 폴더에 대해 다시 호출되어도 무해하지만, absorb 경로에서만 호출된다는 의도를 명확히 하기 위해 이미 시스템 폴더인 경우 빠르게 반환하거나 IllegalStateException을 던지는 가드가 회귀 방지에 도움이 됩니다. 선택 사항입니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java` around lines
49 - 52, The markAsUnorganized() method should guard against repeated calls by
checking the current folderType; inside MemberFolder.markAsUnorganized() add a
conditional that if this.folderType == FolderType.UNORGANIZED either return
immediately (preferred for idempotence) or throw an IllegalStateException with a
clear message (if you want to enforce single-use), and update the method javadoc
to document the chosen behavior so callers know whether repeated calls are
no-ops or errors.
| @Transactional | ||
| public List<MemberFolder> listRootFolders(Long memberId, Long ownerMemberId) { | ||
| validateOwner(memberId, ownerMemberId); | ||
| // 기존 회원 대상 lazy 백필: 조회 호출 시 미분류 폴더가 없으면 이 시점에 생성 | ||
| getOrCreateUnorganizedFolderId(ownerMemberId); | ||
| return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
listRootFolders가 쓰기 트랜잭션으로 격상 — 부작용을 가진 조회 API
@Transactional(readOnly=true) → @Transactional로 바뀌고 매 호출마다 getOrCreateUnorganizedFolderId가 호출됩니다. 정상 케이스에선 인덱스 룩업 1회로 끝나지만, 모든 폴더 목록 조회가 잠재적인 쓰기 트랜잭션이 되어 (1) 향후 read-replica 라우팅 도입 시 분기 대상이 되고 (2) 단순 조회 호출도 락 획득/롤백 비용을 부담합니다.
가능하면 빠른 경로(read-only 조회)로 존재 여부를 확인한 뒤 없을 때만 별도 트랜잭션 메서드(Propagation.REQUIRES_NEW 또는 분리된 호출)로 위임하는 패턴을 검토해 보세요. 현 시점에선 plan 문서(7.2)의 트레이드오프 결정과 부합하지만, 후속 단계에서 read-replica를 도입할 때 이 지점이 병목이 될 수 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java`
around lines 107 - 113, listRootFolders was changed to a read-write transaction
because it always calls getOrCreateUnorganizedFolderId, causing side-effecting
writes on a simple read; change the flow so listRootFolders remains read-only
(annotate with `@Transactional`(readOnly = true)) and only when the "unorganized"
folder is missing invoke a separate write transaction (move creation logic into
a new method annotated with `@Transactional`(propagation =
Propagation.REQUIRES_NEW) or call a service method that does so) — keep
validateOwner and the read call
memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull in the
read-only method and delegate folder creation to the new requires-new method
that performs getOrCreateUnorganizedFolderId behavior.
| // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환 | ||
| Optional<MemberFolder> sameName = memberFolderJpaRepository | ||
| .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME); | ||
| if (sameName.isPresent()) { | ||
| MemberFolder existingFolder = sameName.get(); | ||
| existingFolder.markAsUnorganized(); | ||
| return memberFolderJpaRepository.save(existingFolder).getMemberFolderId(); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# MemberFolder 엔티티의 soft-delete 처리 확인
fd -e java MemberFolder.java | xargs rg -n -C2 'SQLRestriction|@Where|deleted_at|deletedAt'
# JpaRepository에 정의된 finder 시그니처 확인
fd -e java MemberFolderJpaDao.java | xargs rg -n -C1 'findFirstByOwnerMemberIdAndFolderType|findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName'Repository: Searchweb-Dev/Searchweb-Back
Length of output: 454
🏁 Script executed:
# Find MemberFolder entity class
fd -e java | xargs grep -l "class MemberFolder" | head -5
# Get more context around the service implementation (lines 220-260)
fd -e java MemberFolderServiceImpl.java | xargs cat -n | sed -n '220,260p'Repository: Searchweb-Dev/Searchweb-Back
Length of output: 2535
🏁 Script executed:
# Check MemberFolder entity for soft-delete configuration
cat -n src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java | head -80
# Check JpaRepository for `@Query` annotations
rg -n '@Query' src/main/java/com/web/SearchWeb/folder/repository/MemberFolderJpaRepository.javaRepository: Searchweb-Dev/Searchweb-Back
Length of output: 2886
🏁 Script executed:
# Find JpaRepository file
fd -e java | xargs grep -l "MemberFolderJpaRepository" | grep -i repository
# Check if MemberFolder extends something or has soft-delete elsewhere
rg -n 'deleted_at|deletedAt|@Where|@SQLRestriction' src/main/java/com/web/SearchWeb/folder/domain/
# Check if there's more to MemberFolder.java after line 68
wc -l src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.javaRepository: Searchweb-Dev/Searchweb-Back
Length of output: 139
🏁 Script executed:
# Search for repository interface in folder package
fd -e java src/main/java/com/web/SearchWeb/folder/ | grep -i repository
# Check what's in the repository directory
ls -la src/main/java/com/web/SearchWeb/folder/repository/Repository: Searchweb-Dev/Searchweb-Back
Length of output: 632
🏁 Script executed:
# Correct fd usage
fd . 'src/main/java/com/web/SearchWeb/folder/' | grep -i repository
# Also check the folder structure
ls -la src/main/java/com/web/SearchWeb/folder/Repository: Searchweb-Dev/Searchweb-Back
Length of output: 464
🏁 Script executed:
# Check the dao directory
ls -la src/main/java/com/web/SearchWeb/folder/dao/
# Inspect the dao interface
cat -n src/main/java/com/web/SearchWeb/folder/dao/*.javaRepository: Searchweb-Dev/Searchweb-Back
Length of output: 1800
Step 2의 "미분류" 흡수 — 동시성 처리 미흡
사용자가 직접 만든 루트 레벨 CUSTOM 폴더 중 이름이 정확히 "미분류"인 것이 있으면, UNORGANIZED로 마킹됩니다. 다만 Step 2의 save() 직전에 다른 트랜잭션이 Step 3로 새 UNORGANIZED를 INSERT 완료하면, Step 2의 markAsUnorganized 저장이 partial unique index에 걸려 DataIntegrityViolationException을 던지지만 이 catch는 라인 250-256(Step 3)에만 적용되어 Step 2 경로에서는 예외가 호출자에게 그대로 전파됩니다.
Step 2의 save() 호출(라인 239)도 Step 3과 동일하게 DataIntegrityViolationException으로 보호하시는 것이 안전합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java`
around lines 233 - 240, The save() call in MemberFolderServiceImpl's Step 2 path
(when sameName.isPresent()) can raise DataIntegrityViolationException under race
with Step 3; wrap the existingFolder.markAsUnorganized() +
memberFolderJpaRepository.save(existingFolder) in the same
DataIntegrityViolationException handling used for Step 3 so the exception is
caught and handled consistently (use the same catch logic currently used around
the Step 3 insert), referencing memberFolderJpaRepository.save,
markAsUnorganized, UNORGANIZED_FOLDER_NAME and DataIntegrityViolationException
to locate and mirror the protection.
| <if test="unreadOnly"> | ||
| AND msl.view_count = 0 | ||
| </if> |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
unreadOnly/savedTodayOnly 조건에서 null 안전성 확인 필요
<if test="unreadOnly"> / <if test="savedTodayOnly">는 OGNL에서 값이 null이면 false로 평가되어 안전하지만, Boolean 타입 박스가 false인 경우와 null인 경우를 동일하게 다룹니다. BookmarkSearchCommand는 이 값들을 Boolean.TRUE.equals(...)로 정규화한 Boolean(non-null) 형태로 받으므로 현재 흐름에선 문제없지만, 향후 다른 호출 경로(예: 직접 매퍼 호출)에서 null이 들어오는 경우를 예방하려면 <if test="unreadOnly == true"> 형태로 명시화하는 것을 고려해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/resources/mapper/bookmark-mapper.xml` around lines 170 - 172, The
current MyBatis XML uses loose checks like <if test="unreadOnly"> and <if
test="savedTodayOnly"> which treat null and false the same and can allow nulls
from other call sites; update these conditions to explicit boolean checks (e.g.,
<if test="unreadOnly == true"> and <if test="savedTodayOnly == true">) so the
mapper only activates when the Boolean is true, and validate usages of
BookmarkSearchCommand to ensure it supplies non-null Booleans or rely on these
explicit checks to guard against nulls.
| <!-- 북마크 조회수 증가 (읽음 처리) --> | ||
| <update id="incrementViewCount" parameterType="map"> | ||
| UPDATE member_saved_link | ||
| SET view_count = view_count + 1, | ||
| last_viewed_at = now(), | ||
| updated_at = now(), | ||
| updated_by_member_id = #{memberId} | ||
| WHERE member_saved_link_id = #{bookmarkId} | ||
| AND created_by_member_id = #{memberId} | ||
| AND deleted_at IS NULL | ||
| </update> |
There was a problem hiding this comment.
중대: 읽음 처리에서 updated_at을 갱신 — "Newest" 정렬 의미가 오염됩니다
selectBookmarkList의 기본 정렬과 Newest 정렬은 모두 msl.updated_at DESC(라인 102, 108, 125, 131)을 사용합니다. 그런데 incrementViewCount가 updated_at = now()을 함께 갱신하므로, 단순히 북마크를 읽기만 해도 해당 항목이 "Newest" 목록의 최상단으로 이동합니다. 사용자는 컨텐츠 수정/추가가 없는데도 정렬 순서가 바뀌는 현상을 경험합니다.
또한 updated_by_member_id도 "마지막으로 컨텐츠를 변경한 사용자"라는 감사 필드인데 조회 시점에 덮어써지고 있어 audit 의미가 깨집니다. last_viewed_at이 이미 조회 시각을 별도로 추적하므로 view_count 증가만 수행하면 충분합니다.
🐛 제안
<update id="incrementViewCount" parameterType="map">
UPDATE member_saved_link
SET view_count = view_count + 1,
- last_viewed_at = now(),
- updated_at = now(),
- updated_by_member_id = #{memberId}
+ last_viewed_at = now()
WHERE member_saved_link_id = #{bookmarkId}
AND created_by_member_id = #{memberId}
AND deleted_at IS NULL
</update>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <!-- 북마크 조회수 증가 (읽음 처리) --> | |
| <update id="incrementViewCount" parameterType="map"> | |
| UPDATE member_saved_link | |
| SET view_count = view_count + 1, | |
| last_viewed_at = now(), | |
| updated_at = now(), | |
| updated_by_member_id = #{memberId} | |
| WHERE member_saved_link_id = #{bookmarkId} | |
| AND created_by_member_id = #{memberId} | |
| AND deleted_at IS NULL | |
| </update> | |
| <!-- 북마크 조회수 증가 (읽음 처리) --> | |
| <update id="incrementViewCount" parameterType="map"> | |
| UPDATE member_saved_link | |
| SET view_count = view_count + 1, | |
| last_viewed_at = now() | |
| WHERE member_saved_link_id = #{bookmarkId} | |
| AND created_by_member_id = #{memberId} | |
| AND deleted_at IS NULL | |
| </update> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/resources/mapper/bookmark-mapper.xml` around lines 181 - 191, The
incrementViewCount update currently sets updated_at and updated_by_member_id
which corrupts the "Newest" ordering used by selectBookmarkList (msl.updated_at
DESC) and overwrites audit data; modify the update statement with
id="incrementViewCount" so it only increments view_count and sets last_viewed_at
(and any necessary where clauses) — remove updated_at = now() and
updated_by_member_id = #{memberId} to preserve modification timestamps/audit and
keep selectBookmarkList semantics correct.
BookmarkSearchCommand: 검색 기능 확장: unreadOnly(미열람), savedTodayOnly(당일 저장) 필터 속성 추가 및 조회 성능 최적화를 위한 필드 타입 조정. bookmark-mapper.xml: 쿼리 동적 처리: MyBatis의 <if> 태그를 사용하여 unreadOnly 및 savedTodayOnly 조건부 검색 로직 구현 및 ID 기반 단건 조회(findById) 쿼리 추가. BookmarkServiceImpl: 예외 처리 정밀화: 북마크 조회/수정/삭제 실패 시 validateBookmarkOwner 메서드를 통해 '존재하지 않음(404)'과 '권한 없음(403)'을 명확히 구분하여 응답 정확도 확보. BookmarkServiceImpl: 서비스 연동 강화: 북마크 생성 시 폴더 미지정 상태일 경우 MemberFolderService를 통해 사용자의 '미분류' 폴더를 자동 조회하거나 생성하는 로직 추가. BookmarkApiController: API 인터페이스 최적화: recordView(읽음 처리) 호출 시 증가된 조회수와 마지막 열람 시간이 반영된 최신 북마크 객체를 반환하도록 수정. BookmarkErrorCode: 에러 코드 추가: 접근 권한 위반 시 사용될 ACCESS_DENIED(403) 코드 정의.
bookmarkApi.ts: 캐시 동기화 최적화: recordBookmarkView 실행 시 서버에서 반환한 최신 데이터를 React Query 캐시에 즉시 반영하여 클라이언트와 서버 간 상태 무결성 확보. folderStore.ts: 상태 관리 구조 개선: 중복되는 필터 토글 로직을 공통 헬퍼 함수로 통합하여 코드 중복 제거 및 검색 범위(SearchScope) 타입 정의를 통한 타입 안정성 강화. folderUtils.ts: 로직 모듈화: 폴더 정렬 및 미분류 폴더 최상단 고정 로직을 공통 유틸리티로 분리하여 재사용성 확보. RightPanel.tsx: 피드백 메시지 고도화: 활성화된 필터(미분류, 오늘 저장 등) 조합에 따라 검색 결과가 없을 때 표시되는 문구를 동적으로 생성하여 직관적인 UI 제공. page.tsx: 레이스 컨디션 해결: 폴더 목록 로딩 완료 전 미분류 폴더 ID를 참조하여 발생하는 쿼리 오류 방지를 위한 enabled 옵션 조건 제어. SaveLinkDialog.tsx: 데이터 바인딩 안정화: 링크 저장 과정에서 폴더 선택 상태가 비정상적으로 초기화되거나 누락되는 현상 방지를 위한 로직 보완.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 625c67330e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| <select id="findById" parameterType="long" resultMap="BookmarkResultMap"> | ||
| SELECT * | ||
| FROM member_saved_link | ||
| WHERE member_saved_link_id = #{bookmarkId} |
There was a problem hiding this comment.
Use a bookmark-only result map in findById
findById selects only member_saved_link columns but still uses BookmarkResultMap, which expects joined fields such as link and tag columns. This query is called from validateBookmarkOwner after failed update/delete/read operations, so for unauthorized or missing bookmarks it can throw a MyBatis column-mapping SQL error instead of returning the intended 403/404 response. Please map this query with a minimal bookmark-only result mapping (or join the required tables).
Useful? React with 👍 / 👎.
💡 이슈
resolve {#41}
🤩 개요
PR의 개요를 적어주세요.
🧑💻 작업 사항
작업한 내용을 적어주세요.
📖 참고 사항
공유할 내용, 레퍼런스, 추가로 발생할 것으로 예상되는 이슈, 스크린샷 등을 넣어 주세요.
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선 사항