Skip to content

Feat/139 모임홈, 모임 관리, 책장 ,책장 상세 ,정기모임(+운영진페이지들)#147

Merged
hongik-luke merged 36 commits intodevfrom
feat-139
Feb 27, 2026
Merged

Feat/139 모임홈, 모임 관리, 책장 ,책장 상세 ,정기모임(+운영진페이지들)#147
hongik-luke merged 36 commits intodevfrom
feat-139

Conversation

@hongik-luke
Copy link
Contributor

@hongik-luke hongik-luke commented Feb 27, 2026

📌 개요 (Summary)

commit [feat : 모임홈 API 연결]~[fix : build 오류 수정]

[모임 홈화면]
나의 상태 조회 GET /api/clubs/{clubId}/me -> 관리자 유무

모임 홈 화면 GET /api/clubs/{clubId}/home

가장 최근 공지사항 1개 조회 GET /api/clubs/{clubId}/notices/latest

이번 모임 바로가기 GET /api/clubs/{clubId}/meetings/next


commit [chore : 책장 ENDPOINT,type 등]~[fix : build error 수정]

[모임 관리]
page1 : 모임 가입 신청 관리
독서 모임 회원 관리(받아오기) : GET /api/clubs/{clubId}/members
독서 모임 회원 등급 수정(수정하기) : PATCH /api/clubs/{clubId}/members/{clubMemberId}

page2 : 모임 회원 관리
독서 모임 회원 관리(받아오기) : GET /api/clubs/{clubId}/members
독서 모임 회원 등급 수정(수정하기) : PATCH /api/clubs/{clubId}/members/{clubMemberId}

UI짜기
page3 : 모임 수정 (생성 페이지 사용 UI만들기)
[운영진] 독서 모임 상세 조회 GET /api/clubs/{clubId}
[운영진] 독서 모임 정보 수정 PUT /api/clubs/{clubId}


[책장 홈]
책장 간편 조회 GET /api/clubs/{clubId}/bookshelves?cursorId=[마지막으로 조회한 책장 id]

[책장 생성]
책 검색하기 GET /api/books/search?keyword=[검색할 단어]&page=[검색할 페이지] -> 책 등록
책장 생성 POST /api/clubs/{clubId}/bookshelves

[책장 수정] (책장 상세에서 이동, UI 내가 제작해야함)
책장 수정 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}/edit
책장 수정 PATCH /api/clubs/{clubId}/bookshelves/{meetingId} -> 수정되어야하는 요소 변경


commit [chore : image 추가] ~ [feat : 책장 상세페이지 관리자일경우 뜨는 버튼]

나의 상태 조회 GET /api/clubs/{clubId}/me
책장 삭제 DELETE /api/clubs/{clubId}/bookshelves/{meetingId}

[책장 상세 - 발제]
책장 상세 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}
책장에 대한 발제 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}/topics?cursorId=[마지막으로 조회한 발제 id]

발제 등록 POST /api/clubs/{clubId}/bookshelves/{meetingId}/topics
발제 수정 PATCH /api/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}
발제 삭제 DELETE /api/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}

[책장 상세 - 한줄평]
책장 상세 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}
책장에 대한 한줄평 조회 /api/clubs/{clubId}/bookshelves/{meetingId}/reviews?cursorId=[마지막으로 조회한 한줄평 id]

한줄평 생성 POST/api/clubs/{clubId}/bookshelves/{meetingId}/reviews
한줄평 수정 PATCH /api/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}
한줄평 삭제 DELETE /api/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}

[책장 상세 - 정기모임]
책장 상세 조회 GET /api/clubs/{clubId}/bookshelves/{meetingId}
정기모임 조회 GET /api/clubs/{clubId}/meetings/{meetingId}

[책장 상세 - 정기모임(조 관리 페이지)]
조 관리 - 독서모임 회원 전체 조회 GET /api/clubs/{clubId}/meetings/{meetingId}/members
정기모임 조 관리 PUT /api/clubs/{clubId}/meetings/{meetingId}/teams


🛠️ 변경 사항 (Changes)

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링

📸 스크린샷 (Screenshots)

이거 웬만하면 다 스크릿샷찍고 하고 싶은데 너무 많아서 직접 UI 보셔야합니다.

✅ 체크리스트 (Checklist)

  • 빌드가 성공적으로 수행되었나요? (pnpm build)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added admin edit functionality for group details and settings
    • Introduced bookshelf creation, editing, and deletion capabilities
    • Enabled topic and review management with edit and delete options
    • Added member applicant approval/rejection workflows
    • Implemented meeting team management interface
  • Refactor

    • Replaced mock data with real API data fetching and improved pagination
    • Enhanced error handling and loading states across features

Copilot AI review requested due to automatic review settings February 27, 2026 01:36
@vercel
Copy link

vercel bot commented Feb 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
check-mo Ready Ready Preview, Comment Feb 27, 2026 8:26pm
checkmo Ready Ready Preview, Comment Feb 27, 2026 8:26pm

@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-139

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 Pull Request는 모임 관련 기능의 백엔드 API 통합을 통해 전반적인 사용자 경험과 운영 효율성을 크게 향상시킵니다. 특히 모임 홈, 회원 관리, 책장 관리 기능이 실제 데이터 기반으로 전환되었으며, 모임 및 책장 정보 수정 페이지가 새로 도입되어 운영진의 관리 편의성이 증대되었습니다. 이를 통해 더 안정적이고 기능적인 모임 플랫폼을 제공합니다.

Highlights

  • 모임 관리 기능 강화: 모임 가입 신청 관리 및 모임 회원 관리 페이지가 새로운 API를 통합하여 더 효율적으로 작동하도록 개선되었습니다. 더미 데이터 대신 실제 API 호출을 통해 데이터를 처리하며, 무한 스크롤 및 회원 상태 변경 기능이 추가되었습니다.
  • 책장 및 모임 정보 수정 페이지 추가: 책장 상세 정보 수정 페이지와 모임 정보 수정 페이지가 새로 추가되었습니다. 이를 통해 운영진은 모임의 세부 정보와 책장 정보를 직접 편집할 수 있게 되었으며, 관련 API 연동 및 유효성 검사가 구현되었습니다.
  • 모임 홈 및 책장 목록 API 연동: 모임 홈 화면과 책장 목록 페이지가 더미 데이터 대신 실제 API를 통해 데이터를 가져오도록 변경되었습니다. 최신 공지사항, 다음 모임 정보, 그리고 책장 목록의 무한 스크롤 기능이 추가되어 사용자 경험이 향상되었습니다.
  • 공통 컴포넌트 및 유틸리티 개선: 책 선택 모달, 카테고리 태그, 책장 카드 등 여러 UI 컴포넌트가 새로운 데이터 구조와 기능에 맞춰 업데이트되었습니다. 날짜 포맷팅 유틸리티와 이미지 처리 로직도 개선되었습니다.
  • 새로운 API 엔드포인트 및 서비스 계층 추가: 클럽 멤버 관리, 클럽 홈 정보, 책장 생성 및 수정 등 다양한 기능에 대한 새로운 API 엔드포인트와 서비스 계층(service, query, mutation hooks)이 추가되어 백엔드와의 연동이 강화되었습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • .env.example
    • API 엔드포인트 설정이 제거되었습니다.
  • src/app/groups/[id]/admin/applicant/page.tsx
    • 모임 가입 신청 관리 페이지에서 더미 데이터가 제거되고 API 호출을 통한 실제 데이터 처리가 구현되었습니다.
    • 무한 스크롤 기능과 회원 상태 업데이트 뮤테이션이 통합되었습니다.
    • 날짜 포맷팅 유틸리티가 추가되었습니다.
  • src/app/groups/[id]/admin/bookcase/[meetingId]/edit/layout.tsx
    • 책장 수정 페이지를 위한 레이아웃 파일이 추가되었습니다.
  • src/app/groups/[id]/admin/bookcase/[meetingId]/edit/page.tsx
    • 책장 상세 정보를 수정하는 새로운 페이지가 구현되었습니다.
    • API를 통한 데이터 조회 및 업데이트 기능과 입력 유효성 검사가 추가되었습니다.
    • 책 정보는 수정할 수 없도록 제한되었습니다.
  • src/app/groups/[id]/admin/bookcase/new/page.tsx
    • 책장 생성 페이지에서 새로운 API를 사용하도록 리팩토링되었습니다.
    • 날짜 입력 처리 로직이 개선되었고, 태그 선택 로직이 단일 선택으로 변경되었습니다.
  • src/app/groups/[id]/admin/edit/layout.tsx
    • 모임 관리자 수정 페이지를 위한 레이아웃 파일이 추가되었습니다.
  • src/app/groups/[id]/admin/edit/page.tsx
    • 모임 상세 정보를 수정하는 새로운 페이지가 구현되었습니다.
    • API를 통한 모임 정보 조회 및 업데이트, 이미지 업로드, 이름 중복 확인 로직이 통합되었습니다.
  • src/app/groups/[id]/admin/members/page.tsx
    • 모임 회원 관리 페이지에서 더미 데이터가 제거되고 API 호출을 통한 실제 데이터 처리가 구현되었습니다.
    • 무한 스크롤 기능과 회원 역할 및 상태 업데이트 뮤테이션이 통합되었습니다.
    • 회원 프로필 이미지 안전 처리 및 날짜 포맷팅 유틸리티가 추가되었습니다.
  • src/app/groups/[id]/bookcase/page.tsx
    • 책장 목록 페이지에서 목업 데이터가 제거되고 API 호출을 통한 실제 데이터 처리가 구현되었습니다.
    • 무한 스크롤 기능과 운영진 여부에 따른 Floating Action Button 표시 로직이 추가되었습니다.
  • src/app/groups/[id]/dummy.ts
    • 더미 데이터 파일이 제거되었습니다.
  • src/app/groups/[id]/layout.tsx
    • 그룹 상세 레이아웃에서 탭 타입 및 링크 경로가 새로운 라우팅 구조에 맞춰 업데이트되었습니다.
  • src/app/groups/[id]/page.tsx
    • 모임 홈 페이지에서 더미 데이터가 제거되고 API 호출을 통한 실제 데이터 처리가 구현되었습니다.
    • 최신 공지사항, 다음 모임 정보, 그리고 Contact US 링크 처리 로직이 통합되었습니다.
  • src/app/groups/page.tsx
    • 모임 검색 및 추천 페이지에서 클럽 카테고리 매핑 로직이 업데이트되었고, API 응답에 맞춰 데이터 변환 로직이 수정되었습니다.
  • src/components/base-ui/BookStory/bookstory_choosebook.tsx
    • 책 선택 컴포넌트가 클릭 가능하도록 변경되었으며, 명시적인 '변경하기' 버튼이 제거되었습니다.
  • src/components/base-ui/Bookcase/BookcaseCard.tsx
    • 책장 카드 컴포넌트의 카테고리 타입 정의가 업데이트되었습니다.
  • src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx
    • 클럽 카테고리 태그 컴포넌트가 숫자 배열과 DTO 객체 형태의 카테고리 데이터를 모두 지원하도록 개선되었습니다.
  • src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx
    • 클럽 요약 정보 타입(ClubSummary)의 카테고리 필드가 ClubCategoryDTO[]를 사용하도록 업데이트되었습니다.
  • src/components/base-ui/Group/group_admin_menu.tsx
    • 모임 관리자 메뉴에 '모임 수정' 버튼이 추가되었고, 메뉴 높이가 확장되었습니다.
  • src/components/base-ui/Search/search_bookresult.tsx
    • 책 검색 결과 컴포넌트의 UI/UX가 개선되었습니다. 카드 클릭 시 호버 효과가 추가되었고, 좋아요 버튼 및 연필 아이콘의 상호작용이 개선되었습니다.
  • src/components/layout/BookSelectModal.tsx
    • 책 선택 모달에서 책 링크를 새 탭으로 열 수 있는 기능이 추가되었고, 카드 및 연필 클릭 핸들러가 업데이트되었습니다.
  • src/hooks/mutations/useClubAdminEditMutations.ts
    • 클럽 관리자 정보 업데이트를 위한 새로운 React Query 뮤테이션 훅이 추가되었습니다.
  • src/hooks/mutations/useClubMemberMutations.ts
    • 클럽 멤버 상태 업데이트를 위한 새로운 React Query 뮤테이션 훅이 추가되었습니다.
  • src/hooks/mutations/useClubsBookshelfMutations.ts
    • 책장 생성 및 수정을 위한 새로운 React Query 뮤테이션 훅이 추가되었습니다.
  • src/hooks/queries/useClubAdminEditQueries.ts
    • 클럽 관리자 상세 정보를 가져오기 위한 새로운 React Query 훅이 추가되었습니다.
  • src/hooks/queries/useClubMemberQueries.ts
    • 클럽 멤버 목록을 무한 스크롤로 가져오기 위한 새로운 React Query 훅이 추가되었습니다.
  • src/hooks/queries/useClubhomeQueries.ts
    • 클럽 홈, 최신 공지사항, 다음 모임 정보를 가져오기 위한 새로운 React Query 훅이 추가되었습니다.
  • src/hooks/queries/useClubsBookshelfQueries.ts
    • 책장 목록(무한 스크롤), 책 검색(무한 스크롤), 책장 수정 상세 정보를 가져오기 위한 새로운 React Query 훅이 추가되었습니다.
  • src/lib/api/endpoints/Clubs.ts
    • 클럽 관련 API 엔드포인트가 확장되어 멤버 상태, 홈, 공지사항, 모임, 관리자 상세/업데이트 경로가 추가되었습니다.
  • src/lib/api/endpoints/ClubsBookshelf.ts
    • 책장 관련 API 엔드포인트가 추가되어 목록 조회, 책 검색, 책장 생성, 수정 경로가 정의되었습니다.
  • src/lib/api/endpoints/index.ts
    • 새로운 ClubsBookshelf 엔드포인트가 내보내기 목록에 추가되었습니다.
  • src/services/clubMemberService.ts
    • 클럽 멤버 API와 상호작용하기 위한 새로운 서비스가 추가되었습니다.
  • src/services/clubService.ts
    • 클럽 서비스가 확장되어 홈, 공지사항, 모임, 관리자 상세/업데이트 API 호출 기능이 추가되었습니다.
  • src/services/clubsBookshelfService.ts
    • 책장 API와 상호작용하기 위한 새로운 서비스가 추가되었습니다.
  • src/types/bookshelf.ts
    • 책장 관련 데이터 구조, 요청 및 응답을 위한 새로운 타입 정의가 추가되었습니다.
  • src/types/groups/bookcasehome.ts
    • groupByGeneration 유틸리티 함수가 제거되었습니다.
  • src/types/groups/clubAdminEdit.ts
    • 클럽 관리자 수정 요청 및 응답을 위한 새로운 타입 정의가 추가되었습니다.
  • src/types/groups/clubMembers.ts
    • 클럽 멤버 관리, 상태 필터, 업데이트 요청을 위한 새로운 타입 정의가 추가되었습니다.
  • src/types/groups/grouphome.ts
    • 클럽 홈 관련 타입이 새로운 API 응답에 맞춰 리팩토링되었습니다. 나의 상태, 홈 상세, 최신 공지, 다음 모임에 대한 별도 타입이 정의되었습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

