Skip to content

Feat/136 like subscribe api#137

Open
shinwokkang wants to merge 4 commits intodevfrom
feat/136-like-subscribe-api
Open

Feat/136 like subscribe api#137
shinwokkang wants to merge 4 commits intodevfrom
feat/136-like-subscribe-api

Conversation

@shinwokkang
Copy link
Contributor

📌 개요 (Summary)

  • 변경 사항에 대한 간략한 요약을 적어주세요.
  • 관련 이슈가 있다면 링크를 걸어주세요 (예: [fix] cicd / build 문제 #123).

🛠️ 변경 사항 (Changes)

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

📸 스크린샷 (Screenshots)

(UI 변경 사항이 있다면 첨부해주세요)

✅ 체크리스트 (Checklist)

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

@shinwokkang shinwokkang self-assigned this Feb 26, 2026
@shinwokkang shinwokkang added the ✨ feat 새로운 기능 추가 label Feb 26, 2026
@vercel
Copy link

vercel bot commented Feb 26, 2026

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

Project Deployment Actions Updated (UTC)
check-mo Building Building Preview Feb 26, 2026 4:28am

@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 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/136-like-subscribe-api

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.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @shinwokkang, 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!

이 풀 리퀘스트는 사용자 참여를 증진시키기 위한 핵심적인 상호작용 기능인 북 스토리 좋아요 및 회원 팔로우 기능을 시스템에 통합합니다. 사용자가 콘텐츠와 다른 사용자에게 더 쉽게 반응할 수 있도록 하여 플랫폼의 소셜 요소를 강화하고, 즉각적인 피드백을 제공하는 낙관적 UI 업데이트를 통해 전반적인 사용자 경험을 크게 개선합니다.

Highlights

  • 스토리 좋아요 기능 추가: 사용자가 북 스토리에 좋아요를 누를 수 있는 기능을 구현했습니다.
  • 회원 팔로우/언팔로우 기능 추가: 다른 회원을 팔로우하거나 언팔로우할 수 있는 기능을 구현했습니다.
  • 낙관적 UI 업데이트 적용: 좋아요 및 팔로우/언팔로우 액션에 대해 사용자 경험을 향상시키기 위해 낙관적 UI 업데이트를 적용했습니다.
  • 관련 UI 컴포넌트 업데이트: BookStoryCard, BookStoryCardLarge, BookstoryDetail, ListSubscribeElement, ListSubscribeLarge 등 여러 UI 컴포넌트가 새로운 좋아요 및 팔로우 기능을 지원하도록 수정되었습니다.
  • 새로운 React Query 뮤테이션 훅 도입: useToggleStoryLikeMutation 및 useToggleFollowMutation 훅을 추가하여 서버와의 상호작용을 관리합니다.

🧠 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
  • src/app/(main)/books/[id]/page.tsx
    • BookStoryCardLarge 컴포넌트에 id prop이 추가되었습니다.
  • src/app/(main)/page.tsx
    • useToggleStoryLikeMutation 및 useToggleFollowMutation 훅이 임포트되었고, BookStoryCardLarge 및 ListSubscribeLarge 컴포넌트에 id, likedByMe, isFollowing prop과 함께 toggleLike 및 toggleFollow 핸들러가 통합되었습니다.
  • src/app/(main)/stories/[id]/page.tsx
    • useToggleStoryLikeMutation 및 useToggleFollowMutation 훅이 임포트되었고, BookstoryDetail 컴포넌트에 likedByMe, isFollowing prop과 함께 toggleLike 및 toggleFollow 핸들러가 통합되었습니다.
  • src/app/(main)/stories/page.tsx
    • useToggleStoryLikeMutation 및 useToggleFollowMutation 훅이 임포트되었고, BookStoryCardLarge 및 ListSubscribeLarge 컴포넌트에 id, likedByMe, isFollowing prop과 함께 toggleLike 및 toggleFollow 핸들러가 통합되었습니다.
  • src/components/base-ui/BookStory/bookstory_card.tsx
    • id, likedByMe, onLikeClick, isFollowing prop이 추가되었고, 좋아요 카운트 기본값이 0으로 변경되었으며, 좋아요 아이콘이 likedByMe 상태에 따라 동적으로 표시되도록 업데이트되었고, 구독 버튼 스타일이 isFollowing 상태에 따라 변경되도록 수정되었습니다.
  • src/components/base-ui/BookStory/bookstory_card_large.tsx
    • id, likedByMe, onLikeClick, isFollowing prop이 추가되었고, 좋아요 카운트 기본값이 0으로 변경되었으며, 좋아요 아이콘이 likedByMe 상태에 따라 동적으로 표시되도록 업데이트되었고, 구독 버튼 스타일이 isFollowing 상태에 따라 변경되도록 수정되었습니다.
  • src/components/base-ui/BookStory/bookstory_detail.tsx
    • isFollowing, id, likedByMe, onLikeClick prop이 추가되었고, 구독 버튼 스타일 및 좋아요 버튼 상호작용이 업데이트되었습니다.
  • src/components/base-ui/MyPage/MyBookStoryList.tsx
    • useRouter 및 useToggleStoryLikeMutation 훅이 임포트 및 적용되었고, 라우터 이동 기능이 추가되었습니다.
  • src/components/base-ui/Profile/BookStoryList.tsx
    • useRouter 훅이 임포트되었고, BookStoryCard 컴포넌트에 id 및 onClick 핸들러가 전달되었습니다.
  • src/components/base-ui/home/list_subscribe_element.tsx
    • isFollowing prop이 추가되었고, 구독 버튼 스타일이 isFollowing 상태에 따라 변경되도록 업데이트되었습니다.
  • src/components/base-ui/home/list_subscribe_large.tsx
    • ListSubscribeElementLarge 및 ListSubscribeLarge 컴포넌트에 isFollowing prop이 추가되었고, 구독 버튼 스타일이 업데이트되었으며, onSubscribeClick 핸들러가 전달되었습니다.
  • src/hooks/mutations/useMemberMutations.ts
    • useToggleFollowMutation 훅이 추가되었으며, 낙관적 업데이트, 스로틀링, 에러 핸들링 로직이 포함되었습니다. storyKeys, memberKeys, toast가 임포트되었습니다.
  • src/hooks/mutations/useStoryMutations.ts
    • useToggleStoryLikeMutation 훅이 추가되었으며, 낙관적 업데이트, 스로틀링, 에러 핸들링 로직이 포함되었습니다. toast가 임포트되었습니다.
  • src/lib/api/endpoints/bookstory.ts
    • 스토리 좋아요를 위한 LIKE 엔드포인트가 추가되었습니다.
  • src/lib/api/endpoints/member.ts
    • 회원 팔로우를 위한 FOLLOW 엔드포인트가 추가되었습니다.
  • src/services/memberService.ts
    • 회원 팔로우/언팔로우 API 호출 함수가 추가되었습니다.
  • src/services/storyService.ts
    • 스토리 좋아요 토글 API 서비스 함수가 추가되었습니다.
  • src/types/member.ts
    • RecommendedMember 인터페이스에 isFollowing 속성이 추가되었습니다.
Activity
  • 이 풀 리퀘스트에 대한 특정 활동(댓글, 검토 진행 상황 등)은 제공되지 않았습니다.
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

This pull request introduces API integration and UI changes for adding like and follow features. While the implementation generally utilizes react-query's useMutation for optimistic updates and API request throttling effectively, a high-severity path traversal vulnerability was identified. Specifically, the nickname parameter in the member follow endpoint is used in a URL path without proper encoding, which could allow an attacker to manipulate API requests. It is recommended to use encodeURIComponent for all dynamic path parameters. Furthermore, there are opportunities to improve type safety and reduce code duplication within the onMutate logic of the mutation hook.

RECOMMEND: `${API_BASE_URL}/members/me/recommend`,
UPDATE_PROFILE: `${API_BASE_URL}/members/me`,
UPDATE_PASSWORD: `${API_BASE_URL}/members/me/update-password`,
FOLLOW: (nickname: string) => `${API_BASE_URL}/members/${nickname}/following`,
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The FOLLOW endpoint constructor directly concatenates the nickname parameter into the URL path without any encoding or sanitization. If a user's nickname contains path traversal sequences like ../, the resulting URL will point to an unintended endpoint. For example, a nickname of ../me/update-password would result in a POST request to ${API_BASE_URL}/members/../me/update-password/following, which resolves to ${API_BASE_URL}/me/update-password/following. This could allow an attacker to trigger actions on different API endpoints by manipulating their nickname.

Suggested change
FOLLOW: (nickname: string) => `${API_BASE_URL}/members/${nickname}/following`,
FOLLOW: (nickname: string) => `${API_BASE_URL}/members/${encodeURIComponent(nickname)}/following`,

<div
key={story.bookStoryId}
onClick={() => handleCardClick(story.bookStoryId)}
className="cursor-pointer shrink-0"
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

BookStoryCardLarge 컴포넌트 자체에 cursor-pointer 스타일이 적용되어 있으므로, 이 divcursor-pointer 클래스는 중복됩니다. 코드를 더 깔끔하게 유지하기 위해 제거하는 것이 좋습니다.

Suggested change
className="cursor-pointer shrink-0"
className="shrink-0"

<div
key={story.bookStoryId}
onClick={() => handleCardClick(story.bookStoryId)}
className="cursor-pointer shrink-0"
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

BookStoryCardLarge 컴포넌트 자체에 cursor-pointer 스타일이 적용되어 있으므로, 이 divcursor-pointer 클래스는 중복됩니다. 코드를 더 깔끔하게 유지하기 위해 제거하는 것이 좋습니다.

Suggested change
className="cursor-pointer shrink-0"
className="shrink-0"

Comment on lines +100 to +159
onMutate: async ({ nickname, isFollowing }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: storyKeys.all });
await queryClient.cancelQueries({ queryKey: memberKeys.recommended() });

