Skip to content

Feat-228 발제/채팅 API 우선 연결 (+ UI수정, Querykey 수정 등, 다양한 수정) - #230

Closed
hongik-luke wants to merge 11 commits into
mainfrom
feat-228
Closed

hongik-luke wants to merge 11 commits into
mainfrom
feat-228

Conversation

@hongik-luke

@hongik-luke hongik-luke commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

📌 개요 (Summary)

  • 정기모임 책장 내 발제/채팅 화면의 REST API 연결을 완료했습니다.
  • 모임 상세 응답을 기반으로 팀 정보, 팀별 발제 목록, 팀별 채팅 내역을 렌더링하도록 수정했습니다.
  • 채팅 진입 구조를 정리하여 같은 페이지 내에서 조 선택 모달과 채팅 모달이 공존하도록 분리했습니다.
  • 조 멤버 변경 직후 새로고침 없이도 최신 팀 정보와 명단이 반영되도록 캐시/반영 흐름을 수정했습니다.

🛠️ 변경 사항 (Changes)

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링
  • 문서 업데이트
  • 기타 (설명: )

상세 변경 내용

  • 발제/채팅 관련 endpoint, types, service, query hook을 추가했습니다.
  • 정기모임 발제 페이지를 실제 API 기반으로 연결했습니다.
  • 채팅 조회 로직을 API와 연결하고, 조 선택 모달 / 채팅 모달 구조로 컴포넌트를 분리했습니다.
  • 조 인원을 바로 확인할 수 있도록 구현했습니다.
  • infinite scroll이 정상 동작하지 않던 문제를 수정했습니다.
  • 채팅 모달 레이아웃 및 고정 이슈를 수정했습니다.
  • 조 멤버 수정 후 뒤 페이지에서 이전 조 정보가 유지되던 문제를 개선해 변경 사항이 바로 반영되도록 수정했습니다.
  • AI/Copilot 리뷰 반영 사항을 적용했습니다.

✅ 체크리스트 (Checklist)

  • 빌드가 성공적으로 수행되었나요? (pnpm build)
  • 린트 에러가 없나요? (pnpm lint)
  • 불필요한 콘솔 로그나 주석을 제거했나요?

Summary by CodeRabbit

  • New Features

    • Infinite scroll pagination for debates and reviews with explicit "load more" sentinel
    • New meeting chat modal with team selector, message history loading, draggable positioning, and layered navigation
    • Meeting team member popover showing member list and profile links
  • Improvements

    • Disabled/async-aware submit flow with clearer "saving" state
    • More robust topic and meeting data loading with improved selection and loading feedback

@vercel

vercel Bot commented Mar 22, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
check-mo Ready Ready Preview, Comment Mar 22, 2026 8:05am
checkmo Ready Ready Preview, Comment Mar 22, 2026 8:05am

@coderabbitai

coderabbitai Bot commented Mar 22, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds infinite-scroll pagination to debate/review lists, implements a draggable meeting chat modal with infinite upward loading, refactors meeting page to use remote meeting/topic data, and introduces new endpoints, services, hooks, types, and UI components for meeting chats and topics.

Changes

Cohort / File(s) Summary
Type Definitions
src/types/groups/meetingChats.ts, src/types/groups/meetingTopics.ts, src/types/groups/meetingDetail.ts
New meeting chat/topic types and API response shapes; MeetingMemberInfo.profileImageUrl now accepts null.
API Endpoints
src/lib/api/endpoints/Clubs.ts
Added meetingTeamTopics and meetingTeamChatMessages endpoint builders.
Services
src/services/meetingChatService.ts, src/services/meetingTopicService.ts
New services to fetch meeting chats (paginated) and meeting topics.
Query Hooks
src/hooks/queries/useMeetingChatQueries.ts, src/hooks/queries/useMeetingTopicQueries.ts
Added useMeetingChatsInfiniteQuery (infiniteQuery) and useMeetingTopicsQuery (useQuery) with typed keys and conditional enabling.
Meeting Mutations
src/hooks/mutations/useMeetingMutations.ts
useUpdateMeetingTeamsMutation.onSuccess now async: batch invalidates and prefetches detail/members queries.
Meeting Page & Routing
src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx, src/app/groups/[id]/bookcase/[bookId]/page.tsx
Refactored meeting page to use remote queries, useParams, router state, role-aware team logic, chat modal integration; wired pagination props for topics/reviews to fetchNextPage.
Admin Page Cleanup
src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx
Removed unnecessary any casts and simplified effect flow/state around initialization.
Discussion Components (pagination)
src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx, src/app/groups/[id]/bookcase/[bookId]/ReviewSection.tsx
Extended props with hasNextPage, isFetchingNextPage, onLoadMore; added react-intersection-observer sentinel and load-more UI.
Chat UI Components
src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx, src/components/base-ui/Bookcase/MeetingChatModal.tsx, src/components/base-ui/Bookcase/MeetingTeamMemberPopover.tsx
Reworked ChatTeamSelectModal to delegate selection via onSelectTeam; added new MeetingChatModal (draggable, infinite-up scroll, team selector); added MeetingTeamMemberPopover to show team member list.
Admin MemberPool
src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
onSubmit prop now async (() => Promise<void>); added isSubmitting guard, async handler, disabled UI state and label change while submitting.