이번 PR은 더미 데이터를 실제 API 연동으로 전환하는 대규모 기능 구현 및 리팩터링입니다. React-Query를 사용한 훅, 서비스, 타입 분리 구조가 매우 훌륭합니다. 로딩/에러 상태 처리, 무한 스크롤, 상황에 맞는 UI 등 새로운 기능들이 견고하게 구현되었습니다. 몇 가지 사소한 UI 텍스트 일관성 및 쿼리 무효화 로직에 대한 개선 제안이 있습니다. 전반적으로 훌륭한 작업입니다!

open: open === true,
};

await updateClub.mutateAsync(payload as any);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

타입 불일치로 인해 as any 타입 단언이 사용되고 있습니다. UpdateClubAdminRequest 타입의 profileImageUrlstring | null로 변경하면 타입 안정성을 높이고 as any를 제거할 수 있습니다. 관련하여 src/types/groups/clubAdminEdit.ts 파일에 수정을 제안하는 코멘트를 남겼습니다.

export type UpdateClubAdminRequest = {
name: string;
description: string;
profileImageUrl: string;
Copy link
Contributor

Choose a reason for hiding this comment

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

high

모임 수정 페이지(EditClubPage)에서 profileImageUrlnull로 설정할 수 있는 로직이 있습니다. 하지만 UpdateClubAdminRequest 타입에서는 string으로만 정의되어 있어 타입 불일치가 발생하고, 이로 인해 as any 타입 단언이 사용되고 있습니다. profileImageUrl 타입을 string | null로 변경하여 타입 안정성을 높이는 것을 권장합니다.

Suggested change
profileImageUrl: string;
profileImageUrl: string | null;

: 'bg-primary-2 text-White hover:opacity-90'
}`}
>
등록
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

책장 수정 페이지의 제출 버튼 텍스트가 '등록'으로 되어 있습니다. 사용자에게 더 명확한 의미를 전달할 수 있도록 '수정' 또는 '저장'으로 변경하는 것을 제안합니다.

Suggested change
등록
수정

</div>

<div className="mx-auto w-full max-w-[1440px] px-4 t:px-6 py-3 t:pt-3 t:pb-8">
<div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8">
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

px-auto는 유효하지 않은 Tailwind CSS 클래스입니다. 의도하신 스타일이 padding-left: auto; padding-right: auto;가 아니라면, px-0 등으로 수정하거나 제거하는 것을 고려해 보세요. mx-auto가 이미 적용되어 있어 수평 가운데 정렬은 되고 있습니다.

Suggested change
<div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8">
<div className="mx-auto w-full max-w-[1440px] t:px-6 py-3 t:pt-3 t:pb-8">

@hongik-luke hongik-luke changed the title Feat 139 Feat/139 Feb 27, 2026
@hongik-luke hongik-luke changed the title Feat/139 Feat/139 모임홈, 모임 관리, 책장 ,책장 상세 ,정기모임(+운영진페이지들) Feb 27, 2026
Invalidate queries for both PENDING and ALL club members after updating status.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/groups/[id]/admin/applicant/page.tsx (1)

71-87: ⚠️ Potential issue | 🟡 Minor

Improve accessibility for JoinMessageModal backdrop.

The backdrop <div> with onClick is not keyboard accessible. Users navigating with keyboards cannot close the modal by clicking the backdrop.

🔧 Proposed fix using button element
 function JoinMessageModal({ isOpen, onClose, message }: JoinMessageModalProps) {
   if (!isOpen) return null;

   return (
-    <div
-      className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4"
-      onClick={onClose}
-    >
+    <div className="fixed inset-0 z-50 flex items-center justify-center px-4">
+      <button
+        type="button"
+        onClick={onClose}
+        className="absolute inset-0 bg-black/30"
+        aria-label="닫기"
+      />
       <div
         className="w-100 h-45 rounded-lg bg-White p-6 overflow-y-auto"
-        onClick={(e) => e.stopPropagation()}
       >
         <p className="body_1_2 text-Gray-4 whitespace-pre-wrap">{message}</p>
       </div>
     </div>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 71 - 87, The
backdrop div in JoinMessageModal is not keyboard accessible; update the
component so the backdrop element can be activated via keyboard — either replace
the backdrop div with a semantic interactive element (e.g., a button) or add
role="button", tabIndex={0} and an onKeyDown handler that calls onClose for
Enter/Space and also handle Escape to close, while preserving the existing
onClick and stopPropagation behavior on the inner content; ensure these changes
are made inside the JoinMessageModal function and keep props isOpen, onClose,
and message unchanged.
🟡 Minor comments (12)
src/app/groups/[id]/layout.tsx-92-92 (1)

92-92: ⚠️ Potential issue | 🟡 Minor

px-auto is not a valid Tailwind CSS class.

CSS padding properties do not accept auto as a value. This class will have no effect and may cause confusion. If horizontal centering is intended, mx-auto is already applied on parent elements. If padding is needed, use a specific value like px-4 or px-6.

🔧 Proposed fix
-      <div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8">
+      <div className="mx-auto w-full max-w-[1440px] t:px-6 py-3 t:pt-3 t:pb-8">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/layout.tsx at line 92, The div rendering the page
container uses an invalid Tailwind class "px-auto" (see the className string
"mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8"); replace
"px-auto" with a valid padding utility (e.g., "px-6" or "px-4") or remove it if
horizontal centering is already handled by "mx-auto" — update the className on
that container element in layout.tsx accordingly.
src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx-50-84 (1)

50-84: ⚠️ Potential issue | 🟡 Minor

Mixed array type handling could be fragile.

The type discrimination using typeof list[0] === "number" assumes homogeneous arrays. If an array contains both numbers and DTOs, the behavior would be incorrect—numbers would be treated as DTOs.

🛡️ Suggested defensive check
  const list = Array.isArray(category) ? category : [];

- // ✅ number[]로 들어온 경우 (기존)
- if (list.length > 0 && typeof list[0] === "number") {
+ // ✅ number[]로 들어온 경우 (기존) - all items must be numbers
+ const allNumbers = list.length > 0 && list.every((item) => typeof item === "number");
+ if (allNumbers) {
    const nums = Array.from(new Set(list as number[]))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx`
around lines 50 - 84, The component ClubCategoryTags currently decides the
numeric-ID path by checking typeof list[0] === "number", which fails for mixed
arrays; change that check to verify all elements are numbers (e.g.,
Array.isArray(category) && list.every((x) => typeof x === "number")) or
explicitly extract numeric IDs via list.filter((x): x is number => typeof x ===
"number") and use that nums array for LABEL/getBgByNumberCategory rendering;
ensure you reference Props/category, LABEL, getBgByNumberCategory and return
null if no valid nums after filtering.
src/types/groups/bookcasedetail.ts-59-62 (1)

59-62: ⚠️ Potential issue | 🟡 Minor

Inconsistent nullability for profileImageUrl between similar types.

In src/types/groups/bookcasedetail.ts, the MemberInfo type (line 3) has profileImageUrl: string | null, but the MeetingMemberInfo type (line 61) defines it as non-nullable string. This inconsistency is problematic since both represent member information and the codebase already includes defensive null-handling patterns (e.g., ?? DEFAULT_PROFILE, ?? null conversions) suggesting the API may return null values.

Update MeetingMemberInfo to match the nullable definition:

Suggested fix
 export type MeetingMemberInfo = {
   nickname: string;
-  profileImageUrl: string;
+  profileImageUrl: string | null;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/groups/bookcasedetail.ts` around lines 59 - 62, The
MeetingMemberInfo type's profileImageUrl is currently non-nullable while
MemberInfo uses string | null; change MeetingMemberInfo.profileImageUrl to be
nullable (string | null) to match MemberInfo and existing null-handling patterns
(update the MeetingMemberInfo type definition where declared and any related
type aliases or imports if present).
src/app/groups/page.tsx-73-73 (1)

73-73: ⚠️ Potential issue | 🟡 Minor

Avoid blind casting for participantTypes.

p.code as ParticipantType bypasses validation; unknown codes can leak into UI state. Prefer a mapper/type guard before assignment.

💡 Suggested fix
+const PARTICIPANT_TYPES: ParticipantType[] = ["대학생", "직장인", "기타"]; // 실제 유니온 값으로 교체
+const isParticipantType = (v: string): v is ParticipantType =>
+  PARTICIPANT_TYPES.includes(v as ParticipantType);

 function mapClubDTOToSummary(club: ClubDTO, myStatus: string, reason = ""): ClubSummary {
   return {
@@
-    participantTypes: club.participantTypes.map((p) => p.code as ParticipantType).filter(Boolean),
+    participantTypes: club.participantTypes.map((p) => p.code).filter(isParticipantType),
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/page.tsx` at line 73, The current blind cast in
participantTypes (club.participantTypes.map((p) => p.code as
ParticipantType).filter(Boolean)) can introduce invalid values; replace the cast
with a safe mapper/type-guard: implement an isParticipantType(code: string):
code is ParticipantType (or a mapParticipantCode that returns
ParticipantType|undefined) and use club.participantTypes.map(p =>
isParticipantType(p.code) ? p.code : undefined).filter(Boolean) so only
validated ParticipantType values end up in participantTypes.
src/app/groups/[id]/bookcase/page.tsx-99-104 (1)

99-104: ⚠️ Potential issue | 🟡 Minor

Fix iconAlt to match the button's purpose.

The iconAlt is "문의하기" (Contact/Inquiry) but the button navigates to bookcase creation. This is misleading for screen reader users.

🔧 Proposed fix
         <FloatingFab
           iconSrc="/icons_pencil.svg"
-          iconAlt="문의하기"
+          iconAlt="새 책장 추가"
           onClick={() => router.push(`/groups/${groupId}/admin/bookcase/new`)}
         />

Apply to both occurrences (lines 100-104 and 148-152).

Also applies to: 147-153

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 99 - 104, The
FloatingFab's iconAlt is incorrect for the action; update the iconAlt prop on
both FloatingFab instances (the ones rendering when isStaff with onClick={() =>
router.push(`/groups/${groupId}/admin/bookcase/new`)}) to describe "Create
bookcase" (or the appropriate localized label for bookcase creation) so screen
readers reflect the actual button purpose; change the iconAlt value wherever
FloatingFab is used for bookcase creation (both occurrences).
src/app/groups/[id]/bookcase/page.tsx-53-68 (1)

53-68: ⚠️ Potential issue | 🟡 Minor

adaptedResponse has inconsistent pagination metadata.

The hasNext and nextCursor values are taken from the first page, but mergedBookShelfInfoList contains items from all fetched pages. After fetching multiple pages, hasNext will be stale (reflecting the first page's state, not the last).

If adaptedResponse is required for type compatibility, consider using the last page's metadata:

🔧 Proposed fix
   const adaptedResponse: BookcaseApiResponse | null = useMemo(() => {
     if (!data?.pages?.length) return null;

-    const first = data.pages[0];
+    const lastPage = data.pages[data.pages.length - 1];

     return {
       isSuccess: true,
       code: "COMMON200",
       message: "성공입니다.",
       result: {
         bookShelfInfoList: mergedBookShelfInfoList,
-        hasNext: Boolean(first.hasNext),
-        nextCursor: first.nextCursor == null ? null : String(first.nextCursor),
+        hasNext: Boolean(lastPage.hasNext),
+        nextCursor: lastPage.nextCursor == null ? null : String(lastPage.nextCursor),
       },
     };
   }, [data, mergedBookShelfInfoList]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 53 - 68, adaptedResponse
is using the first page's pagination metadata while mergedBookShelfInfoList
contains all pages, causing stale hasNext/nextCursor; update the logic in the
useMemo that builds adaptedResponse to derive hasNext and nextCursor from the
last page (e.g., const last = data.pages[data.pages.length - 1]) instead of
first, and keep the existing null-safe handling for nextCursor (last.nextCursor
== null ? null : String(last.nextCursor)) so pagination reflects the full merged
result.
src/app/groups/[id]/admin/bookcase/new/page.tsx-169-175 (1)

169-175: ⚠️ Potential issue | 🟡 Minor

Avoid catch (e: any) in mutation error handling.

Use unknown and narrow the type before accessing nested properties.

💡 Suggested fix
-    } catch (e: any) {
+    } catch (e: unknown) {
       console.error(e);
-      const msg =
-        e?.response?.data?.message ||
-        e?.message ||
-        '책장 생성에 실패했습니다.';
+      const err = e as { response?: { data?: { message?: string } }; message?: string };
+      const msg = err.response?.data?.message || err.message || '책장 생성에 실패했습니다.';
       toast.error(msg);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/new/page.tsx around lines 169 - 175,
Replace the catch (e: any) with catch (e: unknown) and narrow the error before
reading nested properties: create a small type-guard or helper (e.g.,
getErrorMessage(error: unknown): string) that checks for Axios-like shape (error
is object && 'response' in error && typeof (error as any).response === 'object')
and for message fields, fallback to typeof error === 'string' or error
instanceof Error, then return a safe string; use that helper in the catch block
(the throw/catch around the bookcase creation logic in page.tsx) and pass the
returned string into toast.error instead of directly accessing
e?.response?.data?.message.
src/app/groups/[id]/bookcase/[bookId]/page.tsx-239-239 (1)

239-239: ⚠️ Potential issue | 🟡 Minor

Remove as any casts and type the useMemo hooks directly against component props.

Both topicItems and reviewItems are correctly shaped for their respective components but use as any to bypass type checking. Apply proper typing to restore compile-time safety:

Suggested typing pattern
+import type { DebateItem } from "./DebateSection";
+import type { ReviewItem } from "./ReviewSection";

-  const topicItems = useMemo(() => {
+  const topicItems = useMemo<DebateItem[]>(() => {
     const list = topicsQuery.data?.pages.flatMap((p) => p.topicDetailList) ?? [];
     return list.map((t) => ({
       ...
     }));
   }, [topicsQuery.data]);

-  const reviewItems = useMemo(() => {
+  const reviewItems = useMemo<ReviewItem[]>(() => {
     const list = reviewsQuery.data?.pages.flatMap((p) => p.bookReviewDetailList) ?? [];
     return list.map((r) => ({
       ...
     }));
   }, [reviewsQuery.data]);

-                items={topicItems as any}
+                items={topicItems}

-                items={reviewItems as any}
+                items={reviewItems}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/page.tsx at line 239, Remove the
unsafe "as any" casts on topicItems and reviewItems and instead type the useMemo
hooks to return the exact prop types expected by the target components: annotate
the useMemo that produces topicItems with the TopicList/TopicItem prop type
(e.g., TopicItemType[] or TopicListProps["items"]) and annotate the useMemo that
produces reviewItems with the ReviewList/ReviewItem prop type (e.g.,
ReviewItemType[] or ReviewListProps["items"]); import or reference the component
prop types used by the child components, change the useMemo signatures to use
those generics so the returned arrays are strongly typed, and remove the "as
any" casts from the items={topicItems} and items={reviewItems} usages.
src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx-37-38 (1)

37-38: ⚠️ Potential issue | 🟡 Minor

Remove any casts on params and API response properties.

Lines 37–38, 60, 66 use unnecessary any casts that disable type safety. params from useParams() already matches the expected type for toNumber(), and API response properties (existingTeams, clubMembers) are fully typed.

Suggested fix
-  const clubId = toNumber(params?.id as any);
-  const meetingId = toNumber(params?.meetingId as any);
+  const clubId = toNumber(params?.id);
+  const meetingId = toNumber(params?.meetingId);

-    const existingTeamNumbers =
-      data.existingTeams?.map((t: any) => Number(t.teamNumber)).filter(Number.isFinite) ?? [];
+    const existingTeamNumbers =
+      data.existingTeams?.map((t) => Number(t.teamNumber)).filter(Number.isFinite) ?? [];

-      data.clubMembers?.map((cm: any) => ({
+      data.clubMembers?.map((cm) => ({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 37 -
38, Remove the unnecessary any casts by passing the typed params directly to
toNumber and using the typed API response properties; specifically, replace
"toNumber(params?.id as any)" and "toNumber(params?.meetingId as any)" with
"toNumber(params?.id)" and "toNumber(params?.meetingId)", and stop casting API
response fields like existingTeams and clubMembers to any—use their declared
types from the response (e.g., existingTeams, clubMembers) directly wherever
they are used so type safety is preserved in the clubId, meetingId, and
subsequent logic.
src/hooks/queries/useClubsBookshelfQueries.ts-40-40 (1)

40-40: ⚠️ Potential issue | 🟡 Minor

Guard queries with positive IDs, not only finite numbers.

Number.isFinite(...) still enables 0/negative IDs. Please gate with > 0 to avoid accidental /clubs/0/... requests on bad params.

Suggested patch
+const isValidId = (id: number) => Number.isFinite(id) && id > 0;
+
 export function useClubsBookshelfSimpleInfiniteQuery(clubId: number) {
   return useInfiniteQuery({
@@
-    enabled: Number.isFinite(clubId),
+    enabled: isValidId(clubId),
   });
 }
@@
-    enabled: Number.isFinite(clubId) && Number.isFinite(meetingId),
+    enabled: isValidId(clubId) && isValidId(meetingId),
@@
-    enabled: Number.isFinite(clubId) && Number.isFinite(meetingId),
+    enabled: isValidId(clubId) && isValidId(meetingId),
@@
-    enabled: Number.isFinite(clubId) && Number.isFinite(meetingId),
+    enabled: isValidId(clubId) && isValidId(meetingId),
@@
-    enabled: Number.isFinite(clubId) && Number.isFinite(meetingId),
+    enabled: isValidId(clubId) && isValidId(meetingId),

Also applies to: 64-64, 72-72, 92-92, 114-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useClubsBookshelfQueries.ts` at line 40, Change the query
enabling checks in useClubsBookshelfQueries so they require positive numeric
club IDs instead of just finite numbers: replace uses of Number.isFinite(clubId)
in the query option "enabled" with a guard that ensures the id is finite and
greater than 0 (e.g., Number.isFinite(clubId) && clubId > 0). Update every
occurrence in this file (the enabled properties at the spots flagged) so queries
like /clubs/0/... or negative IDs are not triggered when clubId is invalid.
src/hooks/mutations/useClubsBookshelfMutations.ts-30-33 (1)

30-33: ⚠️ Potential issue | 🟡 Minor

Invalidate bookshelf detail cache after patch success.

Patch updates can affect detail view fields, but Line 30-33 currently invalidates only edit and simple.

Suggested patch
     onSuccess: (_, vars) => {
       qc.invalidateQueries({ queryKey: bookshelfQueryKeys.edit(vars.clubId, vars.meetingId) });
       qc.invalidateQueries({ queryKey: bookshelfQueryKeys.simple(vars.clubId) });
+      qc.invalidateQueries({ queryKey: bookshelfQueryKeys.detail(vars.clubId, vars.meetingId) });
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/mutations/useClubsBookshelfMutations.ts` around lines 30 - 33, The
onSuccess handler in useClubsBookshelfMutations.ts currently invalidates only
bookshelfQueryKeys.edit and bookshelfQueryKeys.simple after a patch; also
invalidate the bookshelf detail cache by calling qc.invalidateQueries with
bookshelfQueryKeys.detail using the same vars (e.g.,
bookshelfQueryKeys.detail(vars.clubId, vars.meetingId)) so the detail view
reflects patched fields; update the onSuccess block to include this additional
invalidateQueries call alongside the existing ones.
src/services/clubsBookshelfService.ts-133-136 (1)

133-136: ⚠️ Potential issue | 🟡 Minor

Avoid sending explicit null as query parameter value.

Passing cursorId: cursorId ?? null may serialize to ?cursorId=null in the URL, which the backend might not handle correctly. Prefer omitting the parameter entirely when undefined.

🔧 Suggested fix
     const res = await apiClient.get<TopicsResponse>(
       CLUBS_BOOKSHELF_ENDPOINTS.topics(clubId, meetingId),
-      { params: { cursorId: cursorId ?? null } }
+      { params: cursorId != null ? { cursorId } : {} }
     );

The same pattern applies to getReviews at line 199.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/clubsBookshelfService.ts` around lines 133 - 136, The call to
apiClient.get for CLUBS_BOOKSHELF_ENDPOINTS.topics (and similarly in getReviews)
passes cursorId as cursorId ?? null which can serialize to ?cursorId=null;
instead, only include the params.cursorId key when cursorId is defined. Modify
the request construction around
apiClient.get<TopicsResponse>(CLUBS_BOOKSHELF_ENDPOINTS.topics(clubId,
meetingId), { params: { ... } }) to conditionally add cursorId (e.g., build
params object and set params.cursorId = cursorId only if cursorId !== undefined)
so the query param is omitted when undefined.
🧹 Nitpick comments (25)
src/components/base-ui/Search/search_bookresult.tsx (1)

86-100: Good interactive states; consider adding accessible label.

The active/hover states provide clear tactile feedback. However, the button relies solely on an icon with alt="", making it inaccessible to screen readers.

♿ Optional: Add aria-label for accessibility
       <button
         type="button"
+        aria-label="Edit"
         onClick={(e) => {
           e.stopPropagation();
           onPencilClick?.();
         }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Search/search_bookresult.tsx` around lines 86 - 100,
The pencil icon button is inaccessible to screen readers because it uses an
empty img alt; add a clear accessible label to the interactive element by adding
an aria-label (e.g., aria-label="Edit book" or similar) to the button that wraps
the Image so screen readers announce its purpose, and keep the Image alt as ""
or set aria-hidden on the Image to avoid duplicate announcements; target the
existing button element that uses onPencilClick and the Image component to
implement this change.
src/app/groups/[id]/layout.tsx (1)

39-79: Admin and non-admin tab definitions are now identical.

Both branches define the same hrefs pointing to non-admin routes (/groups/${groupId}, /groups/${groupId}/notice, /groups/${groupId}/bookcase). Consider consolidating into a single tabs definition to eliminate duplication.

♻️ Proposed refactor
-  const tabs = isAdmin
-    ? [
-        {
-          id: "home" as TabType,
-          label: "모임 홈",
-          href: `/groups/${groupId}`,
-          icon: "/group_home.svg",
-        },
-        {
-          id: "notice" as TabType,
-          label: "공지사항",
-          href: `/groups/${groupId}/notice`,
-          icon: "/Notification2.svg",
-        },
-        {
-          id: "bookcase" as TabType,
-          label: "책장",
-          href: `/groups/${groupId}/bookcase`,
-          icon: "/bookshelf.svg",
-        },
-      ]
-    : [
-        {
-          id: "home" as TabType,
-          label: "모임 홈",
-          href: `/groups/${groupId}`,
-          icon: "/group_home.svg",
-        },
-        {
-          id: "notice" as TabType,
-          label: "공지사항",
-          href: `/groups/${groupId}/notice`,
-          icon: "/Notification2.svg",
-        },
-        {
-          id: "bookcase" as TabType,
-          label: "책장",
-          href: `/groups/${groupId}/bookcase`,
-          icon: "/bookshelf.svg",
-        },
-      ];
+  const tabs = [
+    {
+      id: "home" as TabType,
+      label: "모임 홈",
+      href: `/groups/${groupId}`,
+      icon: "/group_home.svg",
+    },
+    {
+      id: "notice" as TabType,
+      label: "공지사항",
+      href: `/groups/${groupId}/notice`,
+      icon: "/Notification2.svg",
+    },
+    {
+      id: "bookcase" as TabType,
+      label: "책장",
+      href: `/groups/${groupId}/bookcase`,
+      icon: "/bookshelf.svg",
+    },
+  ];

If the admin tabs should actually point to admin-specific routes (e.g., /groups/${groupId}/admin/notice), please verify and restore the differentiated behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/layout.tsx around lines 39 - 79, The current conditional
that builds tabs (variable tabs using isAdmin) duplicates identical arrays;
consolidate by returning a single shared array instead of separate branches,
e.g., remove the isAdmin conditional and assign the common array to tabs
(keeping TabType casts and icons) or, if admin should have different routes,
update the admin branch to use the correct admin-specific hrefs (e.g.,
`/groups/${groupId}/admin/...`) so the isAdmin branch purpose is preserved;
adjust references to tabs, isAdmin, groupId, and TabType accordingly.
src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx (1)

5-8: Local CategoryDTO type differs from ClubCategoryDTO.

The local CategoryDTO type has optional fields (code?: string | number | null, description?: string | null), while ClubCategoryDTO in src/types/groups/clubsearch.ts has required non-nullable fields (code: string, description: string). Consider importing and using the canonical type for consistency.

♻️ Suggested approach
+"use client";
+
+import React from "react";
+import type { ClubCategoryDTO } from "@/types/groups/clubsearch";
+
-type CategoryDTO = {
-  code?: string | number | null;
-  description?: string | null;
-};
+// Use ClubCategoryDTO from shared types, with Partial for backward compatibility
+type CategoryDTO = Partial<ClubCategoryDTO> & { code?: string | number | null };

Or adjust the prop type to explicitly use ClubCategoryDTO when DTOs are passed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx`
around lines 5 - 8, Replace the local CategoryDTO type with the canonical
ClubCategoryDTO type: import ClubCategoryDTO and use it in place of the locally
declared CategoryDTO (or update any prop/type annotations that currently
reference CategoryDTO to use ClubCategoryDTO), ensuring fields are non-optional
and match the shared contract; alternatively, if DTOs might be partial here,
explicitly map/transform incoming data to ClubCategoryDTO before use and update
the component props to accept ClubCategoryDTO for consistency.
src/types/groups/bookcasedetail.ts (1)

46-77: Duplicate type definitions across files.

TeamKey, ExistingTeamItem, and MeetingMemberInfo are also defined in src/types/groups/meetingDetail.ts. Consider extracting shared types to a common module to avoid drift and maintenance burden.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/groups/bookcasedetail.ts` around lines 46 - 77, The types TeamKey,
ExistingTeamItem, and MeetingMemberInfo are duplicated; extract these shared
types into a single common types module (e.g., a new file exporting TeamKey,
ExistingTeamItem, MeetingMemberInfo) and replace the local declarations in
GetMeetingMembers-related code with imports from that module; update references
in MeetingMemberItem and any other files that previously redefined them so they
import the canonical types and remove the duplicate type declarations.
src/types/groups/meetingDetail.ts (1)

3-11: Consolidate duplicate type definitions.

TeamKey and ExistingTeamItem have identical structures. Additionally, these types are also defined in src/types/groups/bookcasedetail.ts. Consider consolidating them in a shared location to avoid duplication and maintain consistency.

♻️ Suggestion

Either:

  1. Export TeamKey from one file and reuse it as ExistingTeamItem via type alias, or
  2. Create a shared types file for meeting-related types and import from both locations.
-export type TeamKey = {
-  teamId: number;
-  teamNumber: number;
-};
-
-export type ExistingTeamItem = {
-  teamId: number;
-  teamNumber: number;
-};
+export type TeamKey = {
+  teamId: number;
+  teamNumber: number;
+};
+
+export type ExistingTeamItem = TeamKey;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/groups/meetingDetail.ts` around lines 3 - 11, TeamKey and
ExistingTeamItem are identical; remove the duplication by keeping a single
definition (e.g., export type TeamKey = { teamId: number; teamNumber: number; })
and make ExistingTeamItem a type alias to it (type ExistingTeamItem = TeamKey)
or move that single definition into a shared meeting-types module and import it
where needed (update references that currently use ExistingTeamItem and TeamKey
to use the shared TeamKey export).
src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx (2)

31-34: Consider adding delete confirmation.

The delete handler immediately invokes onDelete() without any confirmation. Destructive actions typically benefit from a confirmation step to prevent accidental deletions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx` around lines
31 - 34, The delete handler handleDeleteClick in BookshelfAdminMenu currently
calls onDelete() unconditionally; change it to prompt for confirmation (e.g.,
use a browser confirm or the app's ConfirmDialog component) and only call
onDelete() and setMenuOpen(false) if the user confirms, leaving the menu
open/canceling if they decline.

16-24: Optimize click-outside listener to only attach when menu is open.

Unlike ItemMoreMenu.tsx which conditionally attaches the listener only when open is true, this component always has the listener attached. Consider matching the pattern for consistency and minor performance improvement.

♻️ Proposed fix
  useEffect(() => {
+   if (!menuOpen) return;
+
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setMenuOpen(false);
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
- }, []);
+ }, [menuOpen]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx` around lines
16 - 24, The click-outside listener is always attached; change the useEffect in
BookshelfAdminMenu to only add the "mousedown" listener when the menu is open by
watching the menu open state (e.g., menuOpen or open) in the dependency array,
and remove it in the cleanup; keep the existing handleClickOutside, menuRef, and
setMenuOpen(false) logic but only register
document.addEventListener("mousedown", handleClickOutside) when the menu is open
and ensure the cleanup removes it.
src/components/base-ui/BookStory/bookstory_choosebook.tsx (2)

69-71: Consider removing empty container div.

This empty div with a placeholder comment serves no apparent purpose. If it's not needed for layout spacing, consider removing it entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/BookStory/bookstory_choosebook.tsx` around lines 69 -
71, Remove the empty placeholder div in BookStoryChooseBook (the JSX element
with className "shrink-0 t:ml-[52px] t:self-stretch t:flex t:items-end mt-4
t:mt-0 flex justify-center" and the comment "비워둠 (기존 UI 유지)"); if spacing is
required keep a semantic placeholder (e.g., an accessible spacer or apply the
spacing to a parent/container) otherwise delete the element to avoid dead DOM
nodes and redundant layout classes.

22-22: Missing semicolon.

Line 22 is missing a semicolon at the end of the statement, which is inconsistent with the rest of the codebase style.

🔧 Proposed fix
-  const clickable = !!onButtonClick
+  const clickable = !!onButtonClick;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/BookStory/bookstory_choosebook.tsx` at line 22, Add
the missing semicolon at the end of the statement that defines clickable
(replace "const clickable = !!onButtonClick" with "const clickable =
!!onButtonClick;") to match project style; the change should be made where the
clickable constant is declared in bookstory_choosebook.tsx.
src/components/base-ui/Bookcase/ItemMoreMenu.tsx (1)

47-95: Consider adding keyboard accessibility for dropdown navigation.

The dropdown menu lacks keyboard navigation support. Users should be able to navigate menu items with arrow keys and close the menu with Escape.

🔧 Suggested enhancement for Escape key handling
  useEffect(() => {
    if (!open) return;

    const onDown = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
+   const onKeyDown = (e: KeyboardEvent) => {
+     if (e.key === "Escape") setOpen(false);
+   };
    document.addEventListener("mousedown", onDown);
+   document.addEventListener("keydown", onKeyDown);
-   return () => document.removeEventListener("mousedown", onDown);
+   return () => {
+     document.removeEventListener("mousedown", onDown);
+     document.removeEventListener("keydown", onKeyDown);
+   };
  }, [open]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/ItemMoreMenu.tsx` around lines 47 - 95, The
ItemMoreMenu dropdown currently rendered when open lacks keyboard navigation;
update the component to add proper ARIA roles and keyboard handlers: give the
menu container role="menu" and each button role="menuitem" with tabIndex={-1},
create refs for the actionable buttons (report, edit, delete) and manage focus
when open (focus first item), implement an onKeyDown handler on the menu to
handle ArrowDown/ArrowUp to move focus between these refs and Enter/Space to
activate the focused item (call the existing handle wrapper with
onReport/onEdit/onDelete), and handle Escape to close the menu (call the
existing close logic that toggles open). Ensure canManage gating still applies
(only include refs for present items) and maintain existing click behavior via
the handle function.
src/types/groups/grouphome.ts (1)

2-7: Prefer reusing the shared ApiResponse type instead of redefining it.

Duplicating the API envelope in domain files increases drift risk. Importing from the central API types module keeps response typing consistent.

💡 Suggested refactor
-// 공통 응답 포맷
-export interface ApiResponse<T> {
-  isSuccess: boolean;
-  code: string;
-  message: string;
-  result: T;
-}
+import type { ApiResponse } from "@/lib/api/types";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/groups/grouphome.ts` around lines 2 - 7, Remove the local
ApiResponse<T> interface duplicate and instead import and use the shared
ApiResponse type from the central API types module; locate the local definition
named ApiResponse<T> in this file, delete it, add an import for the shared
ApiResponse symbol, and update any usages to reference that imported type so the
domain file reuses the canonical API envelope.
src/components/base-ui/Group/group_admin_menu.tsx (1)

44-47: Add basic menu ARIA semantics for keyboard/screen-reader clarity.

Line 44 and Line 60 render interactive menu UI without menu semantics (aria-expanded, aria-haspopup, role="menu"/menuitem"), which hurts accessibility discoverability.

♿ Suggested patch
       <button
         type="button"
         onClick={() => setMenuOpen(!menuOpen)}
+        aria-haspopup="menu"
+        aria-expanded={menuOpen}
+        aria-controls="group-admin-menu"
         className="flex items-center gap-2 hover:brightness-50 cursor-pointer"
       >
@@
         <div
+          id="group-admin-menu"
+          role="menu"
           className="
             absolute right-0 top-full mt-2
             w-34 h-[120px]
@@
           <button
             type="button"
             onClick={handleApplicantClick}
+            role="menuitem"
             className="
@@
           <button
             type="button"
             onClick={handleMembersClick}
+            role="menuitem"
             className="
@@
           <button
             type="button"
             onClick={handleEditClick}
+            role="menuitem"
             className="

Also applies to: 60-70, 72-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/group_admin_menu.tsx` around lines 44 - 47, The
menu button and menu markup in Group (group_admin_menu.tsx) lack ARIA semantics;
update the toggle button (the element using onClick={() =>
setMenuOpen(!menuOpen)}) to include aria-haspopup="true",
aria-expanded={menuOpen} and aria-controls referencing the menu container ID,
and add role="menu" to the menu container and role="menuitem" (or
role="menuitemcheckbox"/"menuitemradio" as appropriate) to each menu entry;
ensure keyboard interaction is supported by preserving/adding onKeyDown handlers
that open/close the menu with Enter/Space/Escape and move focus into the
role="menu" so screen readers and keyboard users can discover and navigate the
menu.
src/hooks/mutations/useClubAdminEditMutations.ts (1)

7-11: Remove commented fallback code before merge.

Line 7-11 and Line 18 leave implementation alternatives in production code; this adds noise and future drift risk.

🧹 Suggested cleanup
-// ✅ clubService가 객체 export인 경우
 import { clubService } from "@/services/clubService";
-// ✅ 함수 export면 이렇게 바꿔:
-// import { updateAdminClub } from "@/services/clubService";
@@
   return useMutation({
     mutationFn: (body: UpdateClubAdminRequest) =>
       clubService.updateAdminClub(clubId, body),
-    // mutationFn: (body: UpdateClubAdminRequest) => updateAdminClub(clubId, body),
 
     onSuccess: () => {

Also applies to: 18-18

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/mutations/useClubAdminEditMutations.ts` around lines 7 - 11, Remove
the leftover commented fallback imports/alternatives in
useClubAdminEditMutations.ts (the commented block suggesting switching between
import { clubService } and import { updateAdminClub }) and the similar commented
alternative later in the file; keep only the actual import/usage you intend
(e.g., import { clubService } from "@/services/clubService" or the named
function import updateAdminClub) and delete the commented lines so production
code contains no commented implementation alternatives.
src/hooks/queries/useClubAdminEditQueries.ts (1)

13-13: Remove commented-out code.

The commented alternative queryFn line is dead code. If it's no longer needed, remove it to keep the codebase clean.

🧹 Suggested cleanup
     queryKey: clubAdminEditQueryKeys.detail(clubId),
     queryFn: () => clubService.getAdminClubDetail(clubId),
-    // queryFn: () => getAdminClubDetail(clubId),
     enabled: Number.isFinite(clubId) && clubId > 0,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useClubAdminEditQueries.ts` at line 13, Remove the dead
commented-out alternative queryFn line in useClubAdminEditQueries.ts;
specifically delete the "// queryFn: () => getAdminClubDetail(clubId)," comment
so only the active query configuration remains (referencing the query setup and
getAdminClubDetail usage) to keep the code clean.
src/app/groups/[id]/admin/applicant/page.tsx (2)

89-96: Consider extracting formatYYYYMMDD to a shared utility.

This date formatting function is likely useful elsewhere. Consider moving it to a shared utilities module.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 89 - 96, Extract
the formatYYYYMMDD function into a shared utility module (e.g., create a new
exported function in a utilities/date or utils/format.ts file), replace the
local definition in src/app/groups/[id]/admin/applicant/page.tsx with an import
of that exported function, update any other files that need the same formatting
to import the shared utility, and ensure the new module is exported with
appropriate TypeScript signatures and unit tests or runtime checks as needed to
preserve the original behavior (including the NaN fallback '0000.00.00').

177-187: Review the eslint-disable for exhaustive-deps.

The eslint-disable comment suggests the dependency array may be incomplete. The effect accesses membersQuery.fetchNextPage but only lists specific properties. This is intentional to prevent infinite re-triggers, but consider using a ref pattern or restructuring to avoid the disable.

💡 Alternative using ref
+  const fetchNextPageRef = useRef(membersQuery.fetchNextPage);
+  fetchNextPageRef.current = membersQuery.fetchNextPage;
+
   useEffect(() => {
     if (!membersQuery.hasNextPage) return;
     if (membersQuery.isFetchingNextPage) return;

     const needCount = currentPage * itemsPerPage;
     if (applicants.length < needCount) {
-      membersQuery.fetchNextPage();
+      fetchNextPageRef.current();
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [currentPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage]);
+  }, [currentPage, applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 177 - 187, The
effect currently disables exhaustive-deps while referencing
membersQuery.fetchNextPage directly; replace the eslint-disable by stabilizing
the fetch function via a ref or memo so you can safely include all needed deps:
create a ref (e.g., fetchNextPageRef) and assign membersQuery.fetchNextPage to
it inside a useEffect that watches membersQuery, then in the main useEffect call
fetchNextPageRef.current(), and list currentPage, itemsPerPage,
applicants.length, membersQuery.hasNextPage, membersQuery.isFetchingNextPage as
dependencies; this removes the eslint-disable while preventing infinite
re-renders caused by unstable membersQuery.fetchNextPage.
src/app/groups/[id]/bookcase/page.tsx (1)

112-154: JSX has inconsistent indentation.

The return statement's JSX has unbalanced indentation that makes the code harder to read.

🧹 Formatting suggestion
   return (
-      <div
-        className="..."
-      >
-    {list.map((item) => {
+    <div className="...">
+      {list.map((item) => {
         // ...
-    })}
-     {isStaff && (
-        <FloatingFab ... />
-      )}
-  </div>
-    );
+      })}
+      {isStaff && (
+        <FloatingFab ... />
+      )}
+    </div>
+  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/page.tsx around lines 112 - 154, The JSX
returned from the component has inconsistent and unbalanced indentation around
the grid container, the list.map rendering (BookcaseCard), and the conditional
FloatingFab; normalize indentation for readability by aligning the opening <div>
with its closing </div>, indenting the {list.map((item) => { ... })} block and
its returned <BookcaseCard ... /> consistently, and ensuring the {isStaff && (
<FloatingFab ... /> )} block matches the same nesting level; update surrounding
lines referencing BookcaseCard, handleGoToDetail, and FloatingFab so their props
and callbacks are vertically aligned and the entire return block uses a
consistent two- or four-space indent style.
src/hooks/queries/useClubMemberQueries.ts (1)

1-1: Remove unnecessary "use client" directive.

This hooks module doesn't use browser-only APIs directly. The directive is only needed in components that use client-side features. Hooks that call useInfiniteQuery will work correctly when imported into client components without this directive.

🧹 Suggested cleanup
-"use client";
-
 // src/hooks/queries/useClubMemberQueries.ts
 import { useInfiniteQuery, type InfiniteData } from "@tanstack/react-query";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/queries/useClubMemberQueries.ts` at line 1, Remove the unnecessary
"use client" directive at the top of this module: delete the `"use client"` line
in the useClubMemberQueries module so it no longer forces client-only execution;
keep the hook implementations (e.g., useClubMemberQueries and any calls to
useInfiniteQuery) as-is so they can be imported into client components without
the directive.
src/components/base-ui/Bookcase/bookid/BookshelfDeleteConfirmModal.tsx (1)

31-46: Consider memoizing onClose in parent or using a ref.

The onClose callback is in the useEffect dependency array. If the parent doesn't memoize onClose with useCallback, this effect will re-run on every parent render, potentially causing unnecessary event listener churn.

💡 Alternative using ref to avoid dependency
+"use client";
+
+import { useEffect, useRef } from "react";
+import Image from "next/image";
+
+// ...
+
 export default function BookshelfDeleteConfirmModal({
   // ...
 }: Props) {
+  const onCloseRef = useRef(onClose);
+  onCloseRef.current = onClose;
+
   useEffect(() => {
     if (!isOpen) return;

     const prevOverflow = document.body.style.overflow;
     document.body.style.overflow = "hidden";

     const onKeyDown = (e: KeyboardEvent) => {
-      if (e.key === "Escape") onClose();
+      if (e.key === "Escape") onCloseRef.current();
     };
     window.addEventListener("keydown", onKeyDown);

     return () => {
       document.body.style.overflow = prevOverflow;
       window.removeEventListener("keydown", onKeyDown);
     };
-  }, [isOpen, onClose]);
+  }, [isOpen]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/bookid/BookshelfDeleteConfirmModal.tsx`
around lines 31 - 46, The effect in BookshelfDeleteConfirmModal re-runs whenever
the onClose prop identity changes; either memoize the parent's onClose with
useCallback, or change this component to use a ref for the latest onClose (e.g.,
create an onCloseRef and update it on prop change) and have the useEffect (the
one that registers window keydown and toggles body overflow) depend only on
isOpen; inside the handler call onCloseRef.current() so the effect no longer
needs onClose in its dependency array.
src/lib/api/endpoints/ClubsBookshelf.ts (1)

5-14: Consider consolidating duplicate endpoints and using consistent naming.

Several endpoints return identical URLs:

  • simpleBookshelves and CREATE_BOOKSHELF → same URL
  • detail, delete, and patch → same URL

Additionally, naming is inconsistent (SEARCH_BOOKS/CREATE_BOOKSHELF vs. camelCase for others).

While semantic separation can aid readability, consider either:

  1. Consolidating to single endpoints and letting the HTTP method distinguish intent
  2. Using consistent naming throughout (all camelCase recommended)
♻️ Example consolidation
 export const CLUBS_BOOKSHELF_ENDPOINTS = {
-  simpleBookshelves: (clubId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves`,
-  
-  SEARCH_BOOKS: `${API_BASE_URL}/books/search`,
-  CREATE_BOOKSHELF: (clubId: number | string) =>`${API_BASE_URL}/clubs/${clubId}/bookshelves`,
+  bookshelves: (clubId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves`,
+  searchBooks: `${API_BASE_URL}/books/search`,

-  // 책장 상세, 삭제, 수정
-  detail: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`,
-  delete: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`,
   editGet: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}/edit`,
-  patch: (clubId: number | string, meetingId: number | string) =>`${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`,
+  bookshelf: (clubId: number | string, meetingId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/api/endpoints/ClubsBookshelf.ts` around lines 5 - 14, The endpoints
object contains duplicate URL builders and inconsistent naming; consolidate
identical routes and standardize naming to camelCase: remove or merge
simpleBookshelves into createBookshelf (or vice versa) so CREATE_BOOKSHELF is
not duplicated, and merge detail, delete, and patch into a single bookshelve (or
bookshelfDetail) function that returns
`${API_BASE_URL}/clubs/${clubId}/bookshelves/${meetingId}` and let the caller
use HTTP verbs to distinguish actions; update references that use SEARCH_BOOKS,
CREATE_BOOKSHELF, simpleBookshelves, detail, delete, editGet, and patch to the
new keys (e.g., searchBooks, createBookshelf, bookshelfDetail, editGet) to keep
naming consistent.
src/services/clubMemberService.ts (1)

16-21: cursorId ?? null is redundant; simplify to just cursorId.

The apiClient automatically filters out undefined and null values from query parameters before sending requests (see the query string builder logic). Passing cursorId: null has the same effect as passing cursorId: undefined—both will be omitted from the final URL. Simplify to cursorId for clarity and consistency with other services like storyService.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/clubMemberService.ts` around lines 16 - 21, In the apiClient.get
call that fetches club members (the const res = await
apiClient.get<ApiResponse<GetClubMembersResult>>(CLUBS.members(clubId), {
params: { status, cursorId: cursorId ?? null, }, });), remove the redundant null
coalescing and just pass cursorId (params: { status, cursorId }). This aligns
with the query string builder behavior and other services (e.g., storyService)
so undefined/null values are omitted consistently.
src/components/base-ui/Group/DebateList.tsx (1)

31-199: Consider extracting shared editable-item list logic with ReviewList.

DebateList and ReviewList now have near-identical edit/delete/menu orchestration. A shared base component/hook would reduce drift and bug-fix duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Group/DebateList.tsx` around lines 31 - 199,
DebateList duplicates edit/delete/menu orchestration that ReviewList shares;
extract that logic into a shared hook or base component (e.g., useEditableList
or EditableListBase) which owns state and helpers: editingId, draftText,
deleteTargetId, editingItem (derived), startEdit, cancelEdit, openDelete,
closeDelete, confirmDelete and the Escape key useEffect; keep UI-specific pieces
(rendering list, ItemMoreMenu, LongtermChatInput,
onReport/onUpdate/onDelete/onClickAuthor handlers) in DebateList and ReviewList
but call the shared hook/component with props {items, isStaff, onReport,
onUpdate, onDelete, onClickAuthor} so both files reuse the same orchestration
and reduce duplication.
src/hooks/mutations/useClubsBookshelfMutations.ts (1)

19-28: usePatchBookshelfMutation parameters are redundant with mutationFn args.

The hook takes (clubId, meetingId) but uses args.clubId/args.meetingId instead. This API is easy to misuse and can be simplified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/mutations/useClubsBookshelfMutations.ts` around lines 19 - 28, The
hook currently takes (clubId, meetingId) but the mutationFn expects an args
object with its own clubId/meetingId — make the API consistent by using the hook
parameters and simplifying mutationFn: change mutationFn in
usePatchBookshelfMutation to accept only the body (BookshelfPatchRequest) and
call clubsBookshelfService.patch(clubId, meetingId, body) using the hook’s
clubId and meetingId; also update the mutationKey to include the
clubId/meetingId if needed and adjust types so callers call mutate(body) rather
than passing duplicate club/meeting ids.
src/services/clubsBookshelfService.ts (2)

118-120: Remove leftover Korean comment.

The inline comment // 또는 detail/patch 경로 재사용 appears to be a development note that should be removed before merging.

🧹 Suggested fix
     const res = await apiClient.delete<DeleteBookshelfResponse>(
-      CLUBS_BOOKSHELF_ENDPOINTS.delete(clubId, meetingId) // 또는 detail/patch 경로 재사용
+      CLUBS_BOOKSHELF_ENDPOINTS.delete(clubId, meetingId)
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/clubsBookshelfService.ts` around lines 118 - 120, Remove the
leftover Korean inline comment after the endpoint call in
src/services/clubsBookshelfService.ts: delete the text "// 또는 detail/patch 경로
재사용" from the
apiClient.delete<DeleteBookshelfResponse>(CLUBS_BOOKSHELF_ENDPOINTS.delete(clubId,
meetingId)) line so the call remains clean (refer to the apiClient.delete
invocation and CLUBS_BOOKSHELF_ENDPOINTS.delete symbol to locate the code).

61-96: Consider consistent parameter style across methods.

The service mixes parameter styles: createBookshelf uses an object { clubId, body }, while getEdit and patch use positional arguments. Also, line 93 explicitly sets Content-Type: application/json for patch, but createBookshelf doesn't. Either this header is needed for all JSON body requests, or apiClient handles it automatically (making line 93 redundant).

♻️ Suggested: Align parameter style with other methods
-  getEdit: async (
-    clubId: number,
-    meetingId: number
-  ): Promise<BookshelfEditGetResult> => {
+  getEdit: async (params: {
+    clubId: number;
+    meetingId: number;
+  }): Promise<BookshelfEditGetResult> => {
+    const { clubId, meetingId } = params;
     const res = await apiClient.get<BookshelfEditGetResponse>(
       CLUBS_BOOKSHELF_ENDPOINTS.editGet(clubId, meetingId)
     );
     return res.result;
   },

-  patch: async (
-    clubId: number,
-    meetingId: number,
-    payload: BookshelfPatchRequest
-  ): Promise<BookshelfPatchResult> => {
+  patch: async (params: {
+    clubId: number;
+    meetingId: number;
+    payload: BookshelfPatchRequest;
+  }): Promise<BookshelfPatchResult> => {
+    const { clubId, meetingId, payload } = params;
     const res = await apiClient.patch<BookshelfPatchResponse>(
       CLUBS_BOOKSHELF_ENDPOINTS.patch(clubId, meetingId),
-      payload,
-      { headers: { "Content-Type": "application/json" } }
+      payload
     );
     return res.result;
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/clubsBookshelfService.ts` around lines 61 - 96, createBookshelf
currently accepts an object parameter ({ clubId, body }) while getEdit and patch
use positional args (clubId, meetingId, payload), and patch sets an explicit
Content-Type header that createBookshelf does not; make the parameter style
consistent across the service (either change createBookshelf to
createBookshelf(clubId: number, body: CreateBookshelfRequest) or change
getEdit/patch to accept objects) and propagate that change to callers, and unify
Content-Type handling by either removing the explicit header from patch if
apiClient already sets JSON headers or adding the same header to
createBookshelf's post call; adjust the implementations that call
CLUBS_BOOKSHELF_ENDPOINTS.CREATE_BOOKSHELF, .editGet and .patch and the
apiClient.post/patch usages accordingly.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6f250c0 and 00d02ea.

⛔ Files ignored due to path filters (3)
  • public/Danger_Circle.svg is excluded by !**/*.svg
  • public/Delete_2.svg is excluded by !**/*.svg
  • public/Edit.svg is excluded by !**/*.svg
📒 Files selected for processing (57)
  • .env.example
  • src/app/groups/[id]/admin/applicant/page.tsx
  • src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts
  • src/app/groups/[id]/admin/bookcase/[meetingId]/edit/layout.tsx
  • src/app/groups/[id]/admin/bookcase/[meetingId]/edit/page.tsx
  • src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx
  • src/app/groups/[id]/admin/bookcase/new/page.tsx
  • src/app/groups/[id]/admin/edit/layout.tsx
  • src/app/groups/[id]/admin/edit/page.tsx
  • src/app/groups/[id]/admin/members/page.tsx
  • src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/MeetingTabSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/ReviewSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/dummy.ts
  • src/app/groups/[id]/bookcase/[bookId]/page.tsx
  • src/app/groups/[id]/bookcase/page.tsx
  • src/app/groups/[id]/dummy.ts
  • src/app/groups/[id]/layout.tsx
  • src/app/groups/[id]/page.tsx
  • src/app/groups/page.tsx
  • src/components/base-ui/BookStory/bookstory_choosebook.tsx
  • src/components/base-ui/Bookcase/BookDetailCard.tsx
  • src/components/base-ui/Bookcase/BookcaseCard.tsx
  • src/components/base-ui/Bookcase/ItemMoreMenu.tsx
  • src/components/base-ui/Bookcase/bookid/BookshelfAdminMenu.tsx
  • src/components/base-ui/Bookcase/bookid/BookshelfDeleteConfirmModal.tsx
  • src/components/base-ui/Bookcase/bookid/ReviewList.tsx
  • src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx
  • src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx
  • src/components/base-ui/Group/DebateList.tsx
  • src/components/base-ui/Group/group_admin_menu.tsx
  • src/components/base-ui/LongtermInput.tsx
  • src/components/base-ui/Search/search_bookresult.tsx
  • src/components/layout/BookSelectModal.tsx
  • src/hooks/mutations/useClubAdminEditMutations.ts
  • src/hooks/mutations/useClubMemberMutations.ts
  • src/hooks/mutations/useClubsBookshelfMutations.ts
  • src/hooks/mutations/useMeetingMutations.ts
  • src/hooks/queries/useClubAdminEditQueries.ts
  • src/hooks/queries/useClubMemberQueries.ts
  • src/hooks/queries/useClubhomeQueries.ts
  • src/hooks/queries/useClubsBookshelfQueries.ts
  • src/hooks/queries/useMeetingQueries.ts
  • src/lib/api/endpoints/Clubs.ts
  • src/lib/api/endpoints/ClubsBookshelf.ts
  • src/lib/api/endpoints/index.ts
  • src/services/clubMemberService.ts
  • src/services/clubService.ts
  • src/services/clubsBookshelfService.ts
  • src/services/meetingService.ts
  • src/types/bookshelf.ts
  • src/types/groups/bookcasedetail.ts
  • src/types/groups/bookcasehome.ts
  • src/types/groups/clubAdminEdit.ts
  • src/types/groups/clubMembers.ts
  • src/types/groups/grouphome.ts
  • src/types/groups/meetingDetail.ts
💤 Files with no reviewable changes (5)
  • src/app/groups/[id]/admin/bookcase/[meetingId]/dummy.ts
  • src/app/groups/[id]/dummy.ts
  • src/app/groups/[id]/bookcase/[bookId]/dummy.ts
  • src/types/groups/bookcasehome.ts
  • .env.example

Comment on lines 198 to 223
const handleSelectAction = async (clubMemberId: number, action: ActionType) => {
// action 매핑:
// delete -> REJECT (PENDING 삭제)
// approve -> APPROVE (PENDING -> MEMBER)
try {
if (action === 'delete') {
await updateStatus({
clubId,
clubMemberId,
body: {
command: 'REJECT'
},
});
} else {
await updateStatus({
clubId,
clubMemberId,
body: {
command: 'APPROVE',
},
});
}
} finally {
setOpenMenuId(null);
}
setOpenMenuId(null);
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add error handling for mutation failures.

The try/finally block doesn't catch errors, so mutation failures are silently ignored. Users won't know if their approve/reject action failed.

🔧 Proposed fix
   const handleSelectAction = async (clubMemberId: number, action: ActionType) => {
     try {
       if (action === 'delete') {
         await updateStatus({
           clubId,
           clubMemberId,
           body: { command: 'REJECT' },
         });
       } else {
         await updateStatus({
           clubId,
           clubMemberId,
           body: { command: 'APPROVE' },
         });
       }
+    } catch (err) {
+      const message = action === 'delete' ? '거절 처리에 실패했습니다.' : '가입 처리에 실패했습니다.';
+      // Consider using toast or alert here
+      console.error(message, err);
+      // toast.error(message); // if using react-hot-toast
     } finally {
       setOpenMenuId(null);
     }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSelectAction = async (clubMemberId: number, action: ActionType) => {
// action 매핑:
// delete -> REJECT (PENDING 삭제)
// approve -> APPROVE (PENDING -> MEMBER)
try {
if (action === 'delete') {
await updateStatus({
clubId,
clubMemberId,
body: {
command: 'REJECT'
},
});
} else {
await updateStatus({
clubId,
clubMemberId,
body: {
command: 'APPROVE',
},
});
}
} finally {
setOpenMenuId(null);
}
setOpenMenuId(null);
};
const handleSelectAction = async (clubMemberId: number, action: ActionType) => {
// action 매핑:
// delete -> REJECT (PENDING 삭제)
// approve -> APPROVE (PENDING -> MEMBER)
try {
if (action === 'delete') {
await updateStatus({
clubId,
clubMemberId,
body: {
command: 'REJECT'
},
});
} else {
await updateStatus({
clubId,
clubMemberId,
body: {
command: 'APPROVE',
},
});
}
} catch (err) {
const message = action === 'delete' ? '거절 처리에 실패했습니다.' : '가입 처리에 실패했습니다.';
// Consider using toast or alert here
console.error(message, err);
// toast.error(message); // if using react-hot-toast
} finally {
setOpenMenuId(null);
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/applicant/page.tsx around lines 198 - 223, The
handler handleSelectAction currently uses try/finally so any errors from
updateStatus are swallowed; change it to try/catch/finally: in the catch block
capture the thrown error from updateStatus (both REJECT and APPROVE branches),
log/record it (console.error or processLogger), and surface UI feedback (e.g.,
call your existing toast/notification helper or set an error state) so the user
is informed of the failure; keep setOpenMenuId(null) in finally so the menu
still closes, and consider rethrowing the error only if callers need to handle
it.

Comment on lines +116 to +117
const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId);
const patchMutation = usePatchBookshelfMutation(clubId, meetingId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle edit-query failure explicitly (not just loading).

When fetch fails, the page currently renders a mostly empty form instead of an error state, which is confusing and blocks progress without clear feedback.

💡 Suggested fix
-  const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId);
+  const { data: editData, isLoading, isError } = useBookshelfEditQuery(clubId, meetingId);
...
   if (isLoading) {
     return <div className="w-full px-4 py-6 text-Gray-7 body_1_3">로딩중...</div>;
   }
+  if (isError || !editData) {
+    return <div className="w-full px-4 py-6 text-Red-500 body_1_3">수정 정보를 불러오지 못했습니다.</div>;
+  }

Also applies to: 222-224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/edit/page.tsx around lines
116 - 117, The page currently only checks isLoading from useBookshelfEditQuery
(const { data: editData, isLoading } = useBookshelfEditQuery(clubId, meetingId))
and renders the edit form even when the fetch errored; update the component to
also read and handle the query error (e.g., const { data, isLoading, error } =
useBookshelfEditQuery(...)) and render a clear error state (message/UI) when
error is present, rather than the form, and apply the same guard wherever the
edit query is used (the other occurrence around the code referenced at lines
222-224) so the UI shows loading, success, or explicit error.

Comment on lines 120 to 135
const handleSubmit = async () => {
// PUT payload 만들기
if (!Number.isFinite(clubId) || !Number.isFinite(meetingId)) return;

const body: TeamMemberListPutBody = {
teamMemberList: teams.map((teamNumber) => ({
teamNumber,
clubMemberIds: members
.filter((m) => m.teamNumber === teamNumber)
.map((m) => m.clubMemberId),
})),
teamMemberList: teams
.sort((a, b) => a - b)
.map((teamNumber) => ({
teamNumber,
clubMemberIds: members
.filter((m) => m.teamNumber === teamNumber)
.map((m) => m.clubMemberId),
})),
};

// TODO(API 연동):
// await fetch(`/api/groups/${groupId}/admin/bookcase/${meetingId}`, { method: 'PUT', body: JSON.stringify(body) })
console.log("PUT payload:", body);
await updateTeams({ clubId, meetingId, body });
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Block save until initial member/team data is ready.

MemberPool can trigger save even while data is still loading or after fetch error. In that state, default local state (teams=[1], members=[]) can produce an empty assignment payload and overwrite server data.

💡 Suggested guard
 const handleSubmit = async () => {
+  if (!isInitialized || isLoading || isError) {
+    return;
+  }
   if (!Number.isFinite(clubId) || !Number.isFinite(meetingId)) return;

Also applies to: 229-244

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 120 -
135, handleSubmit can run while initial team/member data is still loading and
thus send the default local state (teams=[1], members=[]) that overwrites server
data; add a guard in handleSubmit to return early unless initial data has
finished loading and there was no fetch error. Concretely, use the same
readiness flags used by MemberPool (e.g. isLoading / isError /
isInitialLoadComplete) or add a boolean like initialDataReady and check it at
the start of handleSubmit (before building TeamMemberListPutBody and calling
updateTeams), and apply the same guard to the other submit path mentioned (lines
~229-244) so updateTeams is only called when members and teams reflect loaded
server state.

Comment on lines +124 to +126
teamMemberList: teams
.sort((a, b) => a - b)
.map((teamNumber) => ({
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx | head -150 | tail -50

Repository: checkmo2025/FE

Length of output: 1824


🏁 Script executed:

head -100 src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx

Repository: checkmo2025/FE

Length of output: 3301


Use a copied array before sorting to avoid mutating React state.

teams.sort(...) mutates the state array directly. Arrays in JavaScript are reference types, and .sort() modifies the array in place. This violates React's principle of immutable state updates.

Suggested fix
-      teamMemberList: teams
-        .sort((a, b) => a - b)
+      teamMemberList: [...teams]
+        .sort((a, b) => a - b)
         .map((teamNumber) => ({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
teamMemberList: teams
.sort((a, b) => a - b)
.map((teamNumber) => ({
teamMemberList: [...teams]
.sort((a, b) => a - b)
.map((teamNumber) => ({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 124 -
126, The code is directly calling teams.sort(...) which mutates the original
teams array (React state); to fix, create a shallow copy before sorting—e.g.,
use [...teams] or teams.slice()—and call .sort(...) on that copy, then .map(...)
to build teamMemberList; update the expression that constructs teamMemberList
(the teams.sort(...).map(...) chain) to operate on the copied array to avoid
mutating teams.

Comment on lines +236 to 237
onSubmit={handleSubmit}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "page.tsx" | grep -E "groups.*bookcase.*meetingId"

Repository: checkmo2025/FE

Length of output: 178


🏁 Script executed:

find . -type f -name "MemberPool.tsx"

Repository: checkmo2025/FE

Length of output: 131


🏁 Script executed:

wc -l ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx

Repository: checkmo2025/FE

Length of output: 119


🏁 Script executed:

sed -n '220,250p' ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx | cat -n

Repository: checkmo2025/FE

Length of output: 1597


🏁 Script executed:

rg "handleSubmit\s*=" ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx -A 5

Repository: checkmo2025/FE

Length of output: 269


🏁 Script executed:

head -n 200 ./src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx | cat -n

Repository: checkmo2025/FE

Length of output: 7480


🏁 Script executed:

wc -l ./src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx

Repository: checkmo2025/FE

Length of output: 135


🏁 Script executed:

cat -n ./src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx

Repository: checkmo2025/FE

Length of output: 7358


Await onSubmit before navigating away.

The onSubmit callback is async and awaits an API call to update team assignments, but in MemberPool.tsx line 100 it's invoked without awaiting, causing router.back() to execute immediately. If the update fails, the user is navigated away without seeing the error.

Update handleConfirmYes to await the submission:

const handleConfirmYes = async () => {
  await onSubmit();
  setIsConfirmOpen(false);
  router.back();
};

Also update the onSubmit prop type from () => void to () => Promise<void> to match the actual async function signature.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx around lines 236 -
237, The confirm handler currently calls the async onSubmit without awaiting,
causing router.back() to run before the API update completes; update the
MemberPool component's handleConfirmYes to be async and await onSubmit() before
calling setIsConfirmOpen(false) and router.back(), and update the onSubmit prop
type/signature from () => void to () => Promise<void> (where onSubmit is
declared/used) so callers and TypeScript reflect the async behavior.

Comment on lines +31 to +39
if (!Number.isFinite(groupId) || groupId <= 0) {
return (
<main className="w-full">
<div className="t:mx-auto d:mx-0 w-full max-w-[1024px] t:px-3 d:px-0">
잘못된 모임 ID
</div>
</main>
);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n src/app/groups/[id]/page.tsx | head -150

Repository: checkmo2025/FE

Length of output: 5712


Move useMemo above early returns to preserve hook order.

useMemo is currently skipped on some renders (invalid groupId, loading, error states), then executed on others. This violates React's Rules of Hooks and can crash at runtime with "Rendered fewer hooks than expected" or similar errors.

💡 Suggested structure
-  if (!Number.isFinite(groupId) || groupId <= 0) {
-    return (...);
-  }
-
-  if (isLoading) {
-    return (...);
-  }
-
-  if (isError) {
-    return (...);
-  }
-
-  const me = meQuery.data!;
-  const home = homeQuery.data!;
+  const me = meQuery.data;
+  const home = homeQuery.data;
+
+  const modalLinks = useMemo(() => {
+    const list = home?.links ?? [];
+    return list
+      .map((x, idx) => {
+        const raw = (x.link ?? "").trim();
+        if (!raw) return null;
+        const url = /^(https?:\/\/)/i.test(raw) ? raw : `http://${raw}`;
+        const label = (x.label ?? "").trim() || `링크 ${idx + 1}`;
+        return { id: `${idx}`, url, label };
+      })
+      .filter(Boolean) as { id: string; url: string; label: string }[];
+  }, [home?.links]);
+
+  if (!Number.isFinite(groupId) || groupId <= 0) return (...);
+  if (isLoading) return (...);
+  if (isError || !me || !home) return (...);

Also applies to: 41-59, 51-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/page.tsx around lines 31 - 39, The useMemo call is being
conditionally skipped by early returns (invalid groupId / loading / error),
violating React hook order; move the useMemo invocation(s) that compute memoized
values for groupId (and any related memoized values referenced in lines 41-59,
51-59) to the top of the component body—above any returns—so hooks always run in
the same order; compute derived values (e.g., parsed groupId or memoized props)
using useMemo immediately after you read params and before the
invalid-groupId/loading/error conditional blocks, and then use those memoized
values in the existing early-return branches.

Comment on lines +183 to +190
setAppliedParams({
outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region),
keyword,
});

refetchSearch();
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

setAppliedParams followed by immediate refetchSearch() can fetch with stale params.

Because state updates are async, this can trigger an extra request on the previous query state/key. Let the query run from the updated key/enabled state instead of manually refetching here.

💡 Suggested fix
   setAppliedParams({
     outputFilter: mapCategoryToOutputFilter(category),
     inputFilter: mapInputFilter(group, region),
     keyword,
   });

-  refetchSearch();
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setAppliedParams({
outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region),
keyword,
});
refetchSearch();
};
setAppliedParams({
outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region),
keyword,
});
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/page.tsx` around lines 183 - 190, The immediate call to
refetchSearch() after setAppliedParams(...) risks using stale params because
React state updates are async; remove the direct refetchSearch() invocation and
let the search query re-run from the updated query key/enabled state driven by
appliedParams (which are computed from mapCategoryToOutputFilter(category),
mapInputFilter(group, region), and keyword). If an immediate fetch is truly
required, trigger it from a useEffect that watches appliedParams (or invoke
refetchSearch from the setAppliedParams state-update completion path), but the
simplest fix is to delete the refetchSearch() call so the query reacts to the
new appliedParams instead.

Comment on lines +78 to +82
const openLinkNewTab = (url?: string) => {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the BookSelectModal.tsx file at the specified lines
head -n 85 src/components/layout/BookSelectModal.tsx | tail -n +75 | cat -n

Repository: checkmo2025/FE

Length of output: 429


🏁 Script executed:

# Check lines around 160-162 for the callsite
head -n 165 src/components/layout/BookSelectModal.tsx | tail -n +155 | cat -n

Repository: checkmo2025/FE

Length of output: 668


🏁 Script executed:

# Search for all window.open calls in the codebase
rg -nP '\bwindow\.open\s*\(' -C 3

Repository: checkmo2025/FE

Length of output: 1010


🏁 Script executed:

# Check if there are any other URL handling patterns in the file
rg -n "href|url|link" src/components/layout/BookSelectModal.tsx | head -20

Repository: checkmo2025/FE

Length of output: 305


🏁 Script executed:

# Get more context on the app/groups/[id]/page.tsx window.open call
head -n 360 src/app/groups/[id]/page.tsx | tail -n +350 | cat -n

Repository: checkmo2025/FE

Length of output: 540


Harden external URL opening with protocol validation.

The openLinkNewTab function (lines 78-82) opens URLs without scheme validation. This allows non-HTTP(S) schemes (javascript:, data:, etc.) to be opened if upstream data is compromised. Add protocol allowlisting before window.open.

🔒 Proposed fix for openLinkNewTab
  const openLinkNewTab = (url?: string) => {
    if (!url) return;
+   try {
+     const parsed = new URL(url, window.location.origin);
+     if (!["http:", "https:"].includes(parsed.protocol)) return;
+     window.open(parsed.toString(), "_blank", "noopener,noreferrer");
+   } catch {
+     return;
+   }
-   window.open(url, "_blank", "noopener,noreferrer");
  };

The same vulnerability exists at src/app/groups/[id]/page.tsx:357 where window.open(item.url, ...) is called without validation. Apply the same fix there.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/layout/BookSelectModal.tsx` around lines 78 - 82, Validate and
allowlist URL schemes before calling window.open: in openLinkNewTab, parse the
url with the URL constructor (guarding with try/catch), check that
urlObj.protocol is "http:" or "https:", and only then call window.open(url,
"_blank", "noopener,noreferrer"); apply the same change where
window.open(item.url, ...) is used (parse item.url, ensure protocol is
http/https, and skip opening otherwise) so non-HTTP(S) schemes like javascript:
or data: are rejected.

Comment on lines +69 to +98
getLatestNotice: async (clubId: number) => {
try {
const res = await apiClient.get<LatestNoticeResponse>(CLUBS.latestNotice(clubId));
return res.result;
} catch (e: any) {
const msg = e?.message ?? "";
if (
msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지"))
) {
return null;
}
throw e;
}
},

getNextMeeting: async (clubId: number) => {
try {
const res = await apiClient.get<NextMeetingResponse>(CLUBS.nextMeeting(clubId));
return res.result;
} catch (e: any) {
const msg = e?.message ?? "";
if (msg.includes("다음 정기모임이 존재하지 않습니다")) {
return null;
}
if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) {
return null;
}
throw e;
}
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/services/clubService.ts | head -120 | tail -60

Repository: checkmo2025/FE

Length of output: 2146


🏁 Script executed:

rg "apiClient\s*=" -A 2 -B 2 --type ts --type tsx | head -50

Repository: checkmo2025/FE

Length of output: 85


🏁 Script executed:

fd -e ts -e tsx | xargs rg "apiClient.*get" -B 1 -A 1 | head -80

Repository: checkmo2025/FE

Length of output: 4860


🏁 Script executed:

fd -e ts | xargs rg "apiClient\s*=" -B 5 -A 5

Repository: checkmo2025/FE

Length of output: 1420


🏁 Script executed:

fd -e ts | xargs rg "axios|fetch|http" -B 2 -A 2 | head -100

Repository: checkmo2025/FE

Length of output: 3830


🏁 Script executed:

fd -e ts | xargs rg "response.*status|error.*status" -B 2 -A 2 | head -80

Repository: checkmo2025/FE

Length of output: 1446


🏁 Script executed:

cat -n src/lib/api/client.ts

Repository: checkmo2025/FE

Length of output: 4210


🏁 Script executed:

fd -name "ApiError.ts" -o -name "ApiError.js"

Repository: checkmo2025/FE

Length of output: 288


🏁 Script executed:

cat -n src/lib/api/ApiError.ts

Repository: checkmo2025/FE

Length of output: 394


🏁 Script executed:

rg "ApiError|\.code|\.message" src/services/ -A 2 -B 2 | head -100

Repository: checkmo2025/FE

Length of output: 1899


🏁 Script executed:

rg "HTTP404|HTTP" src/ --type ts | head -30

Repository: checkmo2025/FE

Length of output: 333


🏁 Script executed:

rg "404|HTTP404|\\.code" src/services/ -B 3 -A 3 | head -100

Repository: checkmo2025/FE

Length of output: 40


🏁 Script executed:

rg "catch.*ApiError|instanceof ApiError" src/ --type ts

Repository: checkmo2025/FE

Length of output: 142


🏁 Script executed:

rg "getLatestNotice|latestNotice|nextMeeting" src/ --type ts -B 2 -A 2

Repository: checkmo2025/FE

Length of output: 4749


🏁 Script executed:

cat -n src/lib/api/ApiError.ts

Repository: checkmo2025/FE

Length of output: 394


🏁 Script executed:

rg "HTTP404|HTTP401" src/ --type ts | head -20

Repository: checkmo2025/FE

Length of output: 40


🏁 Script executed:

rg "instanceof.*ApiError|e\.code" src/ --type ts -B 2 -A 2 | head -60

Repository: checkmo2025/FE

Length of output: 462


🏁 Script executed:

rg "getLatestNotice|getNextMeeting" src/hooks/ -B 2 -A 5

Repository: checkmo2025/FE

Length of output: 1029


Use HTTP error codes for null fallbacks, not message text matching.

getLatestNotice and getNextMeeting depend on Korean message substrings, which breaks when the backend changes error messages or adds i18n.

The error object has a code property (e.g., "HTTP404") that should be checked instead:

💡 Suggested fix
-getLatestNotice: async (clubId: number) => {
+getLatestNotice: async (clubId: number) => {
   try {
     const res = await apiClient.get<LatestNoticeResponse>(CLUBS.latestNotice(clubId));
     return res.result;
-  } catch (e: any) {
-    const msg = e?.message ?? "";
-    if (
-      msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지"))
-    ) {
+  } catch (e) {
+    if ((e as any)?.code === "HTTP404") {
       return null;
     }
     throw e;
   }
 },
🧰 Tools
🪛 ESLint

[error] 73-73: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 88-88: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/clubService.ts` around lines 69 - 98, Replace brittle Korean
message substring checks in getLatestNotice and getNextMeeting with an explicit
HTTP error-code check on the caught error object (e.g., if (e?.code ===
"HTTP404") return null); locate the catch blocks in the getLatestNotice and
getNextMeeting functions that currently inspect e.message, change them to
inspect e.code (or e?.code) for the 404 indicator and return null in that case,
and otherwise rethrow the original error; keep existing calls to apiClient.get
and the returned types LatestNoticeResponse and NextMeetingResponse intact.

profileImageUrl: string | null;
region: string | null;
description: string;
profileImageUrl: string;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

profileImageUrl nullability looks inconsistent with nearby club DTOs.

In related club types, this field is nullable. Keeping it as non-null here can lead to unsafe assumptions and runtime UI issues when no image exists. Please align the contract (or normalize at service boundary).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/groups/grouphome.ts` at line 48, The profileImageUrl field in the
GroupHome DTO is currently declared as a non-null string but must be nullable to
match related club DTOs; change the profileImageUrl type to allow null (e.g.,
string | null) in the GroupHome/interface declaration and then update any
constructors, mappers, serializers, or consumers that build or read this DTO
(look for functions that create GroupHome objects or read profileImageUrl) to
handle the null case (or normalize to an empty string at the service boundary if
you prefer) and add/update tests to cover missing images.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 57 out of 60 changed files in this pull request and generated 9 comments.

Comments suppressed due to low confidence (1)

src/services/clubService.ts:49

  • searchClubs에서 cleaned: any + eslint-disable로 타입을 우회하고 있습니다. cursorId/keyword/inputFilter만 선택적으로 제거하려면 Record<string, unknown>로 잡거나, ClubSearchParams를 기반으로 한 좁은 타입(예: Partial<ClubSearchParams>)을 사용해서 any 없이 처리해 주세요.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

</div>

<div className="mx-auto w-full max-w-[1440px] px-4 t:px-6 py-3 t:pt-3 t:pb-8">
<div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8">
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

classNamepx-auto가 들어가 있는데 Tailwind padding 유틸리티에는 px-auto가 없습니다(무시되거나 lint/IDE 경고가 날 수 있음). 의도한 좌우 패딩 값(예: px-4/px-6/px-0)으로 수정하거나 해당 토큰을 제거해 주세요.

Suggested change
<div className="mx-auto w-full max-w-[1440px] px-auto t:px-6 py-3 t:pt-3 t:pb-8">
<div className="mx-auto w-full max-w-[1440px] t:px-6 py-3 t:pt-3 t:pb-8">

Copilot uses AI. Check for mistakes.
Comment on lines +1 to 7
// 공통 응답 포맷
export interface ApiResponse<T> {
isSuccess: boolean;
code: string;
message: string;
result: T;
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

이 파일에서 ApiResponse를 로컬로 다시 정의하고 있는데, 이미 src/lib/api/types.ts에 동일 타입이 존재합니다. 동일 개념의 타입이 여러 곳에 생기면 점진적으로 필드가 어긋날 수 있으니 ApiResponse는 공용 타입(@/lib/api/types)을 import 해서 사용하도록 정리해 주세요.

Copilot uses AI. Check for mistakes.
}}
items={MOCK_REVIEWS}
onClickMore={(id) => console.log("more:", id)}
items={reviewItems as any}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

topicItems/reviewItems가 이미 DebateSection/ReviewSection이 기대하는 shape로 매핑되어 있는데 as any로 타입을 무력화하고 있습니다. 추후 필드 변경 시 컴파일 단계에서 못 잡히니, topicItems/reviewItems의 반환 타입을 명시하거나 해당 컴포넌트 타입에 맞게 제네릭/타입을 맞춰 as any를 제거해 주세요.

Suggested change
items={reviewItems as any}
items={reviewItems}

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +73
if (!data) return;
const existingTeamNumbers =
data.existingTeams?.map((t: any) => Number(t.teamNumber)).filter(Number.isFinite) ?? [];

const normalized = normalizeTeams(existingTeamNumbers);
setTeams(normalized);

const mappedMembers: TeamMember[] =
data.clubMembers?.map((cm: any) => ({
clubMemberId: cm.clubMemberId,
memberInfo: {
nickname: cm.memberInfo?.nickname ?? "",
profileImageUrl: cm.memberInfo?.profileImageUrl ?? "",
},
teamNumber: cm.teamKey?.teamNumber ?? null,
})) ?? [];
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

useMeetingMembersQuerydata는 이미 응답 타입을 알고 있는데 existingTeams/clubMembersany로 캐스팅해서 매핑하고 있습니다. 실제 타입(GetMeetingMembersResponseResult)을 그대로 사용하면 필드명/nullable 이슈를 컴파일 타임에 잡을 수 있으니 any를 제거하고 타입에 맞게 매핑을 정리해 주세요(예: data.existingTeams.map(t => t.teamNumber) 등).

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +98
getLatestNotice: async (clubId: number) => {
try {
const res = await apiClient.get<LatestNoticeResponse>(CLUBS.latestNotice(clubId));
return res.result;
} catch (e: any) {
const msg = e?.message ?? "";
if (
msg.includes("공지") && (msg.includes("없") || msg.includes("존재하지"))
) {
return null;
}
throw e;
}
},

getNextMeeting: async (clubId: number) => {
try {
const res = await apiClient.get<NextMeetingResponse>(CLUBS.nextMeeting(clubId));
return res.result;
} catch (e: any) {
const msg = e?.message ?? "";
if (msg.includes("다음 정기모임이 존재하지 않습니다")) {
return null;
}
if (msg.includes("정기모임") && (msg.includes("없") || msg.includes("존재하지"))) {
return null;
}
throw e;
}
},
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

getLatestNotice/getNextMeeting에서 에러를 문자열 메시지 포함 여부로 분기하고 any로 캐치하고 있습니다. apiClientApiError(message, code, data)를 던지므로, 메시지 파싱 대신 e instanceof ApiError + e.code(또는 e.data.code) 기반으로 ‘없음’ 케이스만 null 처리하고 나머지는 그대로 throw 하는 형태로 바꿔 주세요. 이렇게 해야 메시지 변경/번역에 의해 정상 에러가 조용히 삼켜지는 것을 막을 수 있습니다.

Copilot uses AI. Check for mistakes.
Comment on lines 44 to +54
export interface ClubHomeResponseResult {
clubId: number;
name: string;
profileImageUrl: string | null;
region: string | null;
description: string;
profileImageUrl: string;
region: string;
category: ClubCategory[];
participantTypes: ParticipantType[];
links: ClubLinkItem[];
open: boolean;
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

ClubHomeResponseResult.profileImageUrl/regionstring으로 고정되어 있는데, 기존 코드에서는 null 허용이었고 다른 DTO(ClubDTO 등)에서도 profileImageUrl: string | null로 정의되어 있습니다. 실제 API가 null을 내려주면 타입이 틀어지므로 string | null(필요 시 region도)로 맞춰 주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
import { API_BASE_URL } from "@/lib/api/endpoints";

export const CLUBS_BOOKSHELF_ENDPOINTS = {

simpleBookshelves: (clubId: number | string) => `${API_BASE_URL}/clubs/${clubId}/bookshelves`,

Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

API_BASE_URL@/lib/api/endpoints(re-export index)에서 가져오면 index.ts가 다시 ./ClubsBookshelf를 re-export하면서 순환 의존이 생길 수 있습니다(이미 Clubs.ts도 같은 패턴). 런타임/번들러에 따라 값이 undefined로 초기화되는 케이스를 피하려면 API_BASE_URL@/lib/api/endpoints/base(또는 상대경로 ./base)에서 직접 import 해 주세요.

Copilot uses AI. Check for mistakes.
Comment on lines 79 to 83
}

import type { InfiniteData } from "@tanstack/react-query";

export function useBookshelfTopicsInfiniteQuery(clubId: number, meetingId: number) {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

모듈 중간(함수 선언 이후)에 import type가 들어가 있습니다. 문법적으로는 가능하지만 가독성이 떨어지고 일부 린트 규칙에서 에러가 될 수 있으니 상단 import 블록으로 이동해 주세요.

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +166
{!isEditing ? (
<p className="text-Gray-6 body_2_3 d:body_1_2 break-words whitespace-pre-wrap justifys-center">
{item.content}
</p>
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

Tailwind 클래스 justifys-center는 오타로 보이며 실제로 적용되지 않습니다. 의도한 정렬 클래스(예: justify-center)로 수정하거나 불필요하면 제거해 주세요.

Copilot uses AI. Check for mistakes.
Removed duplicate import of InfiniteData from @tanstack/react-query.
@hongik-luke hongik-luke merged commit e5d8f55 into dev Feb 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants