diff --git a/docs/Plan/03. my-links_search_feature_plan.md b/docs/Plan/03. my-links_search_feature_plan.md new file mode 100644 index 0000000..bb6e316 --- /dev/null +++ b/docs/Plan/03. my-links_search_feature_plan.md @@ -0,0 +1,453 @@ +# My Links 검색 기능 계획서 + +> **문서 정보** +> - **버전:** v0.1.0 (Draft) +> - **작성일:** 2026-04-14 +> - **관련 화면:** `/my-links` +> - **관련 문서:** [SearchWeb PRD](./01.%20searchweb_PRD.md), [KPI 측정 설계](./02.%20kpi_measurement_design.md), [Frontend Architecture](../Fontend/01.%20frontend_architecture.md) +> - **문서 목적:** `/my-links` 검색 기능의 UX 정책, 결과 구조, 데이터 규칙, 구현 범위를 정의한다. + +
+ +## 1. 개요 + +`/my-links` 화면은 현재 좌측 메인 영역과 우측 링크 패널의 역할이 분리되어 있다. + +- 좌측 메인 영역: 폴더 탐색, 폴더 생성, 전체 구조 탐색 +- 우측 패널: 선택된 폴더 기준 링크 조회, 정렬, 태그 필터, 링크 편집 + +검색 기능 역시 이 역할 분리를 유지하면서도, 사용자는 일관된 검색 경험을 느낄 수 있어야 한다. +따라서 본 계획서는 다음 원칙을 채택한다. + +- **검색 경험은 통일한다.** +- **검색 범위와 결과 해석은 영역별로 분리한다.** +- **전체검색에서는 폴더와 링크를 모두 보여주되, 결과를 섞지 않고 섹션으로 분리한다.** +- **링크가 여러 폴더에 저장된 경우, 포함된 폴더 정보를 명시적으로 보여준다.** + +
+ +## 2. 문제 정의 + +현재 `/my-links` 상단에는 `Search folders or links` 형태의 입력창이 존재하지만, 실제 검색 범위와 결과 정책은 정의되어 있지 않다. + +이 상태에서 검색을 바로 구현하면 다음 문제가 생길 수 있다. + +- 사용자가 지금 찾는 대상이 폴더인지 링크인지 명확하지 않다. +- 전체검색 시 폴더 결과와 링크 결과가 한 리스트에 섞이면 결과 문맥이 깨진다. +- 동일 링크가 여러 폴더에 존재할 때 결과를 어떻게 보여줄지 일관된 기준이 없다. +- 좌측 메인 검색과 우측 패널 검색의 역할이 겹치면 인터랙션이 혼란스러워진다. + +
+ +## 3. 목표 + +### 3.1 사용자 목표 + +- 사용자가 폴더를 빠르게 찾을 수 있어야 한다. +- 사용자가 현재 선택한 폴더 안에서 링크를 빠르게 찾을 수 있어야 한다. +- 사용자가 전체검색을 통해 폴더와 링크를 한 번에 탐색할 수 있어야 한다. +- 사용자가 검색 결과에서 링크가 어떤 폴더들에 포함되어 있는지 즉시 이해할 수 있어야 한다. + +### 3.2 제품 목표 + +- 검색 대상과 결과 구조를 명확히 분리하여 혼란을 줄인다. +- 현재 데이터 구조를 크게 깨지 않고 1차 버전을 빠르게 구현한다. +- 이후 전역 검색 고도화와 이벤트 로깅으로 확장 가능한 기반을 만든다. + +
+ +## 4. UX 원칙 + +### 4.1 검색 경험 통일, 검색 범위 분리 + +검색창의 시각 스타일, 입력 방식, 아이콘, 인터랙션은 최대한 통일한다. +다만 각 영역이 담당하는 검색 범위는 다음과 같이 분리한다. + +| 영역 | 기본 역할 | 검색 대상 | 기본 범위 | +| :--- | :--- | :--- | :--- | +| 좌측 메인 검색 | 탐색 진입점 | 폴더, 링크 | 기본은 폴더 검색 | +| 우측 패널 검색 | 현재 작업 맥락 유지 | 링크 | 현재 선택된 폴더 내부 링크 | + +### 4.2 기본값은 좁고, 확장은 명시적으로 + +좌측 검색은 기본적으로 `폴더만 검색`으로 시작한다. +전체검색이 필요한 사용자를 위해 검색 범위 토글을 제공한다. + +권장 범위 토글: + +- `폴더만` +- `전체` + +기본 선택: + +- `폴더만` + +선택 이유: + +- `/my-links` 좌측 영역의 핵심 역할이 폴더 탐색이기 때문이다. +- 전체검색을 기본으로 두면 결과 해석 비용이 커진다. +- 우측 패널의 링크 검색과 역할이 겹치지 않도록 해야 한다. + +
+ +## 5. 검색 범위 정책 + +### 5.1 좌측 메인 검색 + +좌측 검색창은 두 가지 모드를 가진다. + +#### A. 폴더만 검색 + +- 폴더 이름 기준 검색 +- 결과는 폴더 카드 또는 폴더 리스트로 표시 +- 기존 폴더 탐색 흐름을 해치지 않는 범위에서 동작 + +#### B. 전체검색 + +- 검색어와 매칭되는 폴더 결과와 링크 결과를 모두 보여줌 +- 단, 한 리스트에 섞지 않고 별도 섹션으로 분리 +- 상단에 결과 탭 또는 칩으로 `전체 | 폴더 | 링크` 필터 제공 가능 + +### 5.2 우측 패널 검색 + +- 검색 대상은 링크만 +- 검색 범위는 현재 `selectedFolderId` 기준으로 조회된 링크 목록 +- 태그 필터, 정렬과 함께 사용 가능해야 함 +- placeholder는 `Search links in this folder` 수준으로 명확화 + +
+ +## 6. 전체검색 결과 구조 + +### 6.1 핵심 정책 + +전체검색에서 검색어가 폴더와 링크 모두에 매칭되면 **둘 다 보여준다.** +단, 결과를 한 리스트로 섞지 않고 다음과 같이 분리한다. + +- `Folders` +- `Links` + +이는 "전체검색을 켰다"는 사용자의 의도가 "어디 있든 관련 있는 것을 다 찾고 싶다"에 가깝기 때문이다. + +### 6.2 결과 구성 예시 + +```text +[ 검색창: aaa ] [전체] + +8 results +[All] [Folders 5] [Links 6] + +Folders +- aaa 프로젝트 (폴더명 직접 매칭) +- aaa 참고자료 (폴더명 직접 매칭) +- Frontend (링크 'aaa 공식문서' 포함) +- Study (링크 'aaa 공식문서' 포함) +- Work (링크 'How to use aaa' 포함) + +Links +- aaa 공식문서 + 포함 폴더: Frontend / Study / Reference +- How to use aaa + 포함 폴더: Work / Archive +``` + +**주요 변경:** +- 링크에 포함된 폴더(Frontend, Study, Work 등)도 **Folders 섹션에 함께 표시** +- 폴더 클릭 시 우측 패널 링크 검색창에 검색어('aaa')가 자동 입력되어, 해당 폴더의 매칭 링크만 표시 + +### 6.3 UI 표현 규칙 + +- 검색창 바로 아래에 총 결과 수를 표시한다. +- 그 아래에 `All`, `Folders`, `Links` 탭 또는 칩을 둘 수 있다. +- `Folders` 섹션은 폴더 결과만 렌더링한다. +- `Links` 섹션은 링크 결과만 렌더링한다. +- 링크 카드 내부에 `포함 폴더` 메타정보를 표시한다. + +### 6.4 결과를 섞지 않는 이유 + +- 폴더 결과와 링크 결과는 사용자가 기대하는 액션이 다르다. +- 폴더 결과는 "해당 폴더로 이동"이 주된 액션이다. +- 링크 결과는 "링크 열기" 또는 "링크가 저장된 위치 확인"이 주된 액션이다. +- 섞어서 보여주면 정보는 많아지지만 이해 비용이 크게 증가한다. + +
+ +## 7. 링크 중복 및 포함 폴더 표시 정책 + +### 7.1 문제 상황 + +전체검색에서 동일한 링크가 여러 폴더에 저장될 수 있다. 예를 들어 동일 링크가 `Frontend`, `Study` 두 폴더에 모두 저장된 경우가 있다. + +### 7.2 기본 정책 + +전체검색 결과에서 링크 카드에는 **해당 링크가 포함된 폴더를 모두 보여준다.** + +- 링크 결과는 링크 자체의 정보와 함께 렌더링 +- 링크 카드 내부에 `포함 폴더` 목록 표시 +- 폴더 배지 클릭 시 해당 폴더로 이동 가능하도록 설계 + +### 7.3 폴더 표시 규칙 + +- 1~3개 폴더는 모두 바로 노출 +- 폴더가 4개 이상이면 `+N more` 패턴 고려 +- hover, 클릭, 확장 패널 등으로 전체 폴더 목록 확인 가능하도록 확장 가능 + +### 7.4 의미 분리 + +다음 두 정보는 같은 "폴더"처럼 보이지만 역할이 다르다. + +- `Folders` 섹션의 폴더: 검색어에 직접 매칭된 검색 결과 +- 링크 카드의 포함 폴더: 해당 링크가 저장된 위치 정보 + +따라서 반드시 위치를 분리해서 보여줘야 한다. + +
+ +## 8. 데이터 및 API 설계 방향 + +### 8.1 현재 코드 기준 확인 사항 + +현재 프론트엔드에서는 북마크 조회 API가 `query` 파라미터를 받을 수 있다. + +- `frontend/src/lib/api/bookmarkApi.ts` +- `frontend/src/lib/types/bookmark.ts` + +또한 백엔드 `BookmarkSearchCommand`와 MyBatis 매퍼에서도 `query`를 받고 있다. + +- `src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java` +- `src/main/resources/mapper/bookmark-mapper.xml` + +단, 현재 매퍼 기준 링크 검색은 `display_title` 기준으로만 동작한다. + +```sql +AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' +``` + +반면 폴더 목록 API는 현재 검색 전용 파라미터가 없다. + +- `frontend/src/lib/api/folderApi.ts` + +따라서 1차 구현은 다음 전략이 현실적이다. + +- 폴더 검색: 이미 로드된 폴더 목록 기반 클라이언트 필터 +- 우측 링크 검색: 기존 `query` 파라미터 활용 +- 전체검색 1차: 폴더는 클라이언트 필터, 링크는 서버 검색 조합 + +### 8.2 전체검색 결과 응답 방향 + +향후 전체검색을 서버 중심으로 고도화할 경우 아래 형태의 응답 구조를 권장한다. + +```json +{ + "query": "aaa", + "folders": [ + { + "folderId": 1, + "folderName": "aaa 프로젝트" + } + ], + "links": [ + { + "linkKey": "canonical-or-original-url", + "title": "aaa 공식문서", + "url": "https://example.com/aaa", + "matchedBookmarkIds": [10, 25], + "includedFolders": [ + { "folderId": 3, "folderName": "Frontend" }, + { "folderId": 7, "folderName": "Study" } + ] + } + ] +} +``` + +### 8.3 링크 식별 기준 + +링크 중복 묶음 또는 관계 계산 시 권장 우선순위는 다음과 같다. + +1. `canonicalUrl` +2. `originalUrl` +3. 필요 시 `linkId` + +단, 1차 구현에서는 저장 단위가 북마크인 만큼 무리한 통합보다는 "링크 카드에 포함 폴더를 보여주는 방향"을 우선 적용한다. + +
+ +## 9. 구현 단계 제안 + +### 9.1 1단계: 빠른 UX 정착 + +목표: + +- 검색 범위를 명확히 나누고 기본 UX를 안정화 + +범위: + +- 좌측 검색창 범위 토글 추가: `폴더만 | 전체` +- 좌측 `폴더만` 검색: 로컬 폴더 필터 +- 우측 링크 검색: 현재 폴더 기준 `query` 연동 +- placeholder 및 문구 정리 + +제외: + +- 폴더와 링크를 동시에 묶는 서버 통합 검색 API +- 복잡한 dedupe 로직 + +### 9.2 2단계: 전체검색 결과 섹션화 + +목표: + +- 전체검색에서 폴더와 링크를 동시에 보여주는 구조 도입 + +범위: + +- 좌측 검색 결과 영역 추가 +- `Folders` / `Links` 섹션 분리 +- 결과 수 표시 +- `전체 | 폴더 | 링크` 보기 필터 추가 가능 + +### 9.3 3단계: 포함 폴더 정보 고도화 + +목표: + +- 링크 결과의 저장 위치를 명확히 보여줌 + +범위: + +- 링크 카드 내 `포함 폴더` 배지 표시 +- 폴더 배지 클릭 시 해당 폴더 컨텍스트 이동 +- 폴더 수가 많을 때 축약 및 확장 UX 추가 + +### 9.4 4단계: 검색 품질 개선 + +목표: + +- 검색 정확도와 탐색 속도 개선 + +범위: + +- 링크 검색 범위를 `displayTitle` 외 `note`, `domain`, `tag`, `link title`까지 확장 검토 +- 폴더 검색 API 또는 통합 검색 API 검토 +- 디바운스, 캐싱, 최근 검색어, 키보드 탐색 추가 + +
+ +## 10. 프론트엔드 구성 제안 + +### 10.1 좌측 메인 영역 + +추가 상태 예시: + +- `searchScope: 'folders' | 'all'` +- `searchQuery: string` +- `searchView: 'all' | 'folders' | 'links'` + +렌더링 흐름: + +- 검색어 없음: 기존 폴더 대시보드 유지 +- 검색어 있음 + `폴더만`: 폴더 결과만 표시 +- 검색어 있음 + `전체`: `Folders` / `Links` 결과 섹션 표시 + +### 10.2 우측 패널 + +추가 상태 예시: + +- `linkSearchQuery: string` + +렌더링 흐름: + +- 현재 `selectedFolderId` 유지 +- 정렬, 태그 필터와 함께 검색 적용 +- 검색 상태에서도 폴더 컨텍스트를 잃지 않음 + +**폴더 클릭 시 자동 검색 입력 (신규):** + +- 좌측 전체검색의 Folders 섹션에서 폴더를 클릭 시: + ```typescript + handleFolderClick(folderId) { + bookmarkStore.setSelectedFolder(folderId); + bookmarkStore.setLinkSearchQuery(currentSearchQuery); // 현재 검색어 자동 입력 + } + ``` +- 우측 패널 링크 검색창에는 `linkSearchQuery` 상태가 반영되어 미리 입력된 상태로 표시 +- 사용자는 X 버튼으로 검색어를 삭제하거나, 새로운 검색어를 직접 입력 가능 +- 다른 폴더를 클릭하면 `linkSearchQuery`는 새로운 검색어로 자동 업데이트됨 + +
+ +## 11. 이벤트 로깅 제안 + +검색 기능이 실제로 사용되는지 확인하기 위해 아래 이벤트를 추적한다. + +| 이벤트 코드 | 설명 | 우선순위 | +| :--- | :--- | :--- | +| `SEARCH_EXECUTE` | 검색 실행 | P0 | +| `SEARCH_SCOPE_CHANGE` | `폴더만` / `전체` 범위 전환 | P0 | +| `SEARCH_RESULT_CLICK_FOLDER` | 검색 결과에서 폴더 클릭 (좌측 Folders 섹션) | P0 | +| `SEARCH_RESULT_CLICK_LINK` | 검색 결과에서 링크 클릭 | P0 | +| `SEARCH_CONTEXT_AUTO_INPUT` | 폴더 클릭 → 우측 패널 검색어 자동 입력 | P0 | +| `SEARCH_RESULT_CLICK_INCLUDED_FOLDER` | 링크 카드 내부 포함 폴더 클릭 | P1 | +| `SEARCH_VIEW_FILTER_CHANGE` | `전체 | 폴더 | 링크` 보기 전환 | P1 | + +권장 속성 예시: + +```json +{ + "query": "aaa", + "scope": "folders | all | current_folder_links", + "folder_result_count": 2, + "link_result_count": 6, + "selected_folder_id": 15 +} +``` + +
+ +## 12. 검증 포인트 + +### 12.1 UX 검증 + +- 사용자가 좌측 검색과 우측 검색의 차이를 직관적으로 이해하는가 +- 전체검색 결과에서 폴더와 링크가 섞이지 않아 이해가 쉬운가 +- 링크 카드의 포함 폴더가 정보 과밀 없이 읽히는가 + +### 12.2 기능 검증 + +- `폴더만` 검색 시 폴더만 결과에 노출되는가 +- `전체` 검색 시 폴더와 링크가 모두 노출되는가 +- 우측 검색은 현재 선택된 폴더 범위를 벗어나지 않는가 +- 링크가 여러 폴더에 저장된 경우 포함 폴더가 모두 표시되는가 + +### 12.3 성능 검증 + +- 검색 입력 시 불필요한 전체 재렌더가 과도하지 않은가 +- 우측 패널 검색 시 API 호출 빈도가 과도하지 않은가 +- 전체검색 결과 수가 많을 때 레이아웃 붕괴 없이 유지되는가 + +
+ +## 13. 비목표 + +이번 계획서 범위에서 바로 포함하지 않는 항목은 다음과 같다. + +- 완전한 글로벌 검색 모달 별도 도입 +- 고급 랭킹 알고리즘 +- 자연어 검색 +- 팀 폴더/팀 링크까지 포함한 통합 검색 +- 모바일 전용 별도 검색 UX + +
+ +## 14. 결론 + +`/my-links` 검색 기능은 "한 검색창으로 모든 것을 해결"하기보다, **좌측은 탐색, 우측은 현재 작업 컨텍스트 유지**라는 구조를 살려야 한다. + +최종 방향은 다음과 같다. + +- 좌측 검색 기본값은 `폴더만 검색` +- 좌측에서 필요 시 `전체검색`으로 확장 +- 전체검색은 폴더와 링크를 모두 보여주되, 반드시 `Folders` / `Links` 섹션으로 분리 +- 링크 결과에는 `포함 폴더`를 명시적으로 표시 +- 우측 패널 검색은 현재 선택된 폴더 안의 링크 검색으로 유지 + +이 방향은 현재 코드 구조와도 잘 맞고, 1차 구현부터 단계적으로 확장하기에도 적합하다. diff --git a/docs/Plan/04. unorganized_folder_introduction_plan.md b/docs/Plan/04. unorganized_folder_introduction_plan.md new file mode 100644 index 0000000..92bdcd4 --- /dev/null +++ b/docs/Plan/04. unorganized_folder_introduction_plan.md @@ -0,0 +1,349 @@ +# 미분류(디폴트) 폴더 도입 계획서 + +> **문서 정보** +> - **버전:** v0.1.0 (Draft) +> - **작성일:** 2026-04-18 +> - **관련 화면:** `/my-links`, `SaveLinkDialog` +> - **관련 문서:** [SearchWeb PRD](./01.%20searchweb_PRD.md), [My Links 검색 기능 계획서](./03.%20my-links_search_feature_plan.md) +> - **문서 목적:** "미분류" 개념을 **시스템 폴더(`folder_type='UNORGANIZED'`)** 로 정의하고, 이를 도입하기 위한 데이터 모델·서비스·UI 변경 사항을 정의한다. + +
+ +## 1. 개요 + +`/my-links` 화면 상단에는 `Unorganized` 칩이 존재하지만, 현재 정의·동작이 비어 있다 (`frontend/src/app/my-links/page.tsx:179`). + +기존 시스템에서 "미분류"를 정의할 수 있는 방법은 두 가지였다. + +- ⓐ **태그 부재 기반** — 해시태그가 비어 있는 링크를 미분류로 간주 +- ⓑ **물리적 시스템 폴더 기반** — `folder_type='UNORGANIZED'` 인 시스템 폴더를 자동 생성하고, 분류되지 않은 링크는 이 폴더에 저장 + +본 계획서는 사전 인터뷰 결과(`deep-interview` round 4 / 최종 ambiguity 14%)에 따라 **ⓑ 디폴트 폴더 방식**을 채택한다. +이유: +- 폴더와 태그가 직교 개념(현재 코드 기준)이라 태그 부재 = 미분류 정의는 의미가 약함 +- 사용자가 미분류 칩을 클릭했을 때 동작을 **기존 폴더 클릭과 동일하게** 통일할 수 있음 (`setSelectedFolderId(defaultFolderId)`) +- AI 분류 실패/사용자 미선택 케이스 둘 다 같은 위치로 모이므로 회수(回收) 동선이 단순해짐 + +
+ +## 2. 문제 정의 + +현재 구조에서 디폴트 폴더 도입을 막고 있는 결손은 다음과 같다. + +1. **데이터 모델**: `member_folder` 테이블과 `MemberFolder` 엔티티 모두 폴더 타입(`NORMAL`, `UNORGANIZED` 등)을 식별할 수 있는 필드가 없다. +2. **자동 생성 시점 부재**: 회원가입(소셜) 흐름(`CustomOAuth2MemberService.loadUser` 73~95행)에서 폴더 생성 호출이 없다. 기존 회원에 대한 백필 경로도 없다. +3. **불변 정책 부재**: `MemberFolderServiceImpl.delete/move/update` 가 시스템 폴더에 대한 가드 없이 일반 폴더와 동일하게 동작한다. +4. **저장 경로 분기 부재**: `SaveLinkDialog.handleSave` (`frontend/src/components/dialogs/SaveLinkDialog.tsx:261`) 가 `selectedFolderId === null && !pendingNewFolderName` 일 때 `memberFolderId: null` 을 전송하는데, 이를 받는 백엔드는 폴더 NULL 을 허용하지 않는다(현 스키마: `"member_folder_id" int NOT NULL`). → **폴더 미선택 저장 자체가 불가능**. +5. **UI**: `Unorganized` 칩에 onClick 미연결, `FolderCard` 메뉴(이름변경/이동/삭제)가 시스템 폴더에서도 동일하게 표시됨. + +
+ +## 3. 목표 + +### 3.1 사용자 목표 + +- 사용자가 폴더를 정하지 않고도 링크를 저장할 수 있다 (퀵 세이브 경로). +- 사용자가 `Unorganized` 칩을 누르면 미분류 폴더의 링크 목록을 즉시 볼 수 있다. +- 사용자는 미분류 폴더를 실수로 삭제·이동할 수 없다 (이름 변경은 허용). + +### 3.2 제품 목표 + +- 시스템 폴더를 데이터 모델 차원에서 일급(first-class)으로 표현한다. +- 신규/기존 사용자 모두에게 정확히 1개의 미분류 폴더를 보장한다 (idempotent). +- AI 폴더 추천 실패·미선택 같은 후속 시나리오를 같은 디폴트 폴더로 모은다. + +### 3.3 비목표 (이번 이터레이션 제외) + +- AI 폴더 추천이 실패했을 때 자동으로 디폴트 폴더로 폴백하는 로직 (Future Section) +- 폴더 이름의 다국어화 / 사용자 커스텀 (이번에는 한국어 `미분류` 고정) +- 디폴트 폴더 안의 링크에 대한 "이주" 기능 (다른 폴더로 옮기면 자동 비분류 해제 등) + +
+ +## 4. 결정 사항 요약 (인터뷰 산출) + +| 항목 | 결정 | 비고 | +| :--- | :--- | :--- | +| 미분류 정의 | 시스템 폴더(`folder_type='UNORGANIZED'`)에 보관된 링크 | 태그 부재 정의 폐기 | +| 마이그레이션 전략 | **회원가입 로직 변경 + 기존 회원 lazy 생성** | 옵션 3 (권장안) | +| 디폴트 폴더 제약 | 삭제 ✗ / 이름 변경 ✓ / 이동 ✗ / 자식 ✓ | 옵션 B (느슨한) | +| 저장 정책 | **이번 이터레이션에 Quick Save 포함** | 옵션 2 (권장안) | +| AI 실패 폴백 | 본 이터레이션 제외 | Future Section | + +
+ +## 5. 데이터 모델 변경 + +### 5.1 `member_folder` 테이블 + +`src/main/resources/db/init_postgres.sql` (96~113행) 의 정의에 컬럼·제약 추가. + +```sql +-- 1. 폴더 타입 컬럼 추가 (기본값 CUSTOM) +ALTER TABLE "member_folder" + ADD COLUMN IF NOT EXISTS "folder_type" varchar(20) NOT NULL DEFAULT 'CUSTOM'; + +-- 2. 타입 제약 추가 (향후 TRASH, ARCHIVE 등 확장 가능) +ALTER TABLE "member_folder" + DROP CONSTRAINT IF EXISTS ck_member_folder_type, + ADD CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED')); + +-- 3. 한 사용자당 시스템 폴더(UNORGANIZED)는 정확히 1개만 활성 상태로 존재하도록 유니크 제약 +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; + +-- 4. member_saved_link 테이블 정합성 정리 및 FK 추가 +-- (주의: FK 추가 전, member_folder_id가 유효하지 않은 데이터가 있는지 점검 필수) +ALTER TABLE "member_saved_link" + ADD CONSTRAINT fk_member_saved_link_member_folder_id + FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id"); +``` + +`init_postgres.sql` 본체에도 컬럼/제약/인덱스를 반영하여 신규 환경에서 자동 적용되도록 한다. +특히 `member_saved_link` 정의 시점에 `member_folder_id`에 대한 FK가 누락되어 있으므로 이를 보강한다. + +### 5.2 `MemberFolder` 엔티티 + +`src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java`: + +- `private String folderType;` 필드 추가 (기본값 `"CUSTOM"`). +- `isUnorganized()` 편의 메서드 추가. +- `changeParent()` / `delete*` 같이 자기 자신을 변경하는 도메인 메서드들이 시스템 폴더 위반을 방어할 수 있도록, 메서드 레벨에서 `if (isUnorganized()) throw new BusinessException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE);` 가드를 추가한다 (이름 변경 `changeInfo` 는 가드 제외). + +### 5.3 `FolderErrorCode` 추가 + +`src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java` 에 다음을 추가: + +- `SYSTEM_FOLDER_IMMUTABLE` (HTTP 409, "기본 폴더는 삭제하거나 이동할 수 없습니다.") +- `DEFAULT_FOLDER_NAME_CONFLICT` (HTTP 409, 이미 같은 이름의 폴더가 있어 디폴트 폴더 자동 생성 실패 시) + +
+ +## 6. 백엔드 변경 사항 + +### 6.1 서비스 인터페이스 확장 + +`src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java`: + +```java +/** 미분류 폴더 보장. 없으면 생성, 있으면 기존 ID 반환. idempotent. */ +Long getOrCreateUnorganizedFolderId(Long memberId); + +/** 미분류 폴더 단건 조회. 없으면 getOrCreate 호출 후 반환. */ +MemberFolder getDefaultFolder(Long memberId); +``` + +### 6.2 `MemberFolderServiceImpl` 변경 + +- **`getOrCreateUnorganizedFolderId(memberId)`** 신규 구현 + - `findByOwnerAndFolderType(memberId, "UNORGANIZED")` 로 조회 → 있으면 그 ID 반환. + - 없으면 `MemberFolder.builder().ownerMemberId(memberId).folderName("미분류").folderType("UNORGANIZED").build()` 로 저장. + - 동시 호출(중복 INSERT)은 5.1의 부분 유니크 인덱스가 막고, `DataIntegrityViolationException` catch 후 재조회로 복구. +- **`delete(memberId, folderId)`** 가드 추가 + - 조회 후 `if (folder.isUnorganized()) throw FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE` (현재 자식/북마크 가드 앞단). +- **`move(memberId, folderId, newParentFolderId)`** 가드 추가 + - 동일하게 시스템 폴더면 차단. +- **`update(memberId, ...)`** 는 가드 없음 (이름 변경 허용 — 결정 5.B). +- **`listRootFolders(memberId, ownerMemberId)`** 끝부분에 lazy 보장 추가 + - `getOrCreateUnorganizedFolderId(ownerMemberId)` 를 1회 호출하여 기존 회원에게도 자동 백필. + - 호출은 idempotent 하므로 매 요청마다 안전. (성능 영향은 7.2 참고) + +### 6.3 회원가입 흐름 통합 + +**소셜 회원가입** — `CustomOAuth2MemberService.loadUser` (73~95행) + +```java +if (existMember == null) { + // 기존 코드: insertSocialMember(...) + memberDao.insertSocialMember(member); + existMember = member; + // 신규: 디폴트 폴더 생성. 실패해도 가입 자체는 진행시켜야 하므로 try-catch 후 로그. + try { + memberFolderService.getOrCreateUnorganizedFolderId(existMember.getMemberId()); + } catch (Exception e) { + log.warn("default folder creation failed (will be lazily created on first listRootFolders): {}", e.getMessage()); + } +} +``` + +`MemberFolderService` 의존성을 생성자 주입으로 추가. 순환 의존성 방지를 위해 `MemberFolderService` 가 `member` 도메인을 역방향으로 참조하지 않는지 점검 (현재 `MemberFolderServiceImpl` 은 `MemberFolderRepository` 만 사용하므로 안전). + +**로컬 회원가입** — 별도 `JoinController` 가 코드베이스에 존재하지 않음 (탐색 결과). 향후 로컬 회원가입을 추가할 때 `MemberServiceImpl` 의 신규 가입 메서드에서 동일하게 호출하도록 가이드만 명시. + +**기존 회원 백필** — 6.2의 `listRootFolders` lazy 호출이 담당. 별도 마이그레이션 SQL은 작성하지 않는다. + +### 6.4 컨트롤러 + +`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) { ... } +} +``` + +
+ +## 7. 보안 / 인가 / 성능 + +### 7.1 보안 / 인가 + +- **소유권 가드**는 기존 `validateOwner(memberId, folder)` 로직 그대로 유지. `is_system` 은 추가 제약일 뿐 소유권 검사를 대체하지 않는다. +- **`@OwnerCheck` AOP** 가 적용된 메서드들은 이미 다른 사용자의 폴더 접근을 차단하므로, `getOrCreateUnorganizedFolderId` 도 `memberId` 인자만 신뢰한다 (Controller 진입점으로 주입된 값을 사용). +- **DELETE/MOVE 우회 시도**: 클라이언트가 `folderType` 응답을 무시하고 강제로 `DELETE /api/folders/{id}` 를 호출해도 6.2의 서비스 가드에서 `409` 로 차단한다. 컨트롤러 레벨에서 별도 검증을 두지 않는다 (단일 진실 공급원: 서비스). + +### 7.2 성능 + +- **`listRootFolders` 마다 ensure 호출**의 비용은 인덱스 룩업 1회(부분 유니크 인덱스 적중) → 정상 케이스에서 무시 가능 수준. +- 부담이 측정되면 후속 단계에서 다음 두 옵션을 검토: + - 옵션 A: 회원가입 시점에만 생성하고, 기존 회원에 대해서는 1회성 마이그레이션 SQL 실행. + - 옵션 B: 메모리/Redis 캐시에 "이 회원은 이미 디폴트 폴더 보유" 플래그를 두고, 첫 호출 이후 ensure 스킵. +- 본 이터레이션은 단순성을 우선하여 옵션 A/B 모두 보류. + +### 7.3 트랜잭션 / 동시성 + +- `getOrCreateUnorganizedFolderId` 는 `@Transactional` 로 감싸고, INSERT 실패 시 재조회(read-after-write) 후 반환. +- 부분 유니크 인덱스(`uq_member_folder_owner_system`)가 동시 회원가입·동시 첫 호출의 경합을 DB 레벨에서 차단. + +
+ +## 8. 프론트엔드 변경 사항 + +### 8.1 타입 확장 + +`frontend/src/lib/types/folder.ts` 의 `FolderResponse` 에 `folderType: 'CUSTOM' | 'UNORGANIZED'` 추가. + +### 8.2 `Unorganized` 칩 클릭 동작 + +`frontend/src/app/my-links/page.tsx:179` 의 placeholder 버튼: + +```tsx +const defaultFolder = folders?.find(f => f.folderType === 'UNORGANIZED'); + + +``` + +폴더 카드 클릭과 동일한 동작이므로 별도 분기·별도 패널 없음. 우측 패널은 자동으로 미분류 폴더의 북마크를 조회한다 (`useBookmarks({ folderId })`). + +### 8.3 폴더 카드 메뉴 가드 + +`frontend/src/components/my-links/FolderCard.tsx`: + +- 시스템 폴더이면 메뉴 자체를 숨길지, "Move/Delete" 항목만 비활성화할지 결정. 결정 5.B(이름 변경 허용)에 따라 **메뉴는 표시하되 Move/Delete 항목만 disabled** 로 처리 (`folderType === 'UNORGANIZED'`). +- 카드 좌상단 아이콘에 미분류 표식(예: `inventory_2`) 추가하여 시각적으로 구분. + +### 8.4 폴더 목록 정렬 + +`useFolders` 응답을 화면에 표시할 때 디폴트 폴더는 항상 **목록 최상단** 또는 **별도 섹션**에 위치하도록 정렬 (UX 결정은 디자인 단계에서 확정). +일단 1차에서는 "최상단 고정" 으로 단순 구현. + +### 8.5 Quick Save 경로 (결정 5.옵션 2) + +`frontend/src/components/dialogs/SaveLinkDialog.tsx`: + +- `handleSave` (261행) 에서 `selectedFolderId === null && !pendingNewFolderName` 인 경우, 디폴트 폴더 ID로 저장하도록 보강. + ```tsx + const targetFolderId = selectedFolderId + ?? folders?.find(f => f.folderType === 'UNORGANIZED')?.memberFolderId + ?? null; + ``` +- "Save to Workspace" 버튼 옆 또는 메인 버튼에 `Quick Save` 라벨/툴팁 추가 (디자인 단에서 라벨 확정). +- `useBookmarks`/`useCreateBookmark` 등 API 호출은 변경 없음. 백엔드는 `member_folder_id` NOT NULL 제약을 유지하면서 디폴트 폴더 ID를 받는다. + +### 8.6 RightPanel 빈 상태 + +`frontend/src/components/my-links/RightPanel.tsx:329` 의 `'Unordered'` 폴백 라벨 제거 또는 디폴트 폴더에 한해 "AI 분류 대기 중인 링크" 같은 안내 카피로 대체 (선택 사항). + +
+ +## 9. 구현 순서 + +### 9.1 1단계 — DB / 도메인 (백엔드 단독 머지 가능) + +1. `init_postgres.sql` 에 컬럼·인덱스 반영 + 마이그레이션 스크립트 작성. +2. `MemberFolder` 엔티티 + Repository 메서드(`findByOwnerMemberIdAndIsSystemTrue`) 추가. +3. `FolderErrorCode` 신규 코드 추가. + +### 9.2 2단계 — 서비스 레이어 + +4. `MemberFolderService#getOrCreateUnorganizedFolderId`, `getDefaultFolder` 추가. +5. `MemberFolderServiceImpl` 의 delete/move 가드 추가. +6. `listRootFolders` 끝에서 `getOrCreateUnorganizedFolderId` 호출. +7. **단위 테스트**: + - `getOrCreateUnorganizedFolderId` 가 idempotent 한가 + - 시스템 폴더 delete/move 차단 + - 시스템 폴더 update(이름 변경) 허용 + - 동시 호출 시 단일 폴더만 생성됨 (부분 유니크 인덱스 검증) + +### 9.3 3단계 — 회원가입 통합 + +8. `CustomOAuth2MemberService` 에 `MemberFolderService` 의존성 주입 + `getOrCreateUnorganizedFolderId` 호출. +9. **통합 테스트**: 신규 OAuth 가입 → 폴더 목록 조회 시 미분류 폴더 1건 보장. + +### 9.4 4단계 — API 계약 + +10. `MemberFolderResponses.isSystem` 필드 추가. +11. 프론트 타입 `FolderResponse.folderType` 추가. + +### 9.5 5단계 — 프론트엔드 UI + +12. `Unorganized` 칩 onClick 연결 (`page.tsx:179`). +13. `FolderCard` 메뉴 가드 + 시각적 표식. +14. 디폴트 폴더 최상단 고정 정렬. +15. `SaveLinkDialog.handleSave` 의 폴더 미선택 → 디폴트 폴더 fallback. + +### 9.6 6단계 — 검증 / QA + +16. E2E: 신규 가입 → 링크 저장(폴더 미선택) → My Links 의 Unorganized 칩 클릭 → 방금 저장한 링크 노출 확인. +17. 기존 회원: 첫 `/my-links` 진입 → 미분류 폴더 자동 생성 확인. +18. 디폴트 폴더 삭제·이동 시 409 반환 / 토스트 메시지 확인. +19. 동시 가입(부하 테스트) 시 디폴트 폴더 중복 생성 없음 확인. + +
+ +## 10. 위험 및 완화 + +| 위험 | 영향 | 완화책 | +| :--- | :--- | :--- | +| 동일 사용자가 같은 이름("미분류") 폴더를 이미 만들어 둠 | `getOrCreateUnorganizedFolderId` 의 INSERT 가 `uq_member_folder_parent_name` 위반 | catch 후 해당 폴더의 `folder_type`을 `UNORGANIZED`로 업데이트하여 흡수, 또는 시스템 폴더 이름을 `미분류 (기본)` 로 회피. 1차에는 흡수 채택. | +| 데이터 기존 오염 가능성 | `member_saved_link` 에 존재하지 않는 폴더 ID가 있으면 FK 추가 시 에러 발생 | 마이그레이션 SQL 실행 전 `SELECT ... WHERE member_folder_id NOT IN (SELECT ...)` 로 전수 조사 및 유효한 폴더(UNORGANIZED 등)로 업데이트 필요 | +| `listRootFolders` 마다 ensure 호출 → 트랜잭션 비용 | RPS 높을 때 약간의 부하 증가 | 7.2 의 옵션 A/B 로 후속 최적화 | +| 사용자가 "미분류" 라는 이름을 일반 폴더에 쓰고 싶어함 | 명명 충돌 혼란 | 시스템 폴더는 `is_system` 으로만 식별, 일반 폴더 이름은 자유. UI에서 시스템 폴더에 별도 아이콘 표기 | +| AI 분류 실패 시 별도 대응 부재 (이번 이터레이션) | UX 미세 결손 | Future Section: AI 실패 시 디폴트 폴더로 자동 fallback | + +
+ +## 11. Future Section + +- **AI 폴더 추천 실패 시 자동 폴백**: `BookmarkService.create` 가 폴더 ID 미결정 상태로 호출되면 디폴트 폴더로 흡수하고 별도 플래그(`category_source='system'` 등)로 회수 가능하게 표시. +- **다국어화**: `미분류` 라는 폴더명을 지역 설정에 따라 표시 (백엔드 저장 이름은 고정, 프론트에서 i18n 변환). +- **자동 정리**: 디폴트 폴더 안의 링크 중 일정 시간이 지난 것을 사용자에게 분류 제안하는 inbox-like UX. + +
+ +## 12. 결론 + +본 계획서는 "미분류" 의 정의를 **`is_system=true` 디폴트 폴더 1개** 로 일원화하고, 그 도입을 다음 5단계로 진행한다. + +- **데이터 모델**: `folder_type` 컬럼 + 부분 유니크 인덱스 + `member_saved_link` FK 추가 +- **서비스**: `getOrCreateUnorganizedFolderId` (idempotent) + delete/move 가드 (타입 기반 식별) +- **회원가입 통합**: OAuth 가입 시 즉시 생성, 기존 회원은 `listRootFolders` 에서 lazy 백필 +- **API/타입**: `folderType` 필드 노출 +- **UI**: `Unorganized` 칩 onClick + `FolderCard` 메뉴 가드 + `SaveLinkDialog` Quick Save 폴백 (타입 기반 식별) + +각 단계가 독립적으로 머지 가능하며, 1~3단계 머지 후 4~5단계가 그 위에 안전하게 빌드된다. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 6ef3513..5f3e631 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -165,6 +165,9 @@ * { @apply border-border outline-ring/50; } + button, [role="button"] { + @apply cursor-pointer; + } body { /* bg, text is handled via layout.tsx classes directly */ } diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx index 8e6db92..2fa9482 100644 --- a/frontend/src/app/my-links/page.tsx +++ b/frontend/src/app/my-links/page.tsx @@ -4,21 +4,54 @@ import { FolderCard } from '@/components/my-links/FolderCard'; import { RightPanel } from '@/components/my-links/RightPanel'; import { useUIStore } from '@/lib/store/uiStore'; import { useFolders } from '@/lib/api/folderApi'; -import { useFolderStore } from '@/lib/store/folderStore'; +import { useFolderStore, type SearchScope } from '@/lib/store/folderStore'; import { useAuthStore } from '@/lib/store/authStore'; import { SortDropdown, SortOption } from '@/components/ui/SortDropdown'; -import { useState } from 'react'; - -const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90']; +import { useBookmarks } from '@/lib/api/bookmarkApi'; +import { useLinkStore } from '@/lib/store/linkStore'; +import { FOLDER_TYPE } from '@/lib/types/folder'; +import { useState, useEffect, useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { compareFolders } from '@/lib/utils/folderUtils'; export default function MyLinksPage() { const { toggleRightPanel } = useUIStore(); const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId); - const memberId = useAuthStore((s) => s.member?.memberId); - const isAuthInitializing = useAuthStore((s) => s.isInitializing); - const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); - - // 인증 정보를 복구 중이거나 아직 폴더 목록을 가져오는 중이면 로딩 상태로 간주 + const selectedFolderId = useFolderStore((s) => s.selectedFolderId); // 현재 선택된 폴더 ID + const searchQuery = useFolderStore((s) => s.searchQuery); // 폴더 검색어 상태 + const setSearchQuery = useFolderStore((s) => s.setSearchQuery); // 폴더 검색어 변경 함수 + const searchScope = useFolderStore((s) => s.searchScope); // 검색 범위 ('folders' | 'all') + const setSearchScope = useFolderStore((s) => s.setSearchScope); // 검색 범위 변경 함수 + const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // '오늘 저장' 필터 상태 + const toggleSavedTodayFilter = useFolderStore((s) => s.toggleSavedTodayFilter); // 필터 토글 함수 + const unreadFilter = useFolderStore((s) => s.unreadFilter); // 'Unread' 필터 상태 (view_count = 0) + const toggleUnreadFilter = useFolderStore((s) => s.toggleUnreadFilter); // Unread 필터 토글 함수 + const unorganizedFilter = useFolderStore((s) => s.unorganizedFilter); // 미분류 필터 상태 + const toggleUnorganizedFilter = useFolderStore((s) => s.toggleUnorganizedFilter); // 미분류 필터 토글 함수 + const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID + const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부 + + // 검색어 디바운스 처리를 위한 로컬 상태 + const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); + + // 외부(스토어)에서 검색어가 직접 변경될 경우(예: 초기화 버튼) 로컬 상태와 동기화 + useEffect(() => { + setLocalSearchQuery(searchQuery); + }, [searchQuery]); + + // 디바운스 로직: localSearchQuery가 변경되면 250ms 후에 스토어 업데이트 + useEffect(() => { + const handler = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery(localSearchQuery); + } + }, 250); + return () => clearTimeout(handler); + }, [localSearchQuery, searchQuery, setSearchQuery]); + + const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회 + + // 인증 또는 폴더 로딩 중 const isLoading = isAuthInitializing || isFoldersLoading; const pinnedFolders = folders?.slice(0, 5) ?? []; @@ -29,6 +62,162 @@ export default function MyLinksPage() { { id: 'count', label: 'Link Count', icon: 'list_alt' } ]; + const scopeOptions: SortOption[] = [ + { id: 'all', label: 'All', icon: 'filter_alt' }, + { id: 'folders', label: 'Folders', icon: 'folder' }, + { id: 'links', label: 'Links', icon: 'link' }, + ]; + + // 상단 고정 폴더 배지 클릭 처리 + const handleFolderBadgeClick = (folderId: number) => { + const currentSelectedId = useFolderStore.getState().selectedFolderId; + if (currentSelectedId === folderId) { + setSelectedFolderId(null); + } else { + setSelectedFolderId(folderId); // 선택된 폴더 변경 + + // 선택 시에만 수행: 현재 폴더 검색어를 링크 검색어로 동기화 + if (searchQuery) { + useLinkStore.getState().setSearchQuery(searchQuery); + } + + // 선택 시에만 수행: 우측 패널 열기 + toggleRightPanel(true); + } + }; + + // 검색 범위가 '전체' 또는 '링크'로 변경될 때 폴더 선택 해제 + useEffect(() => { + if (searchScope === 'all' || searchScope === 'links') { + setSelectedFolderId(null); + } + }, [searchScope, setSelectedFolderId]); + + // 검색어 동기화: 검색 범위가 '전체' 또는 '링크'일 때만 링크 스토어와 검색어 연동 + useEffect(() => { + if (searchScope === 'all' || searchScope === 'links') { + const currentLinkQuery = useLinkStore.getState().filters.searchQuery; + if (currentLinkQuery !== searchQuery) { + useLinkStore.getState().setSearchQuery(searchQuery); + } + } + }, [searchQuery, searchScope]); + + // 검색 필터링 로직 + const isSearching = searchQuery.trim().length > 0; + + // 미분류 폴더 ID 찾기 + const unorganizedFolder = folders?.find(f => f.folderType === FOLDER_TYPE.UNORGANIZED); + const unorganizedFolderId = unorganizedFolder?.memberFolderId; + + // 북마크 데이터 조회 조건 결정: + // 1. 전체 또는 링크 범위 검색 중이거나 + // 2. 오늘 저장 필터가 활성화된 경우 + // 3. Unread 필터가 활성화된 경우 + // 4. 미분류 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴 + // 단, 미분류 필터가 활성화되었지만 아직 폴더 정보(unorganizedFolderId)를 불러오지 못한 경우 요청을 보류합니다. + const shouldFetchBookmarks = + ((searchScope === 'all' || searchScope === 'links') && isSearching) || + savedTodayFilter || + unreadFilter || + (unorganizedFilter && unorganizedFolderId != null); + + const bookmarkParams: Parameters[0] = {}; + if (isSearching && (searchScope === 'all' || searchScope === 'links')) bookmarkParams.query = searchQuery; + if (unreadFilter) bookmarkParams.unreadOnly = true; + if (savedTodayFilter) bookmarkParams.savedTodayOnly = true; + if (unorganizedFilter && unorganizedFolderId) bookmarkParams.folderId = unorganizedFolderId; + const { data: allBookmarks } = useBookmarks( + bookmarkParams, + { enabled: shouldFetchBookmarks } + ); + + // 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N)) + // matchingFolderIdsSet은 백엔드에서 정확하게 필터링된 폴더들 + const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []); + + // --- Folder Logic (Filtering & Sorting) --- + + // 1. 검색/필터링 결과 폴더 목록 메모이제이션 + const processedSearchResultFolders = useMemo(() => { + if (!folders) return []; + + // 필터링 로직 수행 + let filtered = isSearching + ? folders.filter((folder) => { + const queryLower = searchQuery.toLowerCase(); + + // 검색 범위에 따른 매칭 확인 + const matchFolderName = (searchScope === 'all' || searchScope === 'folders') + && folder.folderName.toLowerCase().includes(queryLower); + + const matchBookmark = (searchScope === 'all' || searchScope === 'links') + && matchingFolderIdsSet.has(folder.memberFolderId); + + return matchFolderName || matchBookmark; + }) + : [...folders]; + + // 추가 필터링 (오늘 저장, 안 읽음, 미분류) + if (savedTodayFilter) { + filtered = filtered.filter((f) => matchingFolderIdsSet.has(f.memberFolderId)); + } + if (unreadFilter) { + filtered = filtered.filter((f) => matchingFolderIdsSet.has(f.memberFolderId)); + } + if (unorganizedFilter) { + filtered = filtered.filter((f) => f.folderType === FOLDER_TYPE.UNORGANIZED); + } + + // 최종 정렬 적용 + return [...filtered].sort((a, b) => compareFolders(a, b, folderSort)); + }, [folders, isSearching, searchQuery, searchScope, matchingFolderIdsSet, savedTodayFilter, unreadFilter, unorganizedFilter, folderSort]); + + // 2. 대시보드용 전체 폴더 목록 메모이제이션 (정렬만 적용) + const processedAllFolders = useMemo(() => { + return (folders ?? []).slice().sort((a, b) => compareFolders(a, b, folderSort)); + }, [folders, folderSort]); + + const searchResultFolderCount = processedSearchResultFolders.length; + const matchedLinksCount = allBookmarks?.bookmarks?.length ?? 0; + + // 헤더 메시지 생성 + let headerLabel = ''; + if (unorganizedFilter) { + headerLabel = `Unorganized: ${searchResultFolderCount} folders found`; + } else if (unreadFilter) { + headerLabel = `Unread: ${searchResultFolderCount} folders found`; + } else if (savedTodayFilter) { + headerLabel = `Saved Today: ${searchResultFolderCount} folders found`; + } else if (searchScope === 'all') { + headerLabel = `${searchResultFolderCount} folders & ${matchedLinksCount} links found`; + } else if (searchScope === 'links') { + headerLabel = `${matchedLinksCount} links found in ${searchResultFolderCount} folders`; + } else { + headerLabel = `${searchResultFolderCount} folders found`; + } + + // 필터 또는 검색 활성화 시 결과 표시 + // 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시) + const isShowingFiltered = isSearching || savedTodayFilter || unreadFilter || unorganizedFilter; + const emptyFilteredMessage = (() => { + if (unreadFilter && savedTodayFilter) return 'No unread links saved today'; + if (unorganizedFilter && savedTodayFilter) return 'No unorganized links saved today'; + if (unreadFilter) return 'No unread links'; + if (savedTodayFilter) return 'No links saved today'; + if (unorganizedFilter) return 'No unorganized links'; + if (isSearching) return 'No results match your search'; + return 'No matching folders'; + })(); + + const PINNED_COLORS = [ + 'bg-gradient-to-br from-indigo-500 to-purple-600', + 'bg-gradient-to-br from-blue-500 to-cyan-500', + 'bg-gradient-to-br from-emerald-500 to-teal-600', + 'bg-gradient-to-br from-rose-500 to-pink-600', + 'bg-gradient-to-br from-amber-500 to-orange-600' + ]; + return (
{/* Main Content Area */} @@ -37,19 +226,64 @@ export default function MyLinksPage() { {/* Top Search & Filter Section */}
-
+
- {/* auto_awesome */} + search
setLocalSearchQuery(e.target.value)} + /> + + {/* Search Scope Toggle Dropdown using common component */} + setSearchScope(val as SearchScope)} + options={scopeOptions} + className="shrink-0 mr-1 ml-1" + panelClassName="w-28" + renderTrigger={({ selectedOption, isOpen, toggle }) => ( + + )} /> - + + {searchQuery && ( + + )}
@@ -58,132 +292,174 @@ export default function MyLinksPage() { tune Filter
- - - -
- {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */} - {pinnedFolders.length > 0 && ( -
-

Pinned

-
- {pinnedFolders.map((folder, idx) => ( -
setSelectedFolderId(folder.memberFolderId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedFolderId(folder.memberFolderId); - } - }} - className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`} - > -
-
- folder -
- -
-
-

{folder.folderName}

-
-
+ {/* 검색어 있음 또는 필터 활성화: 폴더 카드 그리드로 검색 결과 표시 */} + {isShowingFiltered && ( +
+
+

+ search + {headerLabel} +

+
+ +
+ {searchResultFolderCount === 0 ? ( +
+ search_off + {emptyFilteredMessage}
- ))} + ) : ( + processedSearchResultFolders.map((folder) => ( + + )) + )}
)} -
-
-

My Folder

-
- -
- - - -
-
- -
- - {/* ── 로딩 상태 ── */} - {isLoading && ( -
-
- progress_activity - 폴더를 불러오는 중... + {/* 검색어 없음 & 필터 미활성화: 기존 대시보드 */} + {!isShowingFiltered && ( + <> + {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */} + {pinnedFolders.length > 0 && ( +
+

Pinned

+
+ {pinnedFolders.map((folder, idx) => ( +
handleFolderBadgeClick(folder.memberFolderId)} + className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer outline-none hover:shadow-md ${ + selectedFolderId === folder.memberFolderId + ? 'ring-2 ring-purple-400 ring-offset-2' + : '' + }`} + > +
+
+ folder +
+ +
+
+

{folder.folderName}

+
+
+
+ ))} +
-
- )} - - {/* ── 에러 상태 ── */} - {error && ( -
-
- error - 폴더 목록을 불러오지 못했습니다: {error.message} + )} + +
+
+