// Snapshot previous values
const previousRecommendations = queryClient.getQueryData(memberKeys.recommended());
const previousInfiniteStories = queryClient.getQueryData(storyKeys.infiniteList());
const previousStories = queryClient.getQueryData(storyKeys.list());

// 1. Optimistically update recommendations
if (previousRecommendations) {
queryClient.setQueryData(memberKeys.recommended(), (old: any) => {
if (!old || !old.friends) return old;
return {
...old,
friends: old.friends.map((member: any) =>
member.nickname === nickname
? { ...member, isFollowing: !isFollowing }
: member
)
};
});
}

// 2. Optimistically update infinite stories
if (previousInfiniteStories) {
queryClient.setQueryData(storyKeys.infiniteList(), (old: any) => {
if (!old || !old.pages) return old;
return {
...old,
pages: old.pages.map((page: any) => ({
...page,
basicInfoList: page.basicInfoList.map((story: any) =>
story.authorInfo.nickname === nickname
? { ...story, authorInfo: { ...story.authorInfo, following: !isFollowing } }
: story
)
}))
};
});
}

// 3. Optimistically update stories list
if (previousStories) {
queryClient.setQueryData(storyKeys.list(), (old: any) => {
if (!old || !old.basicInfoList) return old;
return {
...old,
basicInfoList: old.basicInfoList.map((story: any) =>
story.authorInfo.nickname === nickname
? { ...story, authorInfo: { ...story.authorInfo, following: !isFollowing } }
: story
)
};
});
}

