Skip to content

Feat/sw 67 - 검색 필터링 기능 구현 #42

Merged
jin2304 merged 16 commits into
devfrom
feat/my-links-search-unread
Apr 27, 2026
Merged

Feat/sw 67 - 검색 필터링 기능 구현 #42
jin2304 merged 16 commits into
devfrom
feat/my-links-search-unread

Conversation

@jin2304

@jin2304 jin2304 commented Apr 25, 2026

Copy link
Copy Markdown
Member

💡 이슈

resolve {#41}

🤩 개요

PR의 개요를 적어주세요.

🧑‍💻 작업 사항

작업한 내용을 적어주세요.

📖 참고 사항

공유할 내용, 레퍼런스, 추가로 발생할 것으로 예상되는 이슈, 스크린샷 등을 넣어 주세요.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 북마크 검색 범위 선택 (전체/폴더/링크)
    • 저장된 항목, 읽지 않은 항목, 미분류 필터 추가
    • 미분류 폴더로 정렬되지 않은 북마크 관리
    • 북마크 조회 기록 및 조회 시간 추적
    • 링크 목록 무한 스크롤 기능
  • 개선 사항

    • 폴더 선택 인터랙션 개선
    • 정렬 드롭다운 옵션 맞춤 설정
    • 검색 및 필터 활성 시 향상된 사용자 인터페이스

jin2304 added 9 commits April 19, 2026 22:05
- 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: 시각적 일관성 확보: 폴더 선택 여부와 필터 활성화 조합에 따라 '폴더명 + 필터명' 또는 '필터명'만 표시되도록 조건부 렌더링 최적화.
@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6262bc42-6997-414f-948f-5df02a546c25

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

이 변경 사항은 /my-links 페이지에 검색 및 필터링 기능을 추가하고, 책갈피 조회 추적을 구현하며, "미분류" 시스템 폴더를 도입합니다. 프론트엔드는 검색 범위 선택(전체/폴더/링크), 저장됨/읽지 않음/미분류 필터 토글, 무한 스크롤 페이지 처리를 지원하도록 업데이트됩니다. 백엔드는 검색 응답 페이로드, 북마크 보기 기록 API, 폴더 타입 열거형, 불변 시스템 폴더 처리를 추가합니다. 데이터베이스는 folder_type 컬럼, view_count, last_viewed_at 필드를 추가합니다. OAuth 가입 시 기본 폴더를 생성하고, 기존 사용자는 루트 폴더 목록 조회 시 자동으로 폴더가 생성됩니다.

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 표시
Loading
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 열기
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • ekdh0858

Poem

🐰✨ 검색 기능 신호탄, 호홉 타닥타닥~
미분류 폴더 솔솔 내려와,
북마크 조회 기록하고 필터 춤을 춘다네!
무한 스크롤 살랑살랑, 상태 저장소 윙윙~
이 많은 코드들이 한데 어우러져 반짝반짝! 🌟

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning PR 설명은 템플릿 제목만 포함되어 있고 실제 내용이 채워지지 않았습니다. 이슈 번호, 개요, 작업 사항, 참고 사항이 모두 빈 상태입니다. PR 설명의 모든 섹션을 완성하세요: 이슈 번호 추가, 개요 작성, 작업 사항 상세 기술, 참고 사항 및 스크린샷 포함.
Docstring Coverage ⚠️ Warning Docstring coverage is 68.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed PR 제목이 검색 필터링 기능 구현이라는 주요 변경사항을 명확히 요약하고 있으며, 실제 변경 내용(검색, 필터링, 미분류 폴더, 읽음 상태 추적 등)과 일치합니다.

✏️ 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 feat/my-links-search-unread

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jin2304 jin2304 force-pushed the feat/my-links-search-unread branch from 70001f7 to db6d505 Compare April 25, 2026 08:06

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread frontend/src/app/layout.tsx Outdated
import { ThemeProvider } from "@/components/providers/ThemeProvider";

import { AgentationProvider } from "@/components/providers/AgentationProvider";
import { Agentation } from "agentation";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +33 to +35
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +586 to +588
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines 563 to 567
folderId: selectedFolderId,
sort: backendSort,
query: linkSearchQuery,
unreadOnly: unreadFilter || undefined,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

jin2304 added 5 commits April 26, 2026 18:37
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 타입 가드 추가로 기존 목록 조회와 무한 스크롤 조회의 읽음 상태 갱신 지원.
@jin2304 jin2304 changed the title Feat/my links search unread Feat/sw 67 - 검색 필터링 기능 구현 Apr 26, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +184 to +187
SET view_count = view_count + 1,
last_viewed_at = now(),
updated_at = now(),
updated_by_member_id = #{memberId}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_type CHECK 제약이 추가되지 않아, MemberFolder 엔티티(@Column(name = "folder_type", nullable = false))나 getOrCreateUnorganizedFolderId 코드 경로에서 컬럼 누락 오류가 발생합니다. view_count/last_viewed_at와 동일하게 idempotent ALTER를 추가해 주세요.

🛠️ 제안 마이그레이션
+-- (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_nonneg CHECK 및 FK도 기존 DB에는 적용되지 않습니다.

view_count/last_viewed_at 컬럼은 라인 419-420의 ALTER ... ADD COLUMN IF NOT EXISTS로 보강되지만, 라인 412의 ck_member_saved_link_view_count_nonneg CHECK 제약은 기존 테이블(이미 생성된 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 &#124;), 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

📥 Commits

Reviewing files that changed from the base of the PR and between b94c4de and c8a0f3b.

📒 Files selected for processing (34)
  • docs/Plan/03. my-links_search_feature_plan.md
  • docs/Plan/04. unorganized_folder_introduction_plan.md
  • frontend/src/app/globals.css
  • frontend/src/app/my-links/page.tsx
  • frontend/src/components/dialogs/SaveLinkDialog.tsx
  • frontend/src/components/my-links/FolderCard.tsx
  • frontend/src/components/my-links/RightPanel.tsx
  • frontend/src/components/ui/SortDropdown.tsx
  • frontend/src/lib/api/bookmarkApi.ts
  • frontend/src/lib/dateUtils.ts
  • frontend/src/lib/store/folderStore.ts
  • frontend/src/lib/store/linkStore.ts
  • frontend/src/lib/types/bookmark.ts
  • frontend/src/lib/types/folder.ts
  • src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java
  • src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java
  • src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java
  • src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
  • src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java
  • src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java
  • src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java
  • src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java
  • src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java
  • src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java
  • src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java
  • src/main/java/com/web/SearchWeb/folder/domain/FolderType.java
  • src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java
  • src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java
  • src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java
  • src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java
  • src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java
  • src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java
  • src/main/resources/db/init_postgres.sql
  • src/main/resources/mapper/bookmark-mapper.xml

Comment on lines +365 to +372
- 좌측 전체검색의 Folders 섹션에서 폴더를 클릭 시:
```typescript
handleFolderClick(folderId) {
bookmarkStore.setSelectedFolder(folderId);
bookmarkStore.setLinkSearchQuery(currentSearchQuery); // 현재 검색어 자동 입력
}
```
- 우측 패널 링크 검색창에는 `linkSearchQuery` 상태가 반영되어 미리 입력된 상태로 표시

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

테이블 행이 깨져 렌더링됩니다.

인라인 코드 블록 내부의 | 문자는 마크다운 테이블 파서에서 셀 구분자로 처리되어, 이 행이 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.

Suggested change
| `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 &#124;), ensuring the row remains three
columns and the header aligns correctly.

Comment on lines +178 to +188
`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) { ... }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

문서 내부 용어가 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_systemuq_member_folder_owner_unorganized
  • 라인 279: findByOwnerMemberIdAndIsSystemTruefindFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED)
  • 라인 300: MemberFolderResponses.isSystemfolderType
  • 라인 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.

Comment on lines +260 to +264
```tsx
const targetFolderId = selectedFolderId
?? folders?.find(f => f.folderType === 'UNORGANIZED')?.memberFolderId
?? null;
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +168 to +170
button, [role="button"] {
@apply cursor-pointer;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

비활성화된 버튼에도 포인터 커서가 적용됩니다.

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.

Suggested change
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.

Comment on lines +49 to +52
/** 기존 폴더를 시스템 폴더(UNORGANIZED)로 마킹. 이름 충돌 흡수(absorb) 경로에서 사용. */
public void markAsUnorganized() {
this.folderType = FolderType.UNORGANIZED;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Comment on lines 107 to 113
@Transactional
public List<MemberFolder> listRootFolders(Long memberId, Long ownerMemberId) {
validateOwner(memberId, ownerMemberId);
// 기존 회원 대상 lazy 백필: 조회 호출 시 미분류 폴더가 없으면 이 시점에 생성
getOrCreateUnorganizedFolderId(ownerMemberId);
return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Comment on lines +233 to +240
// 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환
Optional<MemberFolder> sameName = memberFolderJpaRepository
.findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME);
if (sameName.isPresent()) {
MemberFolder existingFolder = sameName.get();
existingFolder.markAsUnorganized();
return memberFolderJpaRepository.save(existingFolder).getMemberFolderId();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 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.java

Repository: 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.java

Repository: 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/*.java

Repository: 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.

Comment on lines +170 to +172
<if test="unreadOnly">
AND msl.view_count = 0
</if>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 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.

Comment on lines +181 to +191
<!-- 북마크 조회수 증가 (읽음 처리) -->
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

중대: 읽음 처리에서 updated_at을 갱신 — "Newest" 정렬 의미가 오염됩니다

selectBookmarkList의 기본 정렬과 Newest 정렬은 모두 msl.updated_at DESC(라인 102, 108, 125, 131)을 사용합니다. 그런데 incrementViewCountupdated_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.

Suggested change
<!-- 북마크 조회수 증가 (읽음 처리) -->
<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.

jin2304 added 2 commits April 27, 2026 20:30
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: 데이터 바인딩 안정화: 링크 저장 과정에서 폴더 선택 상태가 비정상적으로 초기화되거나 누락되는 현상 방지를 위한 로직 보완.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +89 to +92
<select id="findById" parameterType="long" resultMap="BookmarkResultMap">
SELECT *
FROM member_saved_link
WHERE member_saved_link_id = #{bookmarkId}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

@jin2304 jin2304 merged commit 6570a01 into dev Apr 27, 2026
2 checks passed
@jin2304 jin2304 assigned jin2304 and unassigned jin2304 May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SW-67 검색 필터링 기능 구현 v1.0.0] 검색 필터링 기능 구현 v1.0.0

1 participant