My Folder

-
- )} - - {/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */} - {!isLoading && folders && folders.length === 0 && ( -
-
- folder_off - 아직 생성된 폴더가 없습니다 + +
+ + +
- )} - {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */} - {(folders ? [...folders] : []).sort((a, b) => { - if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId; - if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName); - return 0; - }).map((folder) => ( - - ))} +
+ {/* ── 로딩 상태 ── */} + {isLoading && ( +
+
+ progress_activity + Loading folders... +
+
+ )} -
+ {/* ── 에러 상태 ── */} + {error && ( +
+
+ error + Failed to load folders: {error.message} +
+
+ )} + {/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */} + {!isLoading && folders && folders.length === 0 && ( +
+
+ folder_off + No folders created yet +
+
+ )} + + {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */} + {processedAllFolders.map((folder) => ( + + ))} +
+ + )}
{/* Right Sidebar Panel */} diff --git a/frontend/src/components/dialogs/SaveLinkDialog.tsx b/frontend/src/components/dialogs/SaveLinkDialog.tsx index 5778a9c..d928886 100644 --- a/frontend/src/components/dialogs/SaveLinkDialog.tsx +++ b/frontend/src/components/dialogs/SaveLinkDialog.tsx @@ -12,6 +12,7 @@ import { useCreateBookmark, useAnalyzeUrl } from '@/lib/api/bookmarkApi'; import { useAnalyzeLink } from '@/lib/api/linkAnalysisApi'; import { useAuthStore } from '@/lib/store/authStore'; import type { LinkAnalysisResponse } from '@/lib/types/linkAnalysis'; +import { FOLDER_TYPE } from '@/lib/types/folder'; // 디자인 시안에서 추출한 커스텀 테마 매핑 const theme = { @@ -61,7 +62,7 @@ export function SaveLinkDialog() { // --- API 연동 (React Query Hooks) --- const memberId = useAuthStore((s) => s.member?.memberId); - const { data: folders } = useFolders(memberId); // 기존 폴더 목록 조회 + const { data: folders, isLoading: isFoldersLoading } = useFolders(memberId); // 기존 폴더 목록 조회 const { data: tagsData } = useTags(memberId); // 기존 태그 목록 조회 const createBookmarkMutation = useCreateBookmark(); // 북마크 생성 API 연동 const createFolderMutation = useCreateFolder(); // 폴더 생성 API 연동 @@ -277,7 +278,19 @@ export function SaveLinkDialog() { } ); } else { - saveBookmark(selectedFolderId); + // Quick Save 경로: 폴더 미지정 시 미분류(UNORGANIZED) 폴더로 저장 + const fallbackFolderId = + selectedFolderId + ?? folders?.find((f) => f.folderType === FOLDER_TYPE.UNORGANIZED)?.memberFolderId + ?? null; + + // [추가] 폴더 목록 로딩이 끝났음에도 불구하고 대상 폴더를 결정할 수 없는 경우 예외 처리 + if (fallbackFolderId === null) { + alert('폴더 정보를 불러오는 중이거나 폴더가 존재하지 않습니다. 잠시 후 다시 시도해주세요.'); + return; + } + + saveBookmark(fallbackFolderId); } }; @@ -398,7 +411,7 @@ export function SaveLinkDialog() { link
- 복사한 링크 붙여넣기 + Paste copied link
{/* 부드럽고 존재감 있는 곡선형 SVG 꼬리표 - 크기 확대 및 실루엣 최적화 */} error - URL은 http:// 또는 https://로 시작해야 합니다. + URL must start with http:// or https://

)}
@@ -818,7 +831,7 @@ export function SaveLinkDialog() { {/* Dropdown Menu | 드롭다운 메뉴 (수정/삭제) */} @@ -495,11 +515,38 @@ export function RightPanel() { const { rightPanelOpen } = useUIStore(); // 패널 오픈 여부 const selectedFolderId = useFolderStore((s) => s.selectedFolderId); // 현재 선택된 폴더 ID const memberId = useAuthStore((s) => s.member?.memberId); + const linkSearchQuery = useLinkStore((s) => s.filters.searchQuery); // 현재 검색어 상태 구독 + const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); // 검색어 변경 함수 + const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // 오늘 저장 필터 상태 + const unreadFilter = useFolderStore((s) => s.unreadFilter); // Unread 필터 상태 (view_count = 0) + const unorganizedFilter = useFolderStore((s) => s.unorganizedFilter); // 미분류 필터 상태 + + // 검색어 디바운스 처리를 위한 로컬 상태 + const [pendingSearch, setPendingSearch] = useState(linkSearchQuery); + + // 외부(스토어)에서 검색어가 변경될 경우 로컬 상태와 동기화 + useEffect(() => { + setPendingSearch(linkSearchQuery); + }, [linkSearchQuery]); + + // 디바운스 로직: pendingSearch가 변경되면 250ms 후에 스토어 업데이트 + useEffect(() => { + const handler = setTimeout(() => { + if (pendingSearch !== linkSearchQuery) { + setLinkSearchQuery(pendingSearch); + } + }, 250); + return () => clearTimeout(handler); + }, [pendingSearch, setLinkSearchQuery, linkSearchQuery]); // --- API Data Fetching (React Query) | 서버 데이터 조회 --- const { data: myFolders, isLoading: isFoldersLoading } = useFolders(memberId); // 폴더 목록 const { data: tagsData } = useTags(memberId); // 전체 태그 목록 + // 미분류 폴더 ID 찾기 + const unorganizedFolder = myFolders?.find(f => f.folderType === FOLDER_TYPE.UNORGANIZED); + const unorganizedFolderId = unorganizedFolder?.memberFolderId; + // --- Local UI State | UI 전용 로컬 상태 --- const [selectedTags, setSelectedTags] = useState([]); // 선택된 필터 태그 const [isTagDropdownOpen, setIsTagDropdownOpen] = useState(false); // 태그 필터 드롭다운 오픈 여부 @@ -516,15 +563,59 @@ export function RightPanel() { // UI 정렬 옵션을 백엔드 파라미터로 매핑 const backendSort = sortOption === 'newest' ? 'Newest' as const : sortOption === 'oldest' ? 'Oldest' as const : 'Alphabetical' as const; - // 북마크 데이터 조회 - const { data: bookmarks, isLoading: isBookmarksLoading } = useBookmarks({ - folderId: selectedFolderId, + // 북마크 데이터 조회 (무한 스크롤 페이징 지원) + // 미분류 필터가 켜져 있지만 아직 미분류 폴더 ID를 모를 경우 요청 지연 방어 로직 추가 + const isReadyToFetchBookmarks = !unorganizedFilter || unorganizedFolderId != null; + const { + data: infiniteData, + isLoading: isBookmarksLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useInfiniteBookmarks({ + folderId: unorganizedFilter && unorganizedFolderId ? unorganizedFolderId : selectedFolderId, sort: backendSort, + query: linkSearchQuery, + unreadOnly: unreadFilter || undefined, + savedTodayOnly: savedTodayFilter || undefined, + limit: 100, // 한 페이지당 100개씩 로드 + }, { + enabled: isReadyToFetchBookmarks }); - // API 뮤테이션 (수정/삭제) + // 모든 페이지의 북마크를 하나의 배열로 펼침 + const bookmarks = infiniteData?.pages.flatMap(page => page.bookmarks) ?? []; + const totalCount = infiniteData?.pages[0]?.totalCount ?? 0; + + // 스크롤 하단 감지를 위한 Ref 및 Observer + const observerTarget = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 } // 10% 정도 보일 때 미리 로드 + ); + + if (observerTarget.current) { + observer.observe(observerTarget.current); + } + + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + // API 뮤테이션 (수정/삭제/조회기록) const deleteBookmarkMutation = useDeleteBookmark(); const updateBookmarkMutation = useUpdateBookmark(); + const recordViewMutation = useRecordBookmarkView(); + + /** 링크 카드 클릭 시 조회 기록 (view_count += 1, last_viewed_at = now()) */ + const handleOpenLink = (bookmark: BookmarkResponse) => { + recordViewMutation.mutate(bookmark.bookmarkId); + }; // 대량 편집 관련 상태 const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false); // 헤더 더보기 메뉴 @@ -553,11 +644,38 @@ export function RightPanel() { }; }, [isTagDropdownOpen, isMoreMenuOpen]); - // Client-side tag filtering logic | 태그 필터링 처리 (클라이언트 사이드 필터링) + // Client-side filtering logic (Tags only; date/unread/folder filters are handled by the API) const allLinks = bookmarks ?? []; - const filteredLinks = selectedTags.length > 0 + let filteredLinks = selectedTags.length > 0 ? allLinks.filter(link => link.tags?.some(tag => selectedTags.includes(tag))) : allLinks; + const emptyLinksMessage = (() => { + if (selectedTags.length > 0) return 'No links match selected tags'; + + const activeFilters: string[] = []; + if (unreadFilter) activeFilters.push('unread'); + if (unorganizedFilter) activeFilters.push('unorganized'); + if (savedTodayFilter) activeFilters.push('saved today'); + + const hasQuery = linkSearchQuery.trim().length > 0; + + if (activeFilters.length === 0) { + if (hasQuery) return 'No links match your search'; + return 'No links saved yet'; + } + + const filterText = activeFilters.join(' and '); + + if (hasQuery) { + return `No ${filterText} links match your search`; + } + + // 기본 필터 메시지 (복합 필터의 경우 기존 가독성 유지) + if (unreadFilter && savedTodayFilter && activeFilters.length === 2) return 'No unread links saved today'; + if (unorganizedFilter && savedTodayFilter && activeFilters.length === 2) return 'No unorganized links saved today'; + + return `No ${filterText} links found`; + })(); /** 태그 선택/해제 */ const toggleTag = (tag: string) => { @@ -700,8 +818,24 @@ export function RightPanel() { } }; - // Determine the current folder name | 현재 폴더 이름 결정 - const currentFolderName = myFolders?.find(f => f.memberFolderId === selectedFolderId)?.folderName ?? 'All Links'; + // Determine the display title based on selected folder and active filters | 선택된 폴더 및 활성 필터에 따른 제목 결정 + const folderName = myFolders?.find(f => f.memberFolderId === selectedFolderId)?.folderName; + + const activeFilters: string[] = []; + if (unreadFilter) activeFilters.push('Unread'); + if (savedTodayFilter) activeFilters.push('Saved today'); + if (unorganizedFilter) activeFilters.push('Unorganized'); + + let displayTitle = folderName || 'All Links'; + + if (activeFilters.length > 0) { + const filtersLabel = activeFilters.join(' + '); + if (folderName) { + displayTitle = `${folderName} + ${filtersLabel}`; + } else { + displayTitle = filtersLabel; + } + } if (!rightPanelOpen) return null; @@ -719,9 +853,9 @@ export function RightPanel() {
folder_open
-

{currentFolderName}

+

{displayTitle}

-

{filteredLinks.length} Links

+

{totalCount} Links

{/* Right: Action Buttons & Search | 우측 액션 버튼 및 검색창 */} @@ -779,9 +913,11 @@ export function RightPanel() { {/* Search Bar | 검색창 */}
search - setPendingSearch(e.target.value)} className="bg-transparent border-none focus:outline-none focus:ring-0 text-[9px] text-gray-700 dark:text-gray-200 placeholder-gray-400 w-full p-0 h-4" />
@@ -831,9 +967,19 @@ export function RightPanel() { {tag} ))} @@ -879,14 +1025,25 @@ export function RightPanel() { onUpdateTitle={handleUpdateTitle} onUpdateFolder={handleUpdateFolder} onUpdateTags={handleUpdateTags} + onOpenLink={handleOpenLink} /> )) ) : (
search_off -

{selectedTags.length > 0 ? 'No links match selected tags' : 'No links saved yet'}

+

{emptyLinksMessage}

)} + + {/* Infinite Scroll Trigger & Loader | 무한 스크롤 트리거 및 로딩 표시 */} +
+ {isFetchingNextPage && ( +
+ progress_activity + Loading more... +
+ )} +
@@ -930,9 +1087,19 @@ export function RightPanel() {
@@ -954,9 +1121,19 @@ export function RightPanel() { diff --git a/frontend/src/components/ui/SortDropdown.tsx b/frontend/src/components/ui/SortDropdown.tsx index 34d3bb8..35a043b 100644 --- a/frontend/src/components/ui/SortDropdown.tsx +++ b/frontend/src/components/ui/SortDropdown.tsx @@ -13,6 +13,12 @@ interface SortDropdownProps { options: SortOption[]; className?: string; panelClassName?: string; + align?: 'left' | 'right'; + renderTrigger?: (props: { + selectedOption: SortOption | null; + isOpen: boolean; + toggle: () => void + }) => React.ReactNode; } /** @@ -25,6 +31,8 @@ export function SortDropdown({ options, className = "", panelClassName = "", + align = 'left', + renderTrigger, }: SortDropdownProps) { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -58,30 +66,39 @@ export function SortDropdown({ return (
{/* Trigger Button */} - + {renderTrigger ? ( + renderTrigger({ + selectedOption, + isOpen, + toggle: () => hasOptions && setIsOpen(!isOpen) + }) + ) : ( + + )} {/* Dropdown Menu */} {hasOptions && isOpen && (
@@ -92,7 +109,7 @@ export function SortDropdown({ key={option.id} type="button" onClick={() => handleOptionClick(option.id)} - className="group/item flex items-center gap-2.5 px-3.5 py-2 text-[10px] font-medium hover:bg-purple-50/50 dark:hover:bg-purple-600/20 transition-all text-left w-full relative" + className="group/item flex items-center gap-2.5 px-3 py-1.5 mx-1 rounded-lg text-[10px] font-medium hover:bg-purple-100/60 dark:hover:bg-purple-900/30 transition-all text-left relative" > {option.icon} diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts index 437ff44..52dcb8a 100644 --- a/frontend/src/lib/api/bookmarkApi.ts +++ b/frontend/src/lib/api/bookmarkApi.ts @@ -1,7 +1,8 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, useInfiniteQuery, type InfiniteData } from '@tanstack/react-query'; import { fetchClient } from './fetchClient'; import type { BookmarkResponse, + BookmarkSearchResponse, BookmarkSearchParams, CreateBookmarkRequest, UpdateBookmarkRequest, @@ -11,15 +12,19 @@ import type { * 북마크(링크) 목록 조회 (GET /api/bookmarks) * @param params 검색 필터 (폴더 ID, 정렬, 검색어 등) */ -async function fetchBookmarks(params: BookmarkSearchParams): Promise { +async function fetchBookmarks(params: BookmarkSearchParams): Promise { const searchParams = new URLSearchParams(); if (params.folderId != null) searchParams.set('folderId', String(params.folderId)); if (params.sort) searchParams.set('sort', params.sort); if (params.query) searchParams.set('query', params.query); if (params.categoryId != null) searchParams.set('categoryId', String(params.categoryId)); + if (params.unreadOnly === true) searchParams.set('unreadOnly', 'true'); + if (params.savedTodayOnly === true) searchParams.set('savedTodayOnly', 'true'); + if (params.limit != null) searchParams.set('limit', String(params.limit)); + if (params.offset != null) searchParams.set('offset', String(params.offset)); const qs = searchParams.toString(); - return fetchClient(`/api/bookmarks${qs ? `?${qs}` : ''}`); + return fetchClient(`/api/bookmarks${qs ? `?${qs}` : ''}`); } /** @@ -55,6 +60,73 @@ async function deleteBookmark(bookmarkId: number): Promise { }); } +/** + * 북마크 조회 기록 (PATCH /api/bookmarks/{bookmarkId}/read) + * - 서버에서 view_count += 1, last_viewed_at = now() 처리 + */ +async function recordBookmarkView(bookmarkId: number): Promise { + return fetchClient(`/api/bookmarks/${bookmarkId}/read`, { + method: 'PATCH', + }); +} + +function queryKeyHasUnreadOnly(queryKey: readonly unknown[]): boolean { + return queryKey.some((part) => { + if (!part || typeof part !== 'object' || Array.isArray(part)) return false; + return (part as { unreadOnly?: unknown }).unreadOnly === true; + }); +} + +function updateReadStateInResponse( + data: BookmarkSearchResponse, + updatedBookmark: BookmarkResponse, + removeFromUnreadQuery: boolean, +): BookmarkSearchResponse { + let changed = false; + let removed = false; + const bookmarks = data.bookmarks.flatMap((bookmark) => { + if (bookmark.bookmarkId !== updatedBookmark.bookmarkId) return [bookmark]; + changed = true; + if (removeFromUnreadQuery) { + removed = true; + return []; + } + return [updatedBookmark]; + }); + + if (!changed) return data; + return { + ...data, + bookmarks, + totalCount: removed ? Math.max(data.totalCount - 1, 0) : data.totalCount, + }; +} + +function isInfiniteBookmarkData(data: unknown): data is InfiniteData { + return Boolean(data && typeof data === 'object' && Array.isArray((data as { pages?: unknown }).pages)); +} + +function isBookmarkSearchResponse(data: unknown): data is BookmarkSearchResponse { + return Boolean(data && typeof data === 'object' && Array.isArray((data as { bookmarks?: unknown }).bookmarks)); +} + +function updateReadStateInCacheData( + data: unknown, + updatedBookmark: BookmarkResponse, + removeFromUnreadQuery: boolean, +): unknown { + if (isInfiniteBookmarkData(data)) { + return { + ...data, + pages: data.pages.map((page) => updateReadStateInResponse(page, updatedBookmark, removeFromUnreadQuery)), + }; + } + if (isBookmarkSearchResponse(data)) { + return updateReadStateInResponse(data, updatedBookmark, removeFromUnreadQuery); + } + return data; +} + /** * URL 분석 (GET /api/bookmarks/analyze) * @param url 분석할 URL @@ -71,23 +143,42 @@ async function analyzeUrl(url: string): Promise { /** * [조회 Hook] 북마크 목록을 가져오고 캐싱합니다. */ -export function useBookmarks(params: BookmarkSearchParams) { +export function useBookmarks(params: BookmarkSearchParams, options?: { enabled?: boolean }) { return useQuery({ - queryKey: ['bookmarks', params], // 검색 조건이 바뀌면 새로운 쿼리로 취급 + queryKey: ['bookmarks', params], queryFn: () => fetchBookmarks(params), + enabled: options?.enabled, + }); +} + +/** + * [무한 스크롤 조회 Hook] 북마크 목록을 페이징하여 가져옵니다. + */ +export function useInfiniteBookmarks(params: BookmarkSearchParams, options?: { enabled?: boolean }) { + const limit = params.limit ?? 100; + return useInfiniteQuery({ + queryKey: ['bookmarks', 'infinite', params], + queryFn: ({ pageParam = 0 }) => fetchBookmarks({ ...params, limit, offset: pageParam as number }), + getNextPageParam: (lastPage, allPages) => { + const currentCount = allPages.length * limit; + if (currentCount < lastPage.totalCount) { + return currentCount; + } + return undefined; + }, + initialPageParam: 0, + enabled: options?.enabled, }); } /** * [생성 Hook] 새로운 북마크를 생성합니다. - * 성공 시 'bookmarks' 키를 가진 모든 캐시를 무효화하여 목록을 갱신합니다. */ export function useCreateBookmark() { const queryClient = useQueryClient(); return useMutation({ mutationFn: createBookmark, onSuccess: () => { - // 캐시 무효화: 목록 화면이 자동으로 다시 그려짐 queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); }, }); @@ -119,6 +210,29 @@ export function useDeleteBookmark() { }); } +/** + * [조회 기록 Hook] 북마크 열람 시 view_count 증가 및 읽음 처리. + */ +export function useRecordBookmarkView() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: recordBookmarkView, + onSuccess: (updatedBookmark) => { + const bookmarkQueries = queryClient.getQueryCache().findAll({ queryKey: ['bookmarks'] }); + bookmarkQueries.forEach((query) => { + const removeFromUnreadQuery = queryKeyHasUnreadOnly(query.queryKey); + queryClient.setQueryData(query.queryKey, (oldData: unknown) => + updateReadStateInCacheData(oldData, updatedBookmark, removeFromUnreadQuery) + ); + }); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === 'bookmarks' && queryKeyHasUnreadOnly(query.queryKey), + refetchType: 'none', + }); + }, + }); +} + /** * [분석 Hook] URL에서 제목을 추출합니다. */ diff --git a/frontend/src/lib/dateUtils.ts b/frontend/src/lib/dateUtils.ts new file mode 100644 index 0000000..532cd64 --- /dev/null +++ b/frontend/src/lib/dateUtils.ts @@ -0,0 +1,13 @@ +/** + * 날짜 문자열이 '오늘'인지 확인하는 헬퍼 함수 + */ +export function isCreatedToday(createdAtStr: string): boolean { + if (!createdAtStr) return false; + const createdDate = new Date(createdAtStr); + const today = new Date(); + return ( + createdDate.getFullYear() === today.getFullYear() && + createdDate.getMonth() === today.getMonth() && + createdDate.getDate() === today.getDate() + ); +} diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts index 6fb6494..96cb88e 100644 --- a/frontend/src/lib/store/folderStore.ts +++ b/frontend/src/lib/store/folderStore.ts @@ -1,12 +1,58 @@ import { create } from 'zustand'; +export type SearchScope = 'all' | 'folders' | 'links'; + interface FolderStore { selectedFolderId: number | null; + searchScope: SearchScope; + searchQuery: string; + savedTodayFilter: boolean; + unreadFilter: boolean; + unorganizedFilter: boolean; + setSelectedFolderId: (id: number | null) => void; + setSearchScope: (scope: SearchScope) => void; + setSearchQuery: (query: string) => void; + toggleSavedTodayFilter: () => void; + toggleUnreadFilter: () => void; + toggleUnorganizedFilter: () => void; + clearSearch: () => void; } -export const useFolderStore = create((set) => ({ - selectedFolderId: null, +export const useFolderStore = create((set) => { + // 공통 필터 토글 헬퍼 함수 + const toggleFilter = (state: FolderStore, key: 'savedTodayFilter' | 'unreadFilter' | 'unorganizedFilter') => { + const nextValue = !state[key]; + return { + [key]: nextValue, + // 필터가 활성화될 때 현재 선택된 폴더를 해제 (검색 결과 뷰로 전환 위함) + selectedFolderId: nextValue ? null : state.selectedFolderId, + }; + }; + + return { + selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨) + searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함, 'links': 링크만) + searchQuery: '', // 폴더 목록 필터링용 검색어 + savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부 + unreadFilter: false, // 아직 읽지 않은 링크(view_count = 0)만 표시 여부 + unorganizedFilter: false, // 미분류 폴더만 표시 여부 - setSelectedFolderId: (id) => set({ selectedFolderId: id }), -})); + setSelectedFolderId: (id) => set({ selectedFolderId: id }), + setSearchScope: (scope) => set({ searchScope: scope }), + setSearchQuery: (query) => set({ searchQuery: query }), + + toggleSavedTodayFilter: () => set((state) => toggleFilter(state, 'savedTodayFilter')), + toggleUnreadFilter: () => set((state) => toggleFilter(state, 'unreadFilter')), + toggleUnorganizedFilter: () => set((state) => toggleFilter(state, 'unorganizedFilter')), + + clearSearch: () => set({ + selectedFolderId: null, + searchQuery: '', + searchScope: 'all', + savedTodayFilter: false, + unreadFilter: false, + unorganizedFilter: false, + }), + }; +}); diff --git a/frontend/src/lib/store/linkStore.ts b/frontend/src/lib/store/linkStore.ts index e4e5466..93448aa 100644 --- a/frontend/src/lib/store/linkStore.ts +++ b/frontend/src/lib/store/linkStore.ts @@ -23,6 +23,7 @@ export const useLinkStore = create((set) => ({ sortType: 'latest', }, + // 검색어 상태 업데이트 (다른 컴포넌트 자동 반영) setSearchQuery: (query) => set((state) => ({ filters: { ...state.filters, searchQuery: query } })), diff --git a/frontend/src/lib/types/bookmark.ts b/frontend/src/lib/types/bookmark.ts index 6ff1a7a..72c7abc 100644 --- a/frontend/src/lib/types/bookmark.ts +++ b/frontend/src/lib/types/bookmark.ts @@ -20,12 +20,21 @@ export interface BookmarkResponse { displayTitle: string; note: string | null; primaryCategoryId: number | null; + viewCount: number; + lastViewedAt: string | null; tags: string[]; link: LinkResponse | null; createdAt: string; updatedAt: string | null; } +// 검색 결과 응답 타입 +export interface BookmarkSearchResponse { + bookmarks: BookmarkResponse[]; + matchingFolderIds: number[]; + totalCount: number; +} + // POST /api/bookmarks 요청 바디 export interface CreateBookmarkRequest { memberFolderId?: number | null; @@ -51,4 +60,8 @@ export interface BookmarkSearchParams { sort?: 'Newest' | 'Oldest' | 'Alphabetical'; query?: string; categoryId?: number | null; + unreadOnly?: boolean; + savedTodayOnly?: boolean; + limit?: number; + offset?: number; } diff --git a/frontend/src/lib/types/folder.ts b/frontend/src/lib/types/folder.ts index 6da8119..c9d7d62 100644 --- a/frontend/src/lib/types/folder.ts +++ b/frontend/src/lib/types/folder.ts @@ -2,12 +2,20 @@ export type { ApiResponse } from './apiResponse'; // 백엔드 MemberFolderResponses DTO 대응 +export const FOLDER_TYPE = { + CUSTOM: 'CUSTOM', + UNORGANIZED: 'UNORGANIZED', +} as const; + +export type FolderType = typeof FOLDER_TYPE[keyof typeof FOLDER_TYPE]; + export interface FolderResponse { memberFolderId: number; ownerMemberId: number; parentFolderId: number | null; folderName: string; description: string | null; + folderType: FolderType; } // POST /api/folders 요청 바디 diff --git a/frontend/src/lib/utils/folderUtils.ts b/frontend/src/lib/utils/folderUtils.ts new file mode 100644 index 0000000..4179308 --- /dev/null +++ b/frontend/src/lib/utils/folderUtils.ts @@ -0,0 +1,15 @@ +import { FOLDER_TYPE, type FolderResponse } from '@/lib/types/folder'; + +/** + * 미분류(UNORGANIZED) 폴더를 항상 최상단에 배치하고, + * 그 외의 폴더들은 선택된 정렬 방식에 따라 정렬하는 비교 함수입니다. + */ +export const compareFolders = (a: FolderResponse, b: FolderResponse, sortType?: string) => { + if (a.folderType === FOLDER_TYPE.UNORGANIZED && b.folderType !== FOLDER_TYPE.UNORGANIZED) return -1; + if (b.folderType === FOLDER_TYPE.UNORGANIZED && a.folderType !== FOLDER_TYPE.UNORGANIZED) return 1; + + if (sortType === 'recently') return b.memberFolderId - a.memberFolderId; + if (sortType === 'a-z') return a.folderName.localeCompare(b.folderName); + + return 0; +}; diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java index d22b95e..1fcef4c 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java @@ -2,6 +2,7 @@ import com.web.SearchWeb.bookmark.controller.dto.BookmarkRequests; +import com.web.SearchWeb.bookmark.controller.dto.BookmarkSearchResponse; import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.service.BookmarkService; import com.web.SearchWeb.config.common.ApiResponse; @@ -39,12 +40,12 @@ public ResponseEntity> insertBookmark( Long bookmarkId = bookmarkService.insertBookmark( memberId, - request.url, - request.memberFolderId, - request.displayTitle, - request.note, - request.primaryCategoryId, - request.tags + request.getUrl(), + request.getMemberFolderId(), + request.getDisplayTitle(), + request.getNote(), + request.getPrimaryCategoryId(), + request.getTags() ); return ResponseEntity @@ -67,15 +68,15 @@ public ResponseEntity> selectBookmark( /** - * 북마크 목록 조회 + * 북마크 목록 조회 (검색 결과 및 매칭 폴더 포함) */ @GetMapping - public ResponseEntity>> selectBookmarkList( + public ResponseEntity> selectBookmarkList( @CurrentMemberId Long memberId, @ModelAttribute BookmarkRequests.SearchDto searchDto) { - List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)); - return ResponseEntity.ok(ApiResponse.success(bookmarks)); + BookmarkSearchResponse results = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)); + return ResponseEntity.ok(ApiResponse.success(results)); } @@ -91,11 +92,11 @@ public ResponseEntity> updateBookmark( Long updatedBookmarkId = bookmarkService.updateBookmark( memberId, bookmarkId, - request.memberFolderId, - request.displayTitle, - request.note, - request.primaryCategoryId, - request.tags + request.getMemberFolderId(), + request.getDisplayTitle(), + request.getNote(), + request.getPrimaryCategoryId(), + request.getTags() ); return ResponseEntity.ok(ApiResponse.success(updatedBookmarkId)); } @@ -114,6 +115,19 @@ public ResponseEntity> deleteBookmark( } + /** + * 북마크 조회 기록 + */ + @PatchMapping("/{bookmarkId}/read") + public ResponseEntity> recordView( + @CurrentMemberId Long memberId, + @PathVariable Long bookmarkId) { + + Bookmark updatedBookmark = bookmarkService.recordView(memberId, bookmarkId); + return ResponseEntity.ok(ApiResponse.success(updatedBookmark)); + } + + /** * 북마크 확인 */ diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java index 56e248c..aec5813 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java @@ -3,66 +3,97 @@ import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; import lombok.AllArgsConstructor; -import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; /** - * Bookmark Controller 전용 Request DTO - * - Controller에서만 사용하며, Service 계층에는 개별 파라미터로 전달 + * Bookmark controller request DTOs. */ public class BookmarkRequests { - /** - * 북마크 생성 요청 Dto - */ - public static class CreateDto { - public Long bookmarkId; // 북마크 ID (PK, Insert 시 생성된 키 저장용) - public Long memberFolderId; // 폴더 ID (null이면 기본 폴더) - public String displayTitle; // 표시 제목 - public String url; // 저장할 URL - public String note; // 메모 - public Long primaryCategoryId; // 카테고리 ID - public Long createdByMemberId; // 저장한 회원 - public String tags; // 태그 문자열 (공백/콤마 구분) + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class CreateDto { + private Long bookmarkId; + private Long memberFolderId; + private String displayTitle; + private String url; + private String note; + private Long primaryCategoryId; + private Long createdByMemberId; + private String tags; } - - /** - * 북마크 목록 조회 요청 Dto (Search Params) - */ - @Data + @Getter + @Setter @NoArgsConstructor @AllArgsConstructor public static class SearchDto { - public Long folderId; - public String sort = "Newest"; // 기본값 설정 - public String query; - public Long categoryId; + private static final int DEFAULT_LIMIT = 100; + private static final int MAX_LIMIT = 200; + + private Long folderId; + private String sort = "Newest"; + private String query; + private Long categoryId; + private Boolean unreadOnly; + private Boolean savedTodayOnly; + private Integer limit; + private Integer offset; public BookmarkSearchCommand toCommand(Long memberId) { return BookmarkSearchCommand.builder() - .memberId(memberId) - .folderId(this.folderId) - .sort(this.sort) - .query(this.query) - .categoryId(this.categoryId) - .build(); + .memberId(memberId) + .folderId(this.folderId) + .sort(this.sort) + .query(normalizeQuery(this.query)) + .categoryId(this.categoryId) + .unreadOnly(Boolean.TRUE.equals(this.unreadOnly)) + .savedTodayOnly(Boolean.TRUE.equals(this.savedTodayOnly)) + .limit(normalizeLimit(this.limit)) + .offset(normalizeOffset(this.offset)) + .build(); } - } + private static int normalizeLimit(Integer limit) { + if (limit == null) { + return DEFAULT_LIMIT; + } + return Math.max(1, Math.min(limit, MAX_LIMIT)); + } - /** - * 북마크 수정 요청 Dto - */ - @Data + private static int normalizeOffset(Integer offset) { + if (offset == null) { + return 0; + } + return Math.max(0, offset); + } + + private static String normalizeQuery(String query) { + if (query == null) { + return null; + } + String trimmed = query.trim(); + if (trimmed.isEmpty()) { + return null; + } + return trimmed + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_"); + } + } + + @Getter @NoArgsConstructor @AllArgsConstructor public static class UpdateDto { - public Long memberFolderId; // 폴더 ID - public String displayTitle; // 표시 제목 - public String note; // 메모 - public Long primaryCategoryId; // 카테고리 ID - public String tags; // 태그 문자열 (공백/콤마 구분) + private Long memberFolderId; + private String displayTitle; + private String note; + private Long primaryCategoryId; + private String tags; } - } diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java new file mode 100644 index 0000000..1958473 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkSearchResponse.java @@ -0,0 +1,28 @@ +package com.web.SearchWeb.bookmark.controller.dto; + +import com.web.SearchWeb.bookmark.domain.Bookmark; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 북마크 검색 결과 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookmarkSearchResponse { + + // 검색된 북마크 목록 + private List bookmarks; + + // 검색 조건(쿼리)에 매칭되는 링크가 하나라도 포함된 폴더 ID 목록 + private List matchingFolderIds; + + // 전체 검색 결과 개수 + private int totalCount; +} diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java index 5c1111d..7dd161d 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java @@ -4,6 +4,7 @@ import com.web.SearchWeb.bookmark.domain.Link; import com.web.SearchWeb.bookmark.dto.MemberTagResultDto; import com.web.SearchWeb.bookmark.service.command.BookmarkSearchCommand; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -12,25 +13,37 @@ public interface BookmarkDao { int insertBookmark(Bookmark bookmark); //북마크 단일 조회 - Bookmark selectBookmark(Long memberId, Long bookmarkId); + Bookmark selectBookmark(@Param("memberId") Long memberId, @Param("bookmarkId") Long bookmarkId); + + //북마크 기본 조회 (ID 기반, 권한 체크용) + Bookmark findById(@Param("bookmarkId") Long bookmarkId); //북마크 목록 조회 List selectBookmarkList(BookmarkSearchCommand searchCommand); + // 검색 조건에 매칭되는 북마크들이 속한 고유 폴더 ID 목록 조회 + List selectMatchingFolderIds(BookmarkSearchCommand searchCommand); + + // 검색 조건에 맞는 전체 북마크 개수 조회 + int countBookmarkList(BookmarkSearchCommand searchCommand); + //북마크 수정 int updateBookmark(Bookmark bookmark); //북마크 삭제 - int deleteBookmark(Long memberId, Long bookmarkId); + int deleteBookmark(@Param("memberId") Long memberId, @Param("bookmarkId") Long bookmarkId); + + //북마크 조회수 증가 (읽음 처리) + int incrementViewCount(@Param("bookmarkId") Long bookmarkId, @Param("memberId") Long memberId); //북마크 태그 연결 삭제 - int deleteBookmarkTags(Long bookmarkId, Long memberId); + int deleteBookmarkTags(@Param("bookmarkId") Long bookmarkId, @Param("memberId") Long memberId); //북마크 삭제 (Link ID 기반 - soft delete) - int deleteBookmarkByLink(Long memberId, Long linkId); + int deleteBookmarkByLink(@Param("memberId") Long memberId, @Param("linkId") Long linkId); //북마크 링크 중복 확인 (동일 폴더에 동일 링크) - int checkBookmarkExists(Long memberId, Long folderId, Long linkId); + int checkBookmarkExists(@Param("memberId") Long memberId, @Param("folderId") Long folderId, @Param("linkId") Long linkId); //상세 링크 조회 (정규화된 URL 기반) Link selectLinkByCanonicalUrl(String url); @@ -39,13 +52,13 @@ public interface BookmarkDao { int insertLink(Link link); //URL 기반 북마크 존재 여부 확인 (Board Bridge용) - int checkBookmarkExistsByUrl(Long memberId, String url); + int checkBookmarkExistsByUrl(@Param("memberId") Long memberId, @Param("url") String url); // 태그 등록 및 조회 (Insert & Select) - List insertAndSelectTags(Long memberId, List tagNames); + List insertAndSelectTags(@Param("memberId") Long memberId, @Param("tagNames") List tagNames); // 북마크-태그 연결 일괄 추가 (Bulk Insert) - int insertBookmarkTags(Long bookmarkId, List tagIds); + int insertBookmarkTags(@Param("bookmarkId") Long bookmarkId, @Param("tagIds") List tagIds); // 폴더 내 활성 북마크 존재 여부 boolean existsActiveBookmarkInFolder(Long memberFolderId); diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java index aad5784..31ac004 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java @@ -38,16 +38,35 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) { return mapper.selectBookmark(memberId, bookmarkId); } - /** - * 북마크 목록 조회 + * 북마크 기본 조회 (ID 기반, 권한 체크용) */ + @Override + public Bookmark findById(Long bookmarkId) { + return mapper.findById(bookmarkId); + } + + @Override public List selectBookmarkList(BookmarkSearchCommand searchCommand) { return mapper.selectBookmarkList(searchCommand); } + /** + * 매칭 폴더 ID 목록 조회 + */ + @Override + public List selectMatchingFolderIds(BookmarkSearchCommand searchCommand) { + return mapper.selectMatchingFolderIds(searchCommand); + } + + @Override + public int countBookmarkList(BookmarkSearchCommand searchCommand) { + return mapper.countBookmarkList(searchCommand); + } + + /** * 북마크 수정 */ @@ -75,6 +94,15 @@ public int deleteBookmarkTags(Long bookmarkId, Long memberId) { } + /** + * 북마크 조회수 증가 (읽음 처리) + */ + @Override + public int incrementViewCount(Long bookmarkId, Long memberId) { + return mapper.incrementViewCount(bookmarkId, memberId); + } + + /** * 북마크 삭제 (Link ID 기반 - soft delete) */ diff --git a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java index 0dd050b..d36f143 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java +++ b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java @@ -1,6 +1,7 @@ package com.web.SearchWeb.bookmark.domain; import java.math.BigDecimal; +import java.time.OffsetDateTime; import java.util.List; import com.web.SearchWeb.common.domain.BaseEntity; @@ -33,6 +34,8 @@ public class Bookmark extends BaseEntity { private Long primaryCategoryId; // 주 카테고리 ID (선택, AI 분류 또는 사용자 지정) private String categorySource; // 카테고리 출처 ('system', 'member', default 'system') private BigDecimal categoryScore; // 카테고리 정확도 점수 (0.0 ~ 1.0) + private Integer viewCount; // 조회 수 (클릭 1회당 +1, 0이면 unread) + private OffsetDateTime lastViewedAt; // 마지막 조회 시각 (NULL이면 아직 안 읽음) // Link 객체 (Association) private Link link; // Link 테이블과 조인된 객체 diff --git a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java index 360ea47..3be2591 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java +++ b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum BookmarkErrorCode implements ErrorCode { DUPLICATE_BOOKMARK(HttpStatus.CONFLICT, "B001", "이미 존재하는 북마크입니다."), - BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다."); + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "B003", "해당 북마크에 대한 권한이 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java index 08fa037..4e9c6df 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java @@ -1,5 +1,6 @@ package com.web.SearchWeb.bookmark.service; +import com.web.SearchWeb.bookmark.controller.dto.BookmarkSearchResponse; import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.domain.Link; // import com.web.SearchWeb.bookmark.dto.BoardBookmarkCheckDto; @@ -17,8 +18,8 @@ Long insertBookmark(Long memberId, String url, Long memberFolderId, String displ //북마크 단일 조회 Bookmark selectBookmark(Long memberId, Long bookmarkId); - //북마크 목록 조회 - List selectBookmarkList(BookmarkSearchCommand command); + //북마크 목록 조회 (검색 결과 및 매칭 폴더 포함) + BookmarkSearchResponse selectBookmarkList(BookmarkSearchCommand command); //북마크 수정 Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String displayTitle, @@ -27,6 +28,9 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String //북마크 삭제 Long deleteBookmark(Long memberId, Long bookmarkId); + //북마크 조회 기록 + Bookmark recordView(Long memberId, Long bookmarkId); + // 링크 조회 또는 생성 (URL 정규화) Link getOrCreateLink(String url, Long createdByMemberId); diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java index d370a9c..5113ad9 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java @@ -1,5 +1,6 @@ package com.web.SearchWeb.bookmark.service; +import com.web.SearchWeb.bookmark.controller.dto.BookmarkSearchResponse; import com.web.SearchWeb.bookmark.dao.BookmarkDao; import com.web.SearchWeb.bookmark.domain.Bookmark; import com.web.SearchWeb.bookmark.domain.Link; @@ -17,6 +18,7 @@ import com.web.SearchWeb.config.exception.CommonErrorCode; import com.web.SearchWeb.bookmark.dto.MemberTagResultDto; +import com.web.SearchWeb.folder.service.MemberFolderService; import com.web.SearchWeb.linkanalysis.service.LinkMetadataExtractor; import java.net.URI; import java.util.ArrayList; @@ -32,11 +34,13 @@ public class BookmarkServiceImpl implements BookmarkService { private final BookmarkDao bookmarkDao; private final LinkMetadataExtractor linkMetadataExtractor; + private final MemberFolderService memberFolderService; @Autowired - public BookmarkServiceImpl(BookmarkDao bookmarkDao, LinkMetadataExtractor linkMetadataExtractor) { + public BookmarkServiceImpl(BookmarkDao bookmarkDao, LinkMetadataExtractor linkMetadataExtractor, MemberFolderService memberFolderService) { this.bookmarkDao = bookmarkDao; this.linkMetadataExtractor = linkMetadataExtractor; + this.memberFolderService = memberFolderService; } @@ -50,10 +54,9 @@ public Long insertBookmark(Long memberId, String url, Long memberFolderId, Strin // 링크 조회 또는 생성 Link link = getOrCreateLink(url, memberId); - // TODO: 링크 분석 및 폴더 서비스 완성 후 제거 - 임시 기본 폴더 ID 설정 + // 폴더 미지정 시 사용자의 미분류(UNORGANIZED) 폴더 ID를 조회하거나 생성 if (memberFolderId == null) { - memberFolderId = 1L; // 임시 하드코딩 값 - log.warn("memberFolderId가 null이어서 임시 기본값(1)을 사용합니다. 링크 분석 및 폴더 서비스 연동 후 제거 필요."); + memberFolderId = memberFolderService.getOrCreateUnorganizedFolderId(memberId); } // Entity 생성 @@ -93,6 +96,9 @@ public Long insertBookmark(Long memberId, String url, Long memberFolderId, Strin public Bookmark selectBookmark(Long memberId, Long bookmarkId) { Bookmark bookmark = bookmarkDao.selectBookmark(memberId, bookmarkId); if (bookmark == null) { + // 상세 조회를 통한 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); + // 위에서 예외가 발생하지 않았다면 (그럴 리 없지만) 기본 NotFound 처리 throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } return bookmark; @@ -100,11 +106,19 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) { /** - * 북마크 목록 조회 + * 북마크 목록 조회 (검색 결과 및 매칭 폴더 포함) */ @Override - public List selectBookmarkList(BookmarkSearchCommand command) { - return bookmarkDao.selectBookmarkList(command); + public BookmarkSearchResponse selectBookmarkList(BookmarkSearchCommand command) { + List bookmarks = bookmarkDao.selectBookmarkList(command); + List matchingFolderIds = bookmarkDao.selectMatchingFolderIds(command); + int totalCount = bookmarkDao.countBookmarkList(command); + + return BookmarkSearchResponse.builder() + .bookmarks(bookmarks) + .matchingFolderIds(matchingFolderIds) + .totalCount(totalCount) + .build(); } @@ -130,6 +144,8 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, int result = bookmarkDao.updateBookmark(bookmark); if (result == 0) { + // 수정 실패 시 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } @@ -152,6 +168,22 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, } + /** + * 북마크 조회 기록 (view_count 증가, last_viewed_at 업데이트) + */ + @Override + @Transactional + public Bookmark recordView(Long memberId, Long bookmarkId) { + int affected = bookmarkDao.incrementViewCount(bookmarkId, memberId); + if (affected == 0) { + // 업데이트 실패 시 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); + throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + return bookmarkDao.selectBookmark(memberId, bookmarkId); + } + + /** * 북마크 삭제 (soft delete) */ @@ -162,6 +194,8 @@ public Long deleteBookmark(Long memberId, Long bookmarkId) { int result = bookmarkDao.deleteBookmark(memberId, bookmarkId); if (result == 0) { + // 삭제 실패 시 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } @@ -174,6 +208,20 @@ public Long deleteBookmark(Long memberId, Long bookmarkId) { + /** + * 북마크 소유권 및 존재 여부 검증 (에러 구분용) + */ + private void validateBookmarkOwner(Long bookmarkId, Long memberId) { + Bookmark bookmark = bookmarkDao.findById(bookmarkId); + if (bookmark == null || bookmark.getDeletedAt() != null) { + throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + if (!bookmark.getCreatedByMemberId().equals(memberId)) { + throw BookmarkException.of(BookmarkErrorCode.ACCESS_DENIED); + } + } + + // ========== Helper Methods ========== /** * 링크 조회 또는 생성 (URL 정규화) diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java index 8ac2ce3..a53748d 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java @@ -1,5 +1,6 @@ package com.web.SearchWeb.bookmark.service.command; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -9,8 +10,8 @@ * Bookmark 검색 요청 Command */ @Getter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder public class BookmarkSearchCommand { private Long memberId; // created_by_member_id로 필터 @@ -18,4 +19,10 @@ public class BookmarkSearchCommand { private String sort; // 정렬 기준 (Newest, Oldest) private String query; // 검색어 (display_title 검색) private Long categoryId; // primary_category_id로 필터 (태그 대체) + private Boolean unreadOnly; // true이면 view_count = 0 인 북마크만 조회 + private Boolean savedTodayOnly; // true이면 오늘 생성된 북마크만 조회 + @Builder.Default + private Integer limit = 100; // 최대 조회 개수 (Limit) + @Builder.Default + private Integer offset = 0; // 조회 시작 위치 (Offset) } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java b/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java index fb6655d..8d1a9b8 100644 --- a/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java +++ b/src/main/java/com/web/SearchWeb/folder/controller/dto/MemberFolderResponses.java @@ -12,6 +12,7 @@ public class MemberFolderResponses { private final Long parentFolderId; private final String folderName; private final String description; + private final String folderType; // Entity -> DTO 변환을 위한 정적 팩토리 메서드 public static MemberFolderResponses from(MemberFolder folder) { @@ -21,6 +22,7 @@ public static MemberFolderResponses from(MemberFolder folder) { .parentFolderId(folder.getParentFolderId()) .folderName(folder.getFolderName()) .description(folder.getDescription()) + .folderType(folder.getFolderType().name()) .build(); } } diff --git a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java index fdafe18..2a88282 100644 --- a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java +++ b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java @@ -1,7 +1,9 @@ package com.web.SearchWeb.folder.dao; +import com.web.SearchWeb.folder.domain.FolderType; import com.web.SearchWeb.folder.domain.MemberFolder; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberFolderJpaDao extends JpaRepository { @@ -20,4 +22,12 @@ public interface MemberFolderJpaDao extends JpaRepository { boolean existsByOwnerMemberIdAndParentFolderIdAndFolderName(Long ownerMemberId, Long parentFolderId, String normalizedFolderName); boolean existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName); + + boolean existsByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType); + + // 시스템 폴더(UNORGANIZED) 조회 — 한 사용자당 최대 1개 (partial unique index 로 보장) + Optional findFirstByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType); + + // 루트에 같은 이름 폴더가 이미 있는지 조회 (흡수(absorb) 처리에 사용) + Optional findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName); } diff --git a/src/main/java/com/web/SearchWeb/folder/domain/FolderType.java b/src/main/java/com/web/SearchWeb/folder/domain/FolderType.java new file mode 100644 index 0000000..5766722 --- /dev/null +++ b/src/main/java/com/web/SearchWeb/folder/domain/FolderType.java @@ -0,0 +1,9 @@ +package com.web.SearchWeb.folder.domain; + +/** + * 폴더의 타입을 정의하는 Enum. + */ +public enum FolderType { + CUSTOM, // 사용자가 직접 생성한 일반 폴더 + UNORGANIZED // 시스템에서 자동 생성/관리하는 미분류 폴더 +} diff --git a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java index 7ae81b8..0604400 100644 --- a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java +++ b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java @@ -1,5 +1,7 @@ package com.web.SearchWeb.folder.domain; +import com.web.SearchWeb.folder.error.FolderErrorCode; +import com.web.SearchWeb.folder.error.FolderException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -12,6 +14,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @@ -32,6 +37,20 @@ public class MemberFolder { private String folderName; @Column(name = "description", length = 500) private String description; + @Enumerated(EnumType.STRING) + @Column(name = "folder_type", nullable = false, length = 20) + @Builder.Default + private FolderType folderType = FolderType.CUSTOM; + + public boolean isUnorganized() { + return FolderType.UNORGANIZED.equals(this.folderType); + } + + /** 기존 폴더를 시스템 폴더(UNORGANIZED)로 마킹. 이름 충돌 흡수(absorb) 경로에서 사용. */ + public void markAsUnorganized() { + if (isUnorganized()) return; + this.folderType = FolderType.UNORGANIZED; + } public void changeInfo(String folderName, String description) { if (folderName == null || folderName.isBlank()) { @@ -42,6 +61,9 @@ public void changeInfo(String folderName, String description) { } public void changeParent(Long newParentFolderId) { + if (isUnorganized()) { + throw new FolderException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE); + } this.parentFolderId = newParentFolderId; } } diff --git a/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java b/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java index 5d0e72e..366da96 100644 --- a/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java +++ b/src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java @@ -13,7 +13,9 @@ public enum FolderErrorCode implements ErrorCode { FOLDER_FORBIDDEN(HttpStatus.FORBIDDEN,"Foo3" ,"접근이 제한된 폴더입니다." ), INVALID_FOLDER_NAME(HttpStatus.BAD_REQUEST,"F004","폴더명으로 적절하지 않습니다" ), INVALID_FOLDER_MOVE(HttpStatus.BAD_REQUEST, "F005", "유효하지 않은 폴더 이동입니다."), - FOLDER_NOT_EMPTY(HttpStatus.BAD_REQUEST, "F006", "하위 폴더 또는 북마크가 남아 있어 삭제할 수 없습니다."); + FOLDER_NOT_EMPTY(HttpStatus.BAD_REQUEST, "F006", "하위 폴더 또는 북마크가 남아 있어 삭제할 수 없습니다."), + SYSTEM_FOLDER_IMMUTABLE(HttpStatus.CONFLICT, "F007", "기본 폴더는 삭제하거나 이동할 수 없습니다."), + DEFAULT_FOLDER_NAME_CONFLICT(HttpStatus.CONFLICT, "F008", "같은 이름의 폴더가 이미 존재하여 기본 폴더를 생성할 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java index 073047d..f3f8eda 100644 --- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java +++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java @@ -2,7 +2,6 @@ import com.web.SearchWeb.folder.domain.MemberFolder; import java.util.List; -import java.util.Optional; public interface MemberFolderService { @@ -19,4 +18,10 @@ public interface MemberFolderService { void move(Long memberId, Long memberFolderId, Long newParentFolderId); void delete(Long memberId, Long memberFolderId); + + /** 없으면 생성하고, 있으면 기존 ID를 반환. idempotent. (미분류(UNORGANIZED) 폴더를 보장) */ + Long getOrCreateUnorganizedFolderId(Long memberId); + + /** 미분류 폴더를 단건으로 반환한다. 없으면 getOrCreate 경로로 생성 후 반환. */ + MemberFolder getDefaultFolder(Long memberId); } diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java index 98d8324..bae1262 100644 --- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java @@ -2,20 +2,36 @@ import com.web.SearchWeb.bookmark.dao.BookmarkDao; import com.web.SearchWeb.folder.dao.MemberFolderJpaDao; +import com.web.SearchWeb.folder.domain.FolderType; import com.web.SearchWeb.folder.domain.MemberFolder; import com.web.SearchWeb.folder.error.FolderErrorCode; import com.web.SearchWeb.folder.error.FolderException; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class MemberFolderServiceImpl implements MemberFolderService { + private static final String UNORGANIZED_FOLDER_NAME = "미분류"; + private final MemberFolderJpaDao memberFolderJpaRepository; private final BookmarkDao bookmarkDao; + private MemberFolderService self; + + @Autowired + public void setSelf(@Lazy MemberFolderService self) { + this.self = self; + } @Override @Transactional @@ -100,6 +116,10 @@ public MemberFolder get(Long memberId, Long memberFolderId) { @Transactional(readOnly = true) public List listRootFolders(Long memberId, Long ownerMemberId) { validateOwner(memberId, ownerMemberId); + // 기존 회원 대상 lazy 백필: 조회 호출 시 미분류 폴더가 없으면 이 시점에 생성 + if (!memberFolderJpaRepository.existsByOwnerMemberIdAndFolderType(ownerMemberId, FolderType.UNORGANIZED)) { + self.getOrCreateUnorganizedFolderId(ownerMemberId); + } return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId); } @@ -151,6 +171,10 @@ public void update(Long memberId, Long memberFolderId, String folderName, String public void move(Long memberId, Long memberFolderId, Long newParentFolderId) { MemberFolder folder = getOwnedFolder(memberId, memberFolderId); + if (folder.isUnorganized()) { + throw new FolderException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE); + } + if ((folder.getParentFolderId() == null && newParentFolderId == null) || (folder.getParentFolderId() != null && folder.getParentFolderId().equals(newParentFolderId))) { return; @@ -193,7 +217,11 @@ public void move(Long memberId, Long memberFolderId, Long newParentFolderId) { @Override @Transactional public void delete(Long memberId, Long memberFolderId) { - getOwnedFolder(memberId, memberFolderId); + MemberFolder folder = getOwnedFolder(memberId, memberFolderId); + + if (folder.isUnorganized()) { + throw new FolderException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE); + } if (memberFolderJpaRepository.existsByParentFolderId(memberFolderId) || bookmarkDao.existsActiveBookmarkInFolder(memberFolderId)) { @@ -203,6 +231,52 @@ public void delete(Long memberId, Long memberFolderId) { memberFolderJpaRepository.deleteById(memberFolderId); } + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Long getOrCreateUnorganizedFolderId(Long memberId) { + // 1. 이미 시스템 폴더가 존재하면 해당 ID 반환 (idempotent) + Optional existing = memberFolderJpaRepository + .findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED); + if (existing.isPresent()) { + return existing.get().getMemberFolderId(); + } + + // 2 & 3. 동시 호출은 partial unique index 로 DB 에서 차단됨 -> catch 후 재조회 + try { + // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환 + Optional sameName = memberFolderJpaRepository + .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME); + if (sameName.isPresent()) { + MemberFolder existingFolder = sameName.get(); + existingFolder.markAsUnorganized(); + return memberFolderJpaRepository.save(existingFolder).getMemberFolderId(); + } + + // 3. 신규 생성 + MemberFolder newFolder = MemberFolder.builder() + .ownerMemberId(memberId) + .folderName(UNORGANIZED_FOLDER_NAME) + .folderType(FolderType.UNORGANIZED) + .build(); + return memberFolderJpaRepository.save(newFolder).getMemberFolderId(); + + } catch (DataIntegrityViolationException e) { + // 동시 요청으로 인한 중복 생성/전환 방지: 제약 조건 위반 시 이미 생성된 폴더를 재조회하여 반환 + return memberFolderJpaRepository + .findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED) + .orElseThrow(() -> new FolderException(FolderErrorCode.DEFAULT_FOLDER_NAME_CONFLICT)) + .getMemberFolderId(); + } + } + + @Override + @Transactional + public MemberFolder getDefaultFolder(Long memberId) { + Long id = getOrCreateUnorganizedFolderId(memberId); + return memberFolderJpaRepository.findById(id) + .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)); + } + private MemberFolder getOwnedFolder(Long memberId, Long memberFolderId) { MemberFolder folder = memberFolderJpaRepository.findById(memberFolderId) .orElseThrow(() -> new FolderException(FolderErrorCode.FOLDER_NOT_FOUND)); diff --git a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java index 0d077ee..2675547 100644 --- a/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java +++ b/src/main/java/com/web/SearchWeb/member/service/CustomOAuth2MemberService.java @@ -1,6 +1,7 @@ package com.web.SearchWeb.member.service; import com.web.SearchWeb.auth.error.AuthErrorCode; +import com.web.SearchWeb.folder.service.MemberFolderService; import com.web.SearchWeb.member.dao.MemberDao; import com.web.SearchWeb.member.domain.Member; import com.web.SearchWeb.member.dto.CustomOAuth2Member; @@ -9,6 +10,7 @@ import com.web.SearchWeb.member.dto.Response.NaverResponse; import com.web.SearchWeb.member.dto.Response.OAuth2Response; import com.web.SearchWeb.member.error.MemberErrorCode; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; @@ -24,13 +26,16 @@ * 소셜 로그인(OAuth2) 사용자 정보 처리 및 DB 동기화 서비스. * -DefaultOAuth2UserService를 상속받아 소셜 서비스로부터 받은 유저 데이터를 가공함. */ +@Slf4j @Service public class CustomOAuth2MemberService extends DefaultOAuth2UserService { private final MemberDao memberDao; + private final MemberFolderService memberFolderService; - public CustomOAuth2MemberService(MemberDao memberDao) { + public CustomOAuth2MemberService(MemberDao memberDao, MemberFolderService memberFolderService) { this.memberDao = memberDao; + this.memberFolderService = memberFolderService; } @@ -92,6 +97,14 @@ else if ("kakao".equals(registrationId)) { ); } } + + // 신규 가입자의 미분류 폴더 보장 (실패해도 가입 흐름은 진행 — listRootFolders lazy 경로에서 재시도됨) + try { + memberFolderService.getOrCreateUnorganizedFolderId(existMember.getMemberId()); + } catch (Exception e) { + log.warn("default folder creation failed on social signup (memberId={}): {}", + existMember.getMemberId(), e.getMessage()); + } } else { // 소셜 제공자 정보가 실제로 바뀐 경우에만 최소 필드만 동기화(변경감지) diff --git a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java index 481fb2b..c4a0f7b 100644 --- a/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java +++ b/src/main/java/com/web/SearchWeb/mypage/controller/MyPageController.java @@ -104,7 +104,7 @@ public ResponseEntity> insertBookmark(@PathVariable final Lo public ResponseEntity>> getBookmarks( @PathVariable final Long memberId, @ModelAttribute BookmarkRequests.SearchDto searchDto) { - List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)); + List bookmarks = bookmarkService.selectBookmarkList(searchDto.toCommand(memberId)).getBookmarks(); return ResponseEntity.ok(ApiResponse.success(bookmarks)); } @@ -133,11 +133,11 @@ public ResponseEntity> updateBookmark( Long updatedBookmarkId = bookmarkService.updateBookmark( memberId, bookmarkId, - request.memberFolderId, - request.displayTitle, - request.note, - request.primaryCategoryId, - request.tags + request.getMemberFolderId(), + request.getDisplayTitle(), + request.getNote(), + request.getPrimaryCategoryId(), + request.getTags() ); return ResponseEntity.ok(ApiResponse.success(updatedBookmarkId)); } diff --git a/src/main/resources/db/init_postgres.sql b/src/main/resources/db/init_postgres.sql index 2bb0fa4..335176a 100644 --- a/src/main/resources/db/init_postgres.sql +++ b/src/main/resources/db/init_postgres.sql @@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS "member_folder" ( "parent_folder_id" int, "folder_name" varchar(80) NOT NULL, "description" text, + "folder_type" varchar(20) DEFAULT 'CUSTOM' NOT NULL, "created_at" timestamptz DEFAULT now() NOT NULL, "updated_at" timestamptz DEFAULT now() NOT NULL, "deleted_at" timestamptz, @@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS "member_folder" ( "updated_by_member_id" bigint, "deleted_by_member_id" bigint, CONSTRAINT pk_member_folder PRIMARY KEY ("member_folder_id"), + CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED')), CONSTRAINT fk_member_folder_parent_folder_id FOREIGN KEY ("parent_folder_id") REFERENCES "member_folder"("member_folder_id"), CONSTRAINT fk_member_folder_owner_member_id @@ -396,6 +398,8 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" ( "primary_category_id" int, "category_source" varchar(10) DEFAULT 'system' NOT NULL, "category_score" numeric(5,4), + "view_count" integer DEFAULT 0 NOT NULL, + "last_viewed_at" timestamptz, "created_at" timestamptz DEFAULT now() NOT NULL, "updated_at" timestamptz DEFAULT now() NOT NULL, "deleted_at" timestamptz, @@ -405,10 +409,16 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" ( CONSTRAINT pk_member_saved_link PRIMARY KEY ("member_saved_link_id"), CONSTRAINT ck_member_saved_link_category_source CHECK (category_source in ('system','member')), CONSTRAINT ck_member_saved_link_category_score CHECK (category_score is null or (category_score between 0 and 1)), + CONSTRAINT ck_member_saved_link_view_count_nonneg CHECK (view_count >= 0), CONSTRAINT fk_member_saved_link_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id"), - CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id") + CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id"), + CONSTRAINT fk_member_saved_link_member_folder_id FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id") ); +-- (idempotent) 기존 DB에 대해 view_count / last_viewed_at 컬럼 추가 +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; + CREATE TABLE IF NOT EXISTS "link_enrichment_feedback" ( "link_enrichment_feedback_id" int GENERATED ALWAYS AS IDENTITY NOT NULL, "link_enrichment_id" int NOT NULL, @@ -572,6 +582,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_parent_name CREATE INDEX IF NOT EXISTS idx_member_folder_name ON "member_folder" ("folder_name"); CREATE INDEX IF NOT EXISTS idx_member_folder_created ON "member_folder" ("created_at"); CREATE INDEX IF NOT EXISTS idx_member_folder_deleted ON "member_folder" ("deleted_at"); +-- 한 사용자당 시스템 폴더(UNORGANIZED)는 정확히 1개만 활성 상태로 존재 +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; CREATE INDEX IF NOT EXISTS idx_member_tag_owner ON "member_tag" ("owner_member_id"); CREATE INDEX IF NOT EXISTS idx_member_tag_deleted_at ON "member_tag" ("deleted_at"); diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml index 26bb2e1..5fa1092 100644 --- a/src/main/resources/mapper/bookmark-mapper.xml +++ b/src/main/resources/mapper/bookmark-mapper.xml @@ -17,6 +17,8 @@ + + @@ -83,12 +85,40 @@ + + + + + + + + + + + + + + + AND msl.created_by_member_id = #{memberId} + AND msl.deleted_at IS NULL + + AND msl.member_folder_id = #{folderId} + + + AND msl.primary_category_id = #{categoryId} + + + AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' ESCAPE '\' + + + AND msl.view_count = 0 + + + AND msl.created_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul') + AND msl.created_at < ((date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') + INTERVAL '1 day') AT TIME ZONE 'Asia/Seoul') + + + + + + + + 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 + +