return { previousRecommendations, previousInfiniteStories, previousStories };
},
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

낙관적 업데이트 로직의 타입 안정성과 유지보수성을 높이는 것을 제안합니다.

  1. 타입 명시: 현재 onMutate 콜백에서 old 데이터와 map 내부의 member, story 등이 any 타입으로 되어 있습니다. @tanstack/react-query에서 제공하는 InfiniteData와 직접 정의하신 RecommendResponse, BookStoryListResponse 등의 타입을 명시적으로 사용하면 타입 안정성을 높이고 예기치 않은 오류를 방지할 수 있습니다.

  2. 로직 중복 제거: 여러 쿼리 캐시(recommendations, infinite stories, stories list)를 업데이트하는 로직이 중복되고 있습니다. 이 로직을 별도의 헬퍼 함수로 추출하면 코드가 더 간결해지고 유지보수가 용이해집니다.

아래는 개선 예시입니다. (BookStory 등 필요한 타입을 types 디렉토리에서 가져와야 할 수 있습니다.)

        onMutate: async ({ nickname, isFollowing }) => {
            // 필요한 타입을 import 해주세요.
            // import { InfiniteData } from "@tanstack/react-query";
            // import { BookStory, BookStoryListResponse } from "@/types/story";
            // import { RecommendResponse } from "@/types/member";

            // Cancel outgoing refetches
            await queryClient.cancelQueries({ queryKey: storyKeys.all });
            await queryClient.cancelQueries({ queryKey: memberKeys.recommended() });

            // Snapshot previous values
            const previousRecommendations = queryClient.getQueryData<RecommendResponse>(memberKeys.recommended());
            const previousInfiniteStories = queryClient.getQueryData<InfiniteData<BookStoryListResponse>>(storyKeys.infiniteList());
            const previousStories = queryClient.getQueryData<BookStoryListResponse>(storyKeys.list());

            const updateAuthorInStory = (story: any) => {
                if (story.authorInfo.nickname === nickname) {
                    return {
                        ...story,
                        authorInfo: { ...story.authorInfo, following: !isFollowing },
                    };
                }
                return story;
            };

            // 1. Optimistically update recommendations
            if (previousRecommendations) {
                queryClient.setQueryData<RecommendResponse>(memberKeys.recommended(), (old) => {
                    if (!old || !old.friends) return old;
                    return {
                        ...old,
                        friends: old.friends.map((member) =>
                            member.nickname === nickname
                                ? { ...member, isFollowing: !isFollowing }
                                : member
                        ),
                    };
                });
            }

            // 2. Optimistically update infinite stories
            if (previousInfiniteStories) {
                queryClient.setQueryData<InfiniteData<BookStoryListResponse>>(storyKeys.infiniteList(), (old) => {
                    if (!old || !old.pages) return old;
                    return {
                        ...old,
                        pages: old.pages.map((page) => ({
                            ...page,
                            basicInfoList: page.basicInfoList.map(updateAuthorInStory),
                        })),
                    };
                });
            }

            // 3. Optimistically update stories list
            if (previousStories) {
                queryClient.setQueryData<BookStoryListResponse>(storyKeys.list(), (old) => {
                    if (!old || !old.basicInfoList) return old;
                    return {
                        ...old,
                        basicInfoList: old.basicInfoList.map(updateAuthorInStory),
                    };
                });
            }

            return { previousRecommendations, previousInfiniteStories, previousStories };
        },