Sequence Diagram

sequenceDiagram
    participant User
    participant IntersectionObserver as IntersectionObserver
    participant Section as Debate/Review Section
    participant QueryHook as useMeetingChatsInfiniteQuery
    participant Service as meetingChatService / API

    User->>User: Scrolls near bottom
    User->>IntersectionObserver: Sentinel enters viewport
    IntersectionObserver->>Section: inView true
    Section->>Section: if hasNextPage && !isFetchingNextPage
    Section->>QueryHook: onLoadMore() / fetchNextPage()
    QueryHook->>Service: GET /clubs/{clubId}/meetings/{meetingId}/teams/{teamId}/chat/messages?cursor=...
    Service-->>QueryHook: paginated chats + nextCursor
    QueryHook-->>Section: append pages, update isFetchingNextPage
    Section->>User: render additional items / hide sentinel when done
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Suggested labels

✨ feat

Poem

I hopped through code with tiny feet,
Infinite scroll and chats to meet, 🐇
Topics bloom and members peep,
Draggable windows, messages creep—
A bookcase buzzing, soft and fleet.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive Title partially relates to main changes (API connection, UI modifications) but uses vague phrasing ('다양한 수정' = 'various modifications') and mixes Korean/English which obscures clarity for non-Korean speakers; primary change is connecting topics/chat to APIs but secondary items ('UI수정, Querykey 수정 등') are too generic. Simplify title to emphasize primary objective: e.g., 'Connect meeting topics and chat to REST APIs' or 'Implement meeting chat and topics API integration' to improve clarity for all reviewers.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-228

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.

Tip

CodeRabbit can use oxc to improve the quality of JavaScript and TypeScript code reviews.

Add a configuration file to your project to customize how CodeRabbit runs oxc.

@gemini-code-assist

Copy link
Copy Markdown
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!

이 PR은 정기모임의 발제 및 채팅 화면을 백엔드 REST API와 통합하는 것을 목표로 합니다. 모임 상세 응답을 기반으로 팀 정보, 발제 목록, 채팅 내역을 동적으로 렌더링하도록 UI를 재구성했습니다. 사용자 경험을 향상시키기 위해 팀 멤버 변경 시 실시간 업데이트를 보장하고, 발제 및 한줄평 목록에 무한 스크롤을 도입했습니다. 또한, 채팅 기능은 전용 모달과 데스크톱 환경에서의 드래그 지원으로 강화되었습니다.

Highlights

  • API 연결: 정기모임 발제/채팅 화면의 REST API 연결을 완료하여 실제 데이터를 기반으로 UI를 렌더링합니다.
  • UI/UX 개선: 채팅 진입 구조를 조 선택 모달과 채팅 모달로 분리하여 같은 페이지 내에서 공존하도록 개선했습니다. 또한, 발제 및 한줄평 목록에 무한 스크롤 기능을 추가했습니다.
  • 실시간 데이터 반영: 조 멤버 변경 직후 새로고침 없이도 최신 팀 정보와 명단이 반영되도록 캐시/반영 흐름을 수정했습니다.
  • 채팅 모달 드래그 기능: 데스크톱 환경에서 채팅 모달을 드래그하여 위치를 조정할 수 있는 기능을 추가했습니다.
  • 새로운 API 엔드포인트 및 쿼리 훅 추가: 모임 발제 및 채팅 메시지를 위한 새로운 API 엔드포인트와 TanStack Query 훅을 추가했습니다.

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

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.

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

정기모임의 발제/채팅 기능에 실제 API를 연동하는 큰 규모의 업데이트네요. 더미 데이터를 react-query 기반의 실제 데이터로 교체하고, 채팅 UI를 별도 페이지에서 모달 형태로 리팩토링하는 등 코드 구조와 사용자 경험 양쪽에서 큰 개선이 이루어졌습니다. 특히 복잡했던 채팅 모달을 조 선택과 채팅 화면으로 분리하여 컴포넌트의 단일 책임 원칙을 잘 지키도록 수정한 점이 인상적입니다. 또한, 조 편성 후 데이터 캐시를 미리 가져와서 화면에 즉시 반영되도록 한 최적화도 훌륭합니다. 전반적으로 매우 완성도 높은 변경 사항이라고 생각합니다. 한 가지 작은 개선점을 발견하여 코멘트를 남겼으니 확인 부탁드립니다.

Comment on lines +65 to +66
const clubId = Number(params.id as string);
const meetingId = Number(params.bookId as string);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

useParams에서 가져온 값을 Number로 변환할 때 as string으로 타입 단언을 사용하셨네요. params.id가 undefined일 경우 Number(undefined)는 NaN이 되는데, as string을 사용하면 Number(undefined as string)이 되어 타입스크립트가 잠재적 오류를 발견하기 어렵게 만듭니다. 이 PR의 다른 파일(admin/.../page.tsx)에서는 toNumber 헬퍼 함수를 사용하거나 타입 단언 없이 처리하고 있습니다. 일관성과 타입 안정성을 위해 타입 단언을 제거하는 것을 제안합니다. useQuery의 enabled 옵션에서 Number.isFinite로 이미 NaN 값을 잘 처리하고 있으므로, as string 없이 Number(params.id)로 변경해도 안전하게 동작할 것입니다.

Suggested change
const clubId = Number(params.id as string);
const meetingId = Number(params.bookId as string);
const clubId = Number(params.id);
const meetingId = Number(params.bookId);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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/bookcase/[meetingId]/page.tsx (1)

45-45: ⚠️ Potential issue | 🟠 Major

isInitialized is declared but never set to true, causing stale loading behavior and potential data loss.

The state isInitialized (line 45) is never updated to true, which means:

  1. showLoading (line 183) always treats !isInitialized as true, showing the loading indicator on every refetch.
  2. Without a guard, the effect resets local teams and members state on every data change (including background refetches on window focus), potentially discarding unsaved drag-and-drop edits.

Either restore the initialization logic or remove isInitialized entirely if the old behavior is no longer needed.

🐛 Option 1: Restore the initialization guard
   useEffect(() => {
     if (!data) return;
+    if (isInitialized) return;

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

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

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

     setMembers(mappedMembers);
+    setIsInitialized(true);
-  }, [data]);
+  }, [data, isInitialized]);
🐛 Option 2: Remove unused isInitialized state
-  const [isInitialized, setIsInitialized] = useState(false);
   // ...
-  const showLoading = isLoading || (!isInitialized && isFetching);
+  const showLoading = isLoading;

Also applies to: 56-76

🤖 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 at line 45, The
isInitialized state (isInitialized / setIsInitialized) is never flipped, causing
showLoading to remain true and effects that reset local teams/members on every
data change; fix by either (A) restoring the initialization guard: in the effect
that currently consumes data (the useEffect that assigns teams and members from
data) detect the first successful load (e.g., data present and not previously
initialized) and call setIsInitialized(true) after applying initial
teams/members so subsequent refetches don't clobber local state, or (B) remove
isInitialized entirely and update showLoading to rely only on query flags
(isLoading/isFetching) and modify the data effect to merge updates instead of
resetting local teams/members on background refetches; reference the variables
isInitialized, setIsInitialized, showLoading, teams, members, and the
data-handling useEffect when making the change.
🧹 Nitpick comments (6)
src/hooks/mutations/useMeetingMutations.ts (1)

18-39: Redundant concurrent invalidation and prefetch for the same query keys.

Running invalidateQueries and prefetchQuery concurrently for the same keys (detailKey, membersKey) causes redundant network requests. invalidateQueries already triggers a background refetch for active queries, making the parallel prefetchQuery calls wasteful.

Consider either:

  1. Just invalidate and let React Query handle refetching when data is accessed.
  2. Use only prefetchQuery (without invalidation) if you want to guarantee fresh data is cached before proceeding.
♻️ Simplified approach using only invalidation
     onSuccess: async (_, vars) => {
       const detailKey = meetingQueryKeys.detail(vars.clubId, vars.meetingId);
       const membersKey = meetingQueryKeys.members(vars.clubId, vars.meetingId);

       await Promise.all([
         qc.invalidateQueries({ queryKey: detailKey }),
         qc.invalidateQueries({ queryKey: membersKey }),
-
-        qc.prefetchQuery({
-          queryKey: detailKey,
-          queryFn: () =>
-            meetingService.getMeetingDetail({
-              clubId: vars.clubId,
-              meetingId: vars.meetingId,
-            }),
-        }),
-
-        qc.prefetchQuery({
-          queryKey: membersKey,
-          queryFn: () =>
-            meetingService.getMeetingMembers({
-              clubId: vars.clubId,
-              meetingId: vars.meetingId,
-            }),
-        }),
       ]);
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/mutations/useMeetingMutations.ts` around lines 18 - 39, The block
currently calls qc.invalidateQueries and qc.prefetchQuery for the same keys
(detailKey, membersKey) causing duplicate fetches; pick one strategy—here remove
the qc.prefetchQuery calls and only await the two qc.invalidateQueries for
detailKey and membersKey so React Query will handle background refetching for
active queries; update the Promise.all to only include qc.invalidateQueries({
queryKey: detailKey }) and qc.invalidateQueries({ queryKey: membersKey }) and
delete the prefetchQuery calls that call meetingService.getMeetingDetail /
getMeetingMembers using vars.clubId and vars.meetingId.
src/components/base-ui/Bookcase/MeetingChatModal.tsx (2)

100-136: Consider extracting stable callback references for observer dependencies.

The effect recreates the IntersectionObserver whenever any of chatsQuery.hasNextPage, isFetchingNextPage, or fetchNextPage changes. While fetchNextPage is stable from React Query, the boolean flags can change frequently during pagination.

This is acceptable for correctness, but if you notice performance issues with rapid observer teardown/setup, consider reading flags inside the callback via a ref.

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

In `@src/components/base-ui/Bookcase/MeetingChatModal.tsx` around lines 100 - 136,
The effect recreates the IntersectionObserver frequently because it depends on
changing booleans (chatsQuery.hasNextPage, chatsQuery.isFetchingNextPage) and
fetchNextPage; extract stable callback refs so the observer only depends on
stable values: create refs (e.g., hasNextPageRef, isFetchingNextPageRef,
fetchNextPageRef) and update them in a small effect when chatsQuery changes,
then use a single stable intersection handler (handleIntersect) that reads those
refs and calls fetchNextPageRef.current(); keep the observer effect dependent
only on isOpen, selectedTeamId, topSentinelRef, and scrollRef so it doesn't
teardown/recreate on every flag flip.

39-50: Redundant condition in sort comparator.

After deduplication by messageId, all IDs are unique. The condition if (a.messageId !== b.messageId) is always true, so the sendAt fallback comparison never executes.

♻️ Simplified sort
 function dedupeAndSortMessages(list: MeetingChatMessageItem[]) {
   const map = new Map<number, MeetingChatMessageItem>();

   for (const item of list) {
     map.set(item.messageId, item);
   }

-  return Array.from(map.values()).sort((a, b) => {
-    if (a.messageId !== b.messageId) return a.messageId - b.messageId;
-    return new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime();
-  });
+  return Array.from(map.values()).sort((a, b) => a.messageId - b.messageId);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/base-ui/Bookcase/MeetingChatModal.tsx` around lines 39 - 50,
The sort comparator in dedupeAndSortMessages contains a redundant branch: after
deduplication by messageId (Map keyed on item.messageId) IDs are unique so the
if (a.messageId !== b.messageId) check and the sendAt fallback never runs;
simplify the comparator in dedupeAndSortMessages to sort only by messageId
(e.g., return a.messageId - b.messageId) or, if you prefer to keep a
deterministic fallback, use a single expression that compares messageId then
sendAt when equal (but the equality case should be unreachable). Update the
comparator in dedupeAndSortMessages to remove the unreachable condition while
keeping map, messageId and sendAt references intact.
src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx (1)

5-9: Consider aligning teamId type with downstream consumers.

ChatTeam.teamId is typed as string, but MeetingChatModal expects selectedTeamId: number and uses Number(team.teamId) for comparison. This implicit coercion could cause subtle bugs if teamId contains non-numeric strings.

If the API returns numeric IDs, consider typing teamId as number for consistency, or document that string-to-number conversion is expected.

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

In `@src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx` around lines 5 - 9,
Change the ChatTeam.teamId type to number (instead of string) so it aligns with
MeetingChatModal's selectedTeamId and avoids Number(team.teamId) coercion;
update the ChatTeam type definition (ChatTeam) and any places
constructing/consuming team objects to use numeric IDs, and remove the implicit
Number(...) conversion in MeetingChatModal where it compares selectedTeamId to
team.teamId.
src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx (2)

272-283: Extract magic numbers for panel dimensions as constants.

The hardcoded panelWidth = 426 and panelHeight = 716 could drift out of sync with the actual MeetingChatModal dimensions. Consider extracting these as shared constants or deriving them from the modal component.

💡 Suggested refactor
+const CHAT_PANEL_WIDTH = 426;
+const CHAT_PANEL_HEIGHT = 716;
+const MOBILE_BREAKPOINT = 768;

// In handleChatDragStart:
-    if (window.innerWidth < 768) return;
+    if (window.innerWidth < MOBILE_BREAKPOINT) return;

// In handlePointerMove:
-      const panelWidth = 426;
-      const panelHeight = 716;
+      const panelWidth = CHAT_PANEL_WIDTH;
+      const panelHeight = CHAT_PANEL_HEIGHT;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx around lines 272 -
283, Replace the magic numbers for panel dimensions by extracting them into
shared constants or deriving them from the modal component: remove the inline
panelWidth = 426 and panelHeight = 716 and instead reference a single exported
constant (e.g., MEETING_CHAT_MODAL_WIDTH and MEETING_CHAT_MODAL_HEIGHT) or a
getter on MeetingChatModal so the nextX/nextY calculations use those symbols;
update the references in the drag logic that use dragRef.current.originX and
originY to use the new constants to keep sizing consistent across the component
and the modal.

189-199: Consider aligning ChatTeam.teamId type to avoid repeated conversions.

The ChatTeam type expects teamId: string, but the internal team data uses number. This leads to String() conversions here and Number() conversions elsewhere (lines 211, 213, 237, 514). While functional, this is error-prone.

If the ChatTeam type in ChatTeamSelectModal can be updated to use number, it would eliminate these conversions and reduce potential bugs from type coercion.

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

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx around lines 189 -
199, The ChatTeam type and related uses should use number for teamId to avoid
String/Number coercion: update the ChatTeam interface (and any props in
ChatTeamSelectModal) to declare teamId: number, change the chatSelectableTeams
mapping in useMemo to assign teamId: team.teamId (no String()), and update all
comparisons/filters (e.g., the filter that compares team.teamId to myTeamId and
any places expecting string ids) to use numeric comparisons (no Number()). Also
update any state, prop types, and event handlers that consume ChatTeam.teamId to
accept a number.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx:
- Line 77: The initial chatPosition ({ x: 980, y: 56 }) can place the modal
off-screen on narrower viewports; update the initialization of chatPosition in
the useState call so x is computed from the current viewport width (e.g.,
window.innerWidth) minus the panel width and a margin, then clamp x to a minimum
value so it never starts off-screen; implement this either by using a lazy
initializer for useState that reads window.innerWidth (guarded for SSR) or by
setting chatPosition on first render in a useEffect that calls setChatPosition,
referencing the chatPosition and setChatPosition identifiers and ensuring the
panel width (426) and a minimal margin are used when computing/clamping x.

In `@src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx`:
- Around line 95-107: The submit flow allows modal closes while an in-flight
submit is running; fix by blocking every modal-close path while isSubmitting is
true: in handleConfirmYes keep setIsSubmitting/use await as-is but ensure all
close handlers (e.g., onCancel, onClose, any handleClose or close-button
handlers) early-return when isSubmitting and do not call setIsConfirmOpen(false)
or router.back; also disable backdrop/escape-close via the modal props while
isSubmitting and only call router.back after onSubmit resolves and when not
interrupted. Apply the same guard to the other close handlers referenced (the
handlers around the other modal-close sites in the file).

---

Outside diff comments:
In `@src/app/groups/`[id]/admin/bookcase/[meetingId]/page.tsx:
- Line 45: The isInitialized state (isInitialized / setIsInitialized) is never
flipped, causing showLoading to remain true and effects that reset local
teams/members on every data change; fix by either (A) restoring the
initialization guard: in the effect that currently consumes data (the useEffect
that assigns teams and members from data) detect the first successful load
(e.g., data present and not previously initialized) and call
setIsInitialized(true) after applying initial teams/members so subsequent
refetches don't clobber local state, or (B) remove isInitialized entirely and
update showLoading to rely only on query flags (isLoading/isFetching) and modify
the data effect to merge updates instead of resetting local teams/members on
background refetches; reference the variables isInitialized, setIsInitialized,
showLoading, teams, members, and the data-handling useEffect when making the
change.

---

Nitpick comments:
In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx:
- Around line 272-283: Replace the magic numbers for panel dimensions by
extracting them into shared constants or deriving them from the modal component:
remove the inline panelWidth = 426 and panelHeight = 716 and instead reference a
single exported constant (e.g., MEETING_CHAT_MODAL_WIDTH and
MEETING_CHAT_MODAL_HEIGHT) or a getter on MeetingChatModal so the nextX/nextY
calculations use those symbols; update the references in the drag logic that use
dragRef.current.originX and originY to use the new constants to keep sizing
consistent across the component and the modal.
- Around line 189-199: The ChatTeam type and related uses should use number for
teamId to avoid String/Number coercion: update the ChatTeam interface (and any
props in ChatTeamSelectModal) to declare teamId: number, change the
chatSelectableTeams mapping in useMemo to assign teamId: team.teamId (no
String()), and update all comparisons/filters (e.g., the filter that compares
team.teamId to myTeamId and any places expecting string ids) to use numeric
comparisons (no Number()). Also update any state, prop types, and event handlers
that consume ChatTeam.teamId to accept a number.

In `@src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx`:
- Around line 5-9: Change the ChatTeam.teamId type to number (instead of string)
so it aligns with MeetingChatModal's selectedTeamId and avoids
Number(team.teamId) coercion; update the ChatTeam type definition (ChatTeam) and
any places constructing/consuming team objects to use numeric IDs, and remove
the implicit Number(...) conversion in MeetingChatModal where it compares
selectedTeamId to team.teamId.

In `@src/components/base-ui/Bookcase/MeetingChatModal.tsx`:
- Around line 100-136: The effect recreates the IntersectionObserver frequently
because it depends on changing booleans (chatsQuery.hasNextPage,
chatsQuery.isFetchingNextPage) and fetchNextPage; extract stable callback refs
so the observer only depends on stable values: create refs (e.g.,
hasNextPageRef, isFetchingNextPageRef, fetchNextPageRef) and update them in a
small effect when chatsQuery changes, then use a single stable intersection
handler (handleIntersect) that reads those refs and calls
fetchNextPageRef.current(); keep the observer effect dependent only on isOpen,
selectedTeamId, topSentinelRef, and scrollRef so it doesn't teardown/recreate on
every flag flip.
- Around line 39-50: The sort comparator in dedupeAndSortMessages contains a
redundant branch: after deduplication by messageId (Map keyed on item.messageId)
IDs are unique so the if (a.messageId !== b.messageId) check and the sendAt
fallback never runs; simplify the comparator in dedupeAndSortMessages to sort
only by messageId (e.g., return a.messageId - b.messageId) or, if you prefer to
keep a deterministic fallback, use a single expression that compares messageId
then sendAt when equal (but the equality case should be unreachable). Update the
comparator in dedupeAndSortMessages to remove the unreachable condition while
keeping map, messageId and sendAt references intact.

In `@src/hooks/mutations/useMeetingMutations.ts`:
- Around line 18-39: The block currently calls qc.invalidateQueries and
qc.prefetchQuery for the same keys (detailKey, membersKey) causing duplicate
fetches; pick one strategy—here remove the qc.prefetchQuery calls and only await
the two qc.invalidateQueries for detailKey and membersKey so React Query will
handle background refetching for active queries; update the Promise.all to only
include qc.invalidateQueries({ queryKey: detailKey }) and qc.invalidateQueries({
queryKey: membersKey }) and delete the prefetchQuery calls that call
meetingService.getMeetingDetail / getMeetingMembers using vars.clubId and
vars.meetingId.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5f787f25-e036-41f4-ab72-2f3862ab40c6

📥 Commits

Reviewing files that changed from the base of the PR and between 406d889 and 470f7d7.

📒 Files selected for processing (18)
  • src/app/groups/[id]/admin/bookcase/[meetingId]/page.tsx
  • src/app/groups/[id]/bookcase/[bookId]/DebateSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/ReviewSection.tsx
  • src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx
  • src/app/groups/[id]/bookcase/[bookId]/page.tsx
  • src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
  • src/components/base-ui/Bookcase/ChatTeamSelectModal.tsx
  • src/components/base-ui/Bookcase/MeetingChatModal.tsx
  • src/components/base-ui/Bookcase/MeetingTeamMemberPopover.tsx
  • src/hooks/mutations/useMeetingMutations.ts
  • src/hooks/queries/useMeetingChatQueries.ts
  • src/hooks/queries/useMeetingTopicQueries.ts
  • src/lib/api/endpoints/Clubs.ts
  • src/services/meetingChatService.ts
  • src/services/meetingTopicService.ts
  • src/types/groups/meetingChats.ts
  • src/types/groups/meetingDetail.ts
  • src/types/groups/meetingTopics.ts


const selectedTeamName = selectedTeam?.teamName ?? "";
const teamNames = useMemo(() => teams.map((t) => t.teamName), [teams]);
const [chatPosition, setChatPosition] = useState({ x: 980, y: 56 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hardcoded initial position may place the chat modal off-screen on narrower desktops.

The initial x: 980 combined with a panel width of 426px means the modal requires at least ~1406px viewport width to be fully visible. On smaller desktop screens, the modal will start partially off-screen.

Consider initializing based on viewport dimensions or clamping on first render.

💡 Suggested approach
- const [chatPosition, setChatPosition] = useState({ x: 980, y: 56 });
+ const [chatPosition, setChatPosition] = useState(() => {
+   if (typeof window === "undefined") return { x: 980, y: 56 };
+   const panelWidth = 426;
+   const x = Math.min(980, window.innerWidth - panelWidth - 16);
+   return { x: Math.max(0, x), y: 56 };
+ });
📝 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 [chatPosition, setChatPosition] = useState({ x: 980, y: 56 });
const [chatPosition, setChatPosition] = useState(() => {
if (typeof window === "undefined") return { x: 980, y: 56 };
const panelWidth = 426;
const x = Math.min(980, window.innerWidth - panelWidth - 16);
return { x: Math.max(0, x), y: 56 };
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx at line 77, The
initial chatPosition ({ x: 980, y: 56 }) can place the modal off-screen on
narrower viewports; update the initialization of chatPosition in the useState
call so x is computed from the current viewport width (e.g., window.innerWidth)
minus the panel width and a margin, then clamp x to a minimum value so it never
starts off-screen; implement this either by using a lazy initializer for
useState that reads window.innerWidth (guarded for SSR) or by setting
chatPosition on first render in a useEffect that calls setChatPosition,
referencing the chatPosition and setChatPosition identifiers and ensuring the
panel width (426) and a minimal margin are used when computing/clamping x.

Comment on lines +95 to +107
const handleConfirmYes = async () => {
if (isSubmitting) return;

try {
setIsSubmitting(true);
await onSubmit();
setIsConfirmOpen(false);
router.back();
} catch (error) {
console.error(error);
} finally {
setIsSubmitting(false);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Block all modal-close paths while submitting.

Line 173 and Line 195 still allow closing during in-flight submit. That can hide ongoing work and then navigate unexpectedly when router.back() runs after completion.

Suggested fix
@@
-          <div
-            className="fixed inset-0 z-40 bg-black/50 backdrop-blur-xs px-4 py-4"
-            onClick={() => setIsConfirmOpen(false)}
-          />
+          <div
+            className="fixed inset-0 z-40 bg-black/50 backdrop-blur-xs px-4 py-4"
+            onClick={() => {
+              if (!isSubmitting) setIsConfirmOpen(false);
+            }}
+          />
@@
                 <button
                   type="button"
                   onClick={() => setIsConfirmOpen(false)}
+                  disabled={isSubmitting}
                   className={[
                     "flex-1 h-[44px] rounded-[8px]",
                     "border border-Subbrown-4",
                     "text-Gray-7 body_1_2",
                     "cursor-pointer hover:brightness-90 active:brightness-90",
+                    isSubmitting ? "opacity-60 cursor-not-allowed" : "",
                   ].join(" ")}
                 >

Also applies to: 183-191

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

In `@src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx`
around lines 95 - 107, The submit flow allows modal closes while an in-flight
submit is running; fix by blocking every modal-close path while isSubmitting is
true: in handleConfirmYes keep setIsSubmitting/use await as-is but ensure all
close handlers (e.g., onCancel, onClose, any handleClose or close-button
handlers) early-return when isSubmitting and do not call setIsConfirmOpen(false)
or router.back; also disable backdrop/escape-close via the modal props while
isSubmitting and only call router.back after onSubmit resolves and when not
interrupted. Apply the same guard to the other close handlers referenced (the
handlers around the other modal-close sites in the file).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx (1)

77-77: ⚠️ Potential issue | 🟡 Minor

Hardcoded initial chat position can still start off-screen.

Line 77 still initializes chatPosition.x to a fixed 980, so narrower desktop widths can render the modal partially outside the viewport on first open.

Suggested fix
-  const [chatPosition, setChatPosition] = useState({ x: 980, y: 56 });
+  const [chatPosition, setChatPosition] = useState(() => {
+    if (typeof window === "undefined") return { x: 980, y: 56 };
+    const panelWidth = 426;
+    const x = Math.min(980, window.innerWidth - panelWidth - 16);
+    return { x: Math.max(0, x), y: 56 };
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx at line 77, The
initial chatPosition is hardcoded to x:980 which can place the modal off-screen
on narrow viewports; update the initialization to compute a safe x on the client
(or defer to client) and clamp it inside the viewport: in page.tsx replace the
hardcoded useState({ x: 980, y: 56 }) logic for chatPosition/setChatPosition
with a client-aware approach (e.g., initialize to a neutral value or 0 and in a
useEffect run on mount compute window.innerWidth, subtract the modal width and a
margin, then setChatPosition({ x: Math.max(minX, Math.min(desiredX,
window.innerWidth - modalWidth - margin)), y: 56 }) so the modal always starts
fully visible; reference the chatPosition and setChatPosition state and the
useEffect that runs after mount to perform this clamping.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx:
- Around line 133-138: The current parsing uses
Number(searchParams.get("teamId")) which converts null to 0 and therefore can't
distinguish "missing" from an actual 0 teamId; change the logic to first read
the raw param (searchParams.get("teamId")) and only convert to Number when it's
non-null, then guard matchedById using a finite-number check (e.g., ensure
teamIdFromQuery is not undefined and Number.isFinite(teamIdFromQuery)) before
calling teams.find; update the identifiers teamIdFromQuery, matchedById, and the
searchParams.get("teamId") usage accordingly.
- Around line 110-123: myTeamId currently picks a team by matching
user?.nickname against meetingData.teamMembers which is ambiguous if nicknames
are duplicated; change the logic in the myTeamId useMemo to fail-closed: compute
all team matches for the trimmed myNickname across meetingData.teamMembers (use
group.members/member.memberInfo.nickname), and if zero matches return null, if
exactly one return that group.teamKey.teamId, but if more than one (duplicate
nickname across teams) return null to avoid misattributing team membership until
a stable member id is available.

---

Duplicate comments:
In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx:
- Line 77: The initial chatPosition is hardcoded to x:980 which can place the
modal off-screen on narrow viewports; update the initialization to compute a
safe x on the client (or defer to client) and clamp it inside the viewport: in
page.tsx replace the hardcoded useState({ x: 980, y: 56 }) logic for
chatPosition/setChatPosition with a client-aware approach (e.g., initialize to a
neutral value or 0 and in a useEffect run on mount compute window.innerWidth,
subtract the modal width and a margin, then setChatPosition({ x: Math.max(minX,
Math.min(desiredX, window.innerWidth - modalWidth - margin)), y: 56 }) so the
modal always starts fully visible; reference the chatPosition and
setChatPosition state and the useEffect that runs after mount to perform this
clamping.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3e80ec19-d6cf-4ce8-994a-a3ac2ccc9982

📥 Commits

Reviewing files that changed from the base of the PR and between 470f7d7 and b2ae9a3.

📒 Files selected for processing (1)
  • src/app/groups/[id]/bookcase/[bookId]/meeting/page.tsx

Comment on lines +110 to +123
const myTeamId = useMemo(() => {
// TODO:
// 현재 응답에는 "내 clubMemberId"가 없어서 nickname 기반 임시 판별만 가능.
// 추후 BE가 내 clubMemberId를 내려주면 그 값 기준으로 교체할 것.
const myNickname = user?.nickname?.trim();
if (!myNickname || !meetingData) return null;

for (const group of meetingData.teamMembers ?? []) {
const matched = group.members.some(
(member) => member.memberInfo.nickname === myNickname
);
if (matched) return group.teamKey.teamId;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

myTeamId nickname matching is ambiguous and can resolve to the wrong team.

At Line 119, nickname-only matching can produce incorrect team identity when nicknames are duplicated, which then affects topic-action gating and chat team filtering.

Safer fail-closed fallback (until immutable member id is available)
-    for (const group of meetingData.teamMembers ?? []) {
-      const matched = group.members.some(
-        (member) => member.memberInfo.nickname === myNickname
-      );
-      if (matched) return group.teamKey.teamId;
-    }
-
-    return null;
+    const matchedTeamIds = (meetingData.teamMembers ?? [])
+      .filter((group) =>
+        group.members.some((member) => member.memberInfo.nickname?.trim() === myNickname)
+      )
+      .map((group) => group.teamKey.teamId);
+
+    return matchedTeamIds.length === 1 ? matchedTeamIds[0] : 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 myTeamId = useMemo(() => {
// TODO:
// 현재 응답에는 "내 clubMemberId"가 없어서 nickname 기반 임시 판별만 가능.
// 추후 BE가 내 clubMemberId를 내려주면 그 값 기준으로 교체할 것.
const myNickname = user?.nickname?.trim();
if (!myNickname || !meetingData) return null;
for (const group of meetingData.teamMembers ?? []) {
const matched = group.members.some(
(member) => member.memberInfo.nickname === myNickname
);
if (matched) return group.teamKey.teamId;
}
const myTeamId = useMemo(() => {
// TODO:
// 현재 응답에는 "내 clubMemberId"가 없어서 nickname 기반 임시 판별만 가능.
// 추후 BE가 내 clubMemberId를 내려주면 그 값 기준으로 교체할 것.
const myNickname = user?.nickname?.trim();
if (!myNickname || !meetingData) return null;
const matchedTeamIds = (meetingData.teamMembers ?? [])
.filter((group) =>
group.members.some((member) => member.memberInfo.nickname?.trim() === myNickname)
)
.map((group) => group.teamKey.teamId);
return matchedTeamIds.length === 1 ? matchedTeamIds[0] : null;
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx around lines 110 -
123, myTeamId currently picks a team by matching user?.nickname against
meetingData.teamMembers which is ambiguous if nicknames are duplicated; change
the logic in the myTeamId useMemo to fail-closed: compute all team matches for
the trimmed myNickname across meetingData.teamMembers (use
group.members/member.memberInfo.nickname), and if zero matches return null, if
exactly one return that group.teamKey.teamId, but if more than one (duplicate
nickname across teams) return null to avoid misattributing team membership until
a stable member id is available.

Comment on lines +133 to +138
const teamIdFromQuery = Number(searchParams.get("teamId"));
const teamLabelFromQuery = searchParams.get("team");

setTeams(res);
const matchedById = Number.isFinite(teamIdFromQuery)
? teams.find((team) => team.teamId === teamIdFromQuery)
: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

teamId query parsing treats missing value as 0.

At Line 133, Number(searchParams.get("teamId")) converts null to 0, so “no teamId in URL” is not clearly distinguished from an actual numeric value.

Suggested fix
-    const teamIdFromQuery = Number(searchParams.get("teamId"));
+    const rawTeamId = searchParams.get("teamId");
+    const teamIdFromQuery = rawTeamId === null ? Number.NaN : Number(rawTeamId);
📝 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 teamIdFromQuery = Number(searchParams.get("teamId"));
const teamLabelFromQuery = searchParams.get("team");
setTeams(res);
const matchedById = Number.isFinite(teamIdFromQuery)
? teams.find((team) => team.teamId === teamIdFromQuery)
: undefined;
const rawTeamId = searchParams.get("teamId");
const teamIdFromQuery = rawTeamId === null ? Number.NaN : Number(rawTeamId);
const teamLabelFromQuery = searchParams.get("team");
const matchedById = Number.isFinite(teamIdFromQuery)
? teams.find((team) => team.teamId === teamIdFromQuery)
: undefined;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/groups/`[id]/bookcase/[bookId]/meeting/page.tsx around lines 133 -
138, The current parsing uses Number(searchParams.get("teamId")) which converts
null to 0 and therefore can't distinguish "missing" from an actual 0 teamId;
change the logic to first read the raw param (searchParams.get("teamId")) and
only convert to Number when it's non-null, then guard matchedById using a
finite-number check (e.g., ensure teamIdFromQuery is not undefined and
Number.isFinite(teamIdFromQuery)) before calling teams.find; update the
identifiers teamIdFromQuery, matchedById, and the searchParams.get("teamId")
usage accordingly.

This branch was successfully deployed

2 active deployments
Preview – check-mo — b2ae9a3b Deployed Mar 22, 2026 by vercel[bot]
Preview – checkmo — b2ae9a3b Deployed Mar 22, 2026 by vercel[bot]
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.

1 participant