Comment on lines +68 to +166
onMutate: async (bookStoryId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: storyKeys.all });

// Snapshot the previous values
const previousInfiniteStories = queryClient.getQueryData(storyKeys.infiniteList());
const previousMyStories = queryClient.getQueryData(storyKeys.myList());
const previousStories = queryClient.getQueryData(storyKeys.list());
const previousStoryDetail = queryClient.getQueryData(storyKeys.detail(bookStoryId));

// Optimistically update the infinite list
if (previousInfiniteStories) {
queryClient.setQueryData(storyKeys.infiniteList(), (old: any) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page: any) => ({
...page,
basicInfoList: page.basicInfoList.map((story: any) => {
if (story.bookStoryId === bookStoryId) {
const nextLiked = !story.likedByMe;
return {
...story,
likedByMe: nextLiked,
likes: nextLiked ? story.likes + 1 : story.likes - 1,
};
}
return story;
}),
})),
};
});
}

// Optimistically update my stories
if (previousMyStories) {
queryClient.setQueryData(storyKeys.myList(), (old: any) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page: any) => ({
...page,
basicInfoList: page.basicInfoList.map((story: any) => {
if (story.bookStoryId === bookStoryId) {
const nextLiked = !story.likedByMe;
return {
...story,
likedByMe: nextLiked,
likes: nextLiked ? story.likes + 1 : story.likes - 1,
};
}
return story;
}),
})),
};
});
}

// Optimistically update the regular list (if used)
if (previousStories) {
queryClient.setQueryData(storyKeys.list(), (old: any) => {
if (!old) return old;
return {
...old,
basicInfoList: old.basicInfoList.map((story: any) => {
if (story.bookStoryId === bookStoryId) {
const nextLiked = !story.likedByMe;
return {
...story,
likedByMe: nextLiked,
likes: nextLiked ? story.likes + 1 : story.likes - 1,
};
}
return story;
}),
};
});
}

// Optimistically update the detail view
if (previousStoryDetail) {
queryClient.setQueryData(storyKeys.detail(bookStoryId), (old: any) => {
if (!old) return old;
const nextLiked = !old.likedByMe;
return {
...old,
likedByMe: nextLiked,
likes: nextLiked ? old.likes + 1 : old.likes - 1,
};
});
}

return {
previousInfiniteStories,
previousMyStories,
previousStories,
previousStoryDetail,
};
},
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

이 뮤테이션의 onMutate 로직도 useToggleFollowMutation과 유사하게 타입 안정성 및 코드 중복 관점에서 개선할 수 있습니다.

  1. 타입 명시: old 데이터와 story 객체에 any 대신 구체적인 타입을 지정하여 타입 안정성을 강화할 수 있습니다.
  2. 로직 중복 제거: 여러 종류의 스토리 목록(무한 스크롤, 내 스토리, 일반 목록)과 상세 정보에 대한 낙관적 업데이트 로직이 중복됩니다. 좋아요 상태를 토글하는 로직을 헬퍼 함수로 분리하면 코드를 더 깔끔하게 만들 수 있습니다.

아래는 개선 예시입니다. (BookStory 등 필요한 타입을 types 디렉토리에서 가져와야 할 수 있습니다.)

        onMutate: async (bookStoryId) => {
            // 필요한 타입을 import 해주세요.
            // import { InfiniteData } from "@tanstack/react-query";
            // import { BookStory, BookStoryListResponse, BookStoryDetail } from "@/types/story";

            // Cancel any outgoing refetches
            await queryClient.cancelQueries({ queryKey: storyKeys.all });

            // Snapshot the previous values
            const previousInfiniteStories = queryClient.getQueryData<InfiniteData<any>>(storyKeys.infiniteList());
            const previousMyStories = queryClient.getQueryData<InfiniteData<any>>(storyKeys.myList());
            const previousStories = queryClient.getQueryData<any>(storyKeys.list());
            const previousStoryDetail = queryClient.getQueryData<any>(storyKeys.detail(bookStoryId));

            const updateStoryLike = (story: any) => {
                const nextLiked = !story.likedByMe;
                return {
                    ...story,
                    likedByMe: nextLiked,
                    likes: nextLiked ? story.likes + 1 : story.likes - 1,
                };
            };

            const updateStoriesInPage = (page: any) => ({
                ...page,
                basicInfoList: page.basicInfoList.map((story: any) =>
                    story.bookStoryId === bookStoryId ? updateStoryLike(story) : story
                ),
            });

            // Optimistically update the infinite list
            if (previousInfiniteStories) {
                queryClient.setQueryData<InfiniteData<any>>(storyKeys.infiniteList(), (old) => {
                    if (!old) return old;
                    return {
                        ...old,
                        pages: old.pages.map(updateStoriesInPage),
                    };
                });
            }

            // Optimistically update my stories
            if (previousMyStories) {
                queryClient.setQueryData<InfiniteData<any>>(storyKeys.myList(), (old) => {
                    if (!old) return old;
                    return {
                        ...old,
                        pages: old.pages.map(updateStoriesInPage),
                    };
                });
            }

            // Optimistically update the regular list (if used)
            if (previousStories) {
                queryClient.setQueryData<any>(storyKeys.list(), (old) => {
                    if (!old) return old;
                    return {
                        ...old,
                        basicInfoList: old.basicInfoList.map((story: any) =>
                            story.bookStoryId === bookStoryId ? updateStoryLike(story) : story
                        ),
                    };
                });
            }

            // Optimistically update the detail view
            if (previousStoryDetail) {
                queryClient.setQueryData<any>(storyKeys.detail(bookStoryId), (old) => {
                    if (!old) return old;
                    return updateStoryLike(old);
                });
            }

            return {
                previousInfiniteStories,
                previousMyStories,
                previousStories,
                previousStoryDetail,
            };
        },

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feat 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant