Skip to content

Feat : info 페이지 홍보 신청 이력 확인 및 취소 기능 추가 (#233)#234

Merged
GulSam00 merged 3 commits into
developfrom
feat/233-promotionHistory
May 8, 2026
Merged

Feat : info 페이지 홍보 신청 이력 확인 및 취소 기능 추가 (#233)#234
GulSam00 merged 3 commits into
developfrom
feat/233-promotionHistory

Conversation

@GulSam00
Copy link
Copy Markdown
Owner

@GulSam00 GulSam00 commented May 8, 2026

User description

📌 PR 제목

[Feat] : info 페이지 홍보 신청 이력 확인 및 취소 기능 추가

📌 변경 사항

  • GET /api/user/promotions — 로그인 유저의 홍보 신청 이력 조회 API 추가
  • DELETE /api/user/promotions — 시작 날짜 이전 홍보 취소 API 추가 (소유권 및 날짜 검증 포함)
  • src/lib/api/user.tsgetUserPromotions, deleteUserPromotion 함수 추가
  • src/queries/userQuery.tsuseUserPromotionsQuery, useDeleteUserPromotionMutation 훅 추가
  • src/app/info/promotions/page.tsx — 홍보 이력 목록 페이지 (상태 배지: 예정/진행중/종료, 시작 전 취소 버튼)
  • src/app/info/page.tsx — 홍보 이력 메뉴 카드 추가 (Megaphone 아이콘)

💬 추가 참고 사항


PR Type

Enhancement


Description

  • Add promotion history viewing and cancellation feature for users

  • Implement GET/DELETE API endpoints for managing song promotions

  • Create React Query hooks for promotion data fetching and mutations

  • Add promotions page with status badges and cancellation functionality


Diagram Walkthrough

flowchart LR
  A["User Info Page"] -->|"Add Menu Card"| B["Promotions Page"]
  C["GET /api/user/promotions"] -->|"Fetch Data"| B
  D["DELETE /api/user/promotions"] -->|"Cancel Promotion"| B
  E["React Query Hooks"] -->|"useUserPromotionsQuery"| B
  E -->|"useDeleteUserPromotionMutation"| B
  B -->|"Display with Status"| F["Promotion Items"]
  F -->|"Show: Pending/Active/Ended"| G["Status Badges"]
Loading

File Walkthrough

Relevant files
Enhancement
route.ts
API endpoints for promotion history management                     

apps/web/src/app/api/user/promotions/route.ts

  • Implement GET endpoint to fetch user's promotion history with song
    details
  • Implement DELETE endpoint with ownership and date validation
  • Validate that promotions can only be cancelled before start date
  • Return appropriate error messages for auth, ownership, and date
    violations
+120/-0 
user.ts
API client functions for promotions                                           

apps/web/src/lib/api/user.ts

  • Add getUserPromotions() function to fetch user's promotion list
  • Add deleteUserPromotion(id) function to cancel a promotion
  • Import SongPromotion type for type safety
+11/-0   
userQuery.ts
React Query hooks for promotion operations                             

apps/web/src/queries/userQuery.ts

  • Add useUserPromotionsQuery hook with conditional enabling and caching
  • Add useDeleteUserPromotionMutation hook with query invalidation
  • Import new promotion API functions
+34/-1   
page.tsx
Add promotions menu card to info page                                       

apps/web/src/app/info/page.tsx

  • Import Megaphone icon from lucide-react
  • Add new menu item for promotions with icon and description
+7/-1     
page.tsx
New promotions history page with cancellation UI                 

apps/web/src/app/info/promotions/page.tsx

  • Create new page component displaying user's promotion history
  • Implement PromotionItem component with status badges
    (pending/active/ended)
  • Show cancellation button only for promotions before start date
  • Display promotion details including song title, artist, and content
  • Handle loading and empty states with appropriate messaging
+124/-0 

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 8, 2026

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

Project Deployment Actions Updated (UTC)
singcode Ready Ready Preview, Comment May 8, 2026 5:57am

@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 8, 2026

/describe

@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 8, 2026

/review

@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 8, 2026

/improve

@qodo-code-review
Copy link
Copy Markdown

ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Grey Divider


Action required

1. Cross-user cache leak 🐞 Bug ⛨ Security ⭐ New
Description
useUserPromotionsQuery uses a static queryKey (['userPromotions']) with a 60s staleTime, so
a logout→login as a different user within the same SPA session can reuse the previous user’s cached
promotions without refetching, exposing another user’s history. This happens because the
QueryClient persists for the app lifetime and logout does not clear React Query caches.
Code

apps/web/src/queries/userQuery.ts[R55-65]

+export const useUserPromotionsQuery = (enabled: boolean) => {
+  return useQuery({
+    queryKey: ['userPromotions'],
+    queryFn: async () => {
+      const response = await getUserPromotions();
+      if (!response.success) return [];
+      return response.data ?? [];
+    },
+    enabled,
+    staleTime: 1000 * 60,
+  });
Evidence
The promotions query is cached under a user-agnostic key with a non-zero staleTime, and the app’s
QueryClient is created once and kept for the full session; the auth logout flow resets only Zustand
state, not the React Query cache, so cached data can be served to a subsequent user.

apps/web/src/queries/userQuery.ts[55-66]
apps/web/src/query.tsx[7-28]
apps/web/src/stores/useAuthStore.ts[124-134]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`useUserPromotionsQuery` caches promotions under a static key (`['userPromotions']`). If a user logs out and another logs in without a full reload, React Query can return the previous user’s cached promotions (still “fresh” due to `staleTime`).

### Issue Context
- QueryClient persists for the SPA lifetime.
- Logout only resets Zustand auth state; it does not clear React Query caches.

### Fix Focus Areas
- Use a user-specific queryKey (e.g. `['userPromotions', userId]`) and enable the query only when `userId` exists.
- Update the delete mutation invalidation to target the same user-scoped key.
- Update `/info/promotions` page to pass `userId` (or derive it inside the hook) instead of only `isAuthenticated`.

- apps/web/src/queries/userQuery.ts[55-80]
- apps/web/src/app/info/promotions/page.tsx[80-86]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. DELETE JSON parse 500 🐞 Bug ☼ Reliability ⭐ New
Description
DELETE /api/user/promotions calls await request.json() before any validation; when the body is
empty or invalid JSON, this throws and the route returns a 500 instead of a 400-level client error.
This makes client bugs/misuse look like server failures and complicates debugging/monitoring.
Code

apps/web/src/app/api/user/promotions/route.ts[R63-72]

+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { id } = await request.json();
+
+    if (!id) {
+      return NextResponse.json({ success: false, error: 'id is required' }, { status: 400 });
+    }
Evidence
The handler unconditionally parses JSON and relies on the outer catch, which maps all non-auth
errors to a 500 response.

apps/web/src/app/api/user/promotions/route.ts[63-72]
apps/web/src/app/api/user/promotions/route.ts[107-118]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The DELETE handler can throw on `request.json()` for empty/invalid request bodies and incorrectly return HTTP 500.

### Issue Context
Even if the current client always sends JSON, API endpoints should treat invalid/missing bodies as 4xx errors.

### Fix Focus Areas
- Wrap `request.json()` in a small try/catch and return 400 on parse failure.
- Validate `id` type (e.g., `typeof id === 'string' && id.length > 0`) after parsing.

- apps/web/src/app/api/user/promotions/route.ts[63-72] არაფ

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Server error hidden clientside 🐞 Bug ◔ Observability ⭐ New
Description
useDeleteUserPromotionMutation alerts error.message, which for Axios errors is typically a
generic string (e.g., “Request failed with status code 400”) and hides the server’s localized
validation reason (ownership/date checks). As a result, users won’t see actionable cancel-failure
messages like “이미 시작된 홍보는 취소할 수 없습니다.”
Code

apps/web/src/queries/userQuery.ts[R68-78]

+export const useDeleteUserPromotionMutation = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: (id: string) => deleteUserPromotion(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['userPromotions'] });
+    },
+    onError: error => {
+      console.error('error', error);
+      alert(error.message ?? '삭제 실패');
+    },
Evidence
The server returns specific error strings in the response JSON, but the client’s mutation error
handler reads only the thrown Error’s message; the Axios instance has no active interceptor to
convert response.data.error into an Error message.

apps/web/src/queries/userQuery.ts[68-79]
apps/web/src/app/api/user/promotions/route.ts[87-99]
apps/web/src/lib/api/client.ts[1-17]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The delete mutation’s `onError` alerts a generic Axios error message instead of the API’s `ApiResponse.error` string.

### Issue Context
The API intentionally returns meaningful Korean validation messages (403/400/404). The UI should display those.

### Fix Focus Areas
- In `onError`, detect AxiosError and read `error.response?.data?.error` first.
- Alternatively, add/enable a response interceptor to throw `new Error(response.data.error)` consistently for all API calls.

- apps/web/src/queries/userQuery.ts[68-79]
- apps/web/src/lib/api/client.ts[1-17]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Unscoped delete condition 🐞 Bug ≡ Correctness
Description
DELETE /api/user/promotions는 소유권/시작일을 SELECT로 검사한 뒤 실제 DELETE는 id만 조건으로 실행합니다. 이로 인해 검사 이후 레코드가
변경되거나(시작일/소유자), 조건이 달라져도 삭제가 진행될 수 있어 검증 로직을 강제하지 못합니다.
Code

apps/web/src/app/api/user/promotions/route.ts[R74-103]

+    const { data: promotion, error: fetchError } = await supabase
+      .from('song_promotions')
+      .select('id, user_id, start_date')
+      .eq('id', id)
+      .single();
+
+    if (fetchError || !promotion) {
+      return NextResponse.json(
+        { success: false, error: '홍보를 찾을 수 없습니다.' },
+        { status: 404 },
+      );
+    }
+
+    if (promotion.user_id !== userId) {
+      return NextResponse.json(
+        { success: false, error: '본인의 홍보만 취소할 수 있습니다.' },
+        { status: 403 },
+      );
+    }
+
+    const todayKST = getTodayKST();
+    if (promotion.start_date <= todayKST) {
+      return NextResponse.json(
+        { success: false, error: '이미 시작된 홍보는 취소할 수 없습니다.' },
+        { status: 400 },
+      );
+    }
+
+    const { error: deleteError } = await supabase.from('song_promotions').delete().eq('id', id);
+
Evidence
현재 구현은 (1) id로 promotion을 가져와 소유권/날짜를 체크한 뒤 (2) 삭제는 .eq('id', id)만 수행합니다. 동일 코드베이스의 다른 삭제 API는
.match({ id, user_id })로 삭제 조건에 소유권을 포함시키고 count로 삭제 여부를 확인하는 패턴을 사용합니다.

apps/web/src/app/api/user/promotions/route.ts[74-104]
apps/web/src/app/api/songs/report/route.ts[70-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The promotions DELETE endpoint validates ownership and start date via a separate SELECT, but performs the DELETE with only `id` as a filter. This does not enforce the validated constraints at deletion time.
## Issue Context
There is an established pattern in the repo for atomic ownership-scoped deletes using `.match({ id, user_id })` and `count: 'exact'`.
## Fix Focus Areas
- apps/web/src/app/api/user/promotions/route.ts[74-106]
- apps/web/src/app/api/songs/report/route.ts[70-90]
## Suggested fix
- Include the validated constraints in the DELETE query:
- `.delete({ count: 'exact' })`
- `.match({ id, user_id: userId })`
- `.gt('start_date', todayKST)`
- Check `count` to confirm deletion.
- If `count === 0`, return an appropriate error (e.g., not found / already started / not owner) based on your desired UX; but ensure the DELETE itself cannot remove rows outside the allowed conditions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
5. Empty nickname in response 🐞 Bug ⚙ Maintainability
Description
GET /api/user/promotions는 SongPromotion 타입을 반환하면서 nickname을 항상 ''로 채웁니다. 같은 타입이 다른 곳에서 nickname 표시
용도로 쓰이기 때문에, 공용 타입 의미와 응답 데이터가 불일치하여 빈 값 UI/추후 오용을 유발합니다.
Code

apps/web/src/app/api/user/promotions/route.ts[R24-44]

+    const promotions: SongPromotion[] = (data ?? []).map(row => {
+      const song = row.songs as unknown as {
+        title: string;
+        artist: string;
+        title_ko: string | null;
+        artist_ko: string | null;
+      } | null;
+
+      return {
+        id: row.id,
+        song_id: row.song_id,
+        user_id: row.user_id,
+        content: row.content ?? '',
+        start_date: row.start_date ?? '',
+        end_date: row.end_date ?? '',
+        nickname: '',
+        title: song?.title ?? '',
+        artist: song?.artist ?? '',
+        title_ko: song?.title_ko ?? null,
+        artist_ko: song?.artist_ko ?? null,
+      };
Evidence
SongPromotion 인터페이스는 nickname을 필수로 요구하고, 실제로 PromotionBanner에서 nickname을 화면에 렌더링합니다. 또한 다른
promotions API는 users(nickname) 조인을 통해 nickname을 채우는 선례가 있습니다. 반면 이번 유저 promotions API는 nickname을
''로 고정합니다.

apps/web/src/types/promotion.ts[1-13]
apps/web/src/app/api/user/promotions/route.ts[24-44]
apps/web/src/components/PromotionBanner.tsx[49-57]
apps/web/src/app/api/songs/promotions/route.ts[16-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`GET /api/user/promotions` returns `SongPromotion[]` but hard-codes `nickname: ''`. This diverges from the shared `SongPromotion` contract and from other endpoints that populate `nickname`.
## Issue Context
`SongPromotion.nickname` is required by the type and is rendered in components like `PromotionBanner`.
## Fix Focus Areas
- apps/web/src/app/api/user/promotions/route.ts[14-45]
- apps/web/src/types/promotion.ts[1-13]
- apps/web/src/app/api/songs/promotions/route.ts[16-46]
## Suggested fix (pick one)
1) Populate nickname:
- Add `users(nickname)` to the select.
- Map `nickname` similarly to `/api/songs/promotions`.
2) Split the type:
- Introduce a `UserPromotion` type without `nickname` (or make it optional), and use it consistently in the user promotions API + client query.
- This avoids shipping meaningless placeholder fields.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add user promotion history viewing and cancellation feature

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add promotion history viewing and cancellation feature to info page
• Implement GET/DELETE APIs for user promotion management with validation
• Create promotion history page with status badges and cancellation UI
• Add React Query hooks for promotion data fetching and mutations
Diagram
flowchart LR
  A["Info Page"] -->|"Add Menu Card"| B["Promotions Page"]
  C["GET /api/user/promotions"] -->|"Fetch History"| B
  B -->|"Display with Status"| D["Promotion Items"]
  D -->|"Cancel if start_date > today"| E["DELETE /api/user/promotions"]
  E -->|"Validate ownership & date"| F["Delete from DB"]
Loading

Grey Divider

File Changes

1. apps/web/src/app/api/user/promotions/route.ts ✨ Enhancement +120/-0

API endpoints for promotion history management

• Implement GET endpoint to fetch user's promotion history with song details
• Implement DELETE endpoint with ownership and date validation
• Validate that promotions can only be cancelled before start_date
• Return appropriate error messages for auth, ownership, and timing issues

apps/web/src/app/api/user/promotions/route.ts


2. apps/web/src/lib/api/user.ts ✨ Enhancement +11/-0

API client functions for promotions

• Add getUserPromotions() function to fetch promotion history
• Add deleteUserPromotion() function to cancel a promotion
• Import SongPromotion type for type safety

apps/web/src/lib/api/user.ts


3. apps/web/src/queries/userQuery.ts ✨ Enhancement +34/-1

React Query hooks for promotion operations

• Add useUserPromotionsQuery hook for fetching promotion history with caching
• Add useDeleteUserPromotionMutation hook for cancelling promotions
• Implement query invalidation on successful deletion
• Add error handling with user alerts

apps/web/src/queries/userQuery.ts


View more (2)
4. apps/web/src/app/info/page.tsx ✨ Enhancement +7/-1

Add promotion history menu to info page

• Import Megaphone icon from lucide-react
• Add new menu item for promotion history with icon and description
• Link to promotions page for viewing and managing promotions

apps/web/src/app/info/page.tsx


5. apps/web/src/app/info/promotions/page.tsx ✨ Enhancement +124/-0

Promotion history page with status and cancellation

• Create promotion history page with list of user's promotions
• Display status badges showing promotion state (예정/진행중/종료)
• Show song title, artist, and promotion content for each item
• Implement cancellation button only for promotions not yet started
• Add empty state message when no promotions exist

apps/web/src/app/info/promotions/page.tsx


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Grey Divider


Action required

1. Cross-user cache leak 🐞 Bug ⛨ Security
Description
useUserPromotionsQuery가 queryKey를 ['userPromotions']로 고정하고 staleTime을 1분으로 설정해, 같은 클라이언트 세션에서 계정 전환
시 이전 유저의 홍보 이력이 캐시에서 그대로 재사용될 수 있습니다. 이로 인해 다른 유저의 홍보 신청 이력이 일시적으로 노출될 수 있습니다.
Code

apps/web/src/queries/userQuery.ts[R55-65]

+export const useUserPromotionsQuery = (enabled: boolean) => {
+  return useQuery({
+    queryKey: ['userPromotions'],
+    queryFn: async () => {
+      const response = await getUserPromotions();
+      if (!response.success) return [];
+      return response.data ?? [];
+    },
+    enabled,
+    staleTime: 1000 * 60,
+  });
Evidence
React Query 캐시는 queryKey로 식별되는데, 현재 키에 userId가 포함되지 않습니다. 또한 staleTime(1분)과 QueryClient 기본
staleTime/gcTime 설정으로 캐시가 유지되므로, 사용자 컨텍스트가 바뀌어도 동일 키의 데이터를 재사용할 여지가 있습니다. 저장된 유저 객체에는 id가 존재하므로 이를
키에 포함해 분리할 수 있습니다.

apps/web/src/queries/userQuery.ts[55-65]
apps/web/src/query.tsx[10-18]
apps/web/src/types/user.ts[1-6]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`useUserPromotionsQuery` uses a static React Query `queryKey` (`['userPromotions']`). Because the cache key is not user-scoped and the query has a non-zero `staleTime`, cached promotions can be served for a different user after an account switch in the same client session.

## Issue Context
The app has access to the authenticated user's `id` (`User.id`). React Query identifies cached results strictly by `queryKey`.

## Fix Focus Areas
- apps/web/src/queries/userQuery.ts[55-80]
- apps/web/src/app/info/promotions/page.tsx[80-87]
- apps/web/src/stores/useAuthStore.ts[14-30]

## Suggested fix
- Change the hook signature to accept `userId` (or read it inside the hook via the auth store), and scope the key: `queryKey: ['userPromotions', userId]`.
- Gate with `enabled: enabled && !!userId`.
- Update invalidation in `useDeleteUserPromotionMutation` to invalidate the scoped key (or use a partial match that includes the prefix).
- In the page, pass the current `user?.id` (not just `isAuthenticated`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unscoped delete condition 🐞 Bug ≡ Correctness
Description
DELETE /api/user/promotions는 소유권/시작일을 SELECT로 검사한 뒤 실제 DELETE는 id만 조건으로 실행합니다. 이로 인해 검사 이후 레코드가
변경되거나(시작일/소유자), 조건이 달라져도 삭제가 진행될 수 있어 검증 로직을 강제하지 못합니다.
Code

apps/web/src/app/api/user/promotions/route.ts[R74-103]

+    const { data: promotion, error: fetchError } = await supabase
+      .from('song_promotions')
+      .select('id, user_id, start_date')
+      .eq('id', id)
+      .single();
+
+    if (fetchError || !promotion) {
+      return NextResponse.json(
+        { success: false, error: '홍보를 찾을 수 없습니다.' },
+        { status: 404 },
+      );
+    }
+
+    if (promotion.user_id !== userId) {
+      return NextResponse.json(
+        { success: false, error: '본인의 홍보만 취소할 수 있습니다.' },
+        { status: 403 },
+      );
+    }
+
+    const todayKST = getTodayKST();
+    if (promotion.start_date <= todayKST) {
+      return NextResponse.json(
+        { success: false, error: '이미 시작된 홍보는 취소할 수 없습니다.' },
+        { status: 400 },
+      );
+    }
+
+    const { error: deleteError } = await supabase.from('song_promotions').delete().eq('id', id);
+
Evidence
현재 구현은 (1) id로 promotion을 가져와 소유권/날짜를 체크한 뒤 (2) 삭제는 .eq('id', id)만 수행합니다. 동일 코드베이스의 다른 삭제 API는
.match({ id, user_id })로 삭제 조건에 소유권을 포함시키고 count로 삭제 여부를 확인하는 패턴을 사용합니다.

apps/web/src/app/api/user/promotions/route.ts[74-104]
apps/web/src/app/api/songs/report/route.ts[70-90]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The promotions DELETE endpoint validates ownership and start date via a separate SELECT, but performs the DELETE with only `id` as a filter. This does not enforce the validated constraints at deletion time.

## Issue Context
There is an established pattern in the repo for atomic ownership-scoped deletes using `.match({ id, user_id })` and `count: 'exact'`.

## Fix Focus Areas
- apps/web/src/app/api/user/promotions/route.ts[74-106]
- apps/web/src/app/api/songs/report/route.ts[70-90]

## Suggested fix
- Include the validated constraints in the DELETE query:
 - `.delete({ count: 'exact' })`
 - `.match({ id, user_id: userId })`
 - `.gt('start_date', todayKST)`
- Check `count` to confirm deletion.
- If `count === 0`, return an appropriate error (e.g., not found / already started / not owner) based on your desired UX; but ensure the DELETE itself cannot remove rows outside the allowed conditions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Empty nickname in response 🐞 Bug ⚙ Maintainability
Description
GET /api/user/promotions는 SongPromotion 타입을 반환하면서 nickname을 항상 ''로 채웁니다. 같은 타입이 다른 곳에서 nickname 표시
용도로 쓰이기 때문에, 공용 타입 의미와 응답 데이터가 불일치하여 빈 값 UI/추후 오용을 유발합니다.
Code

apps/web/src/app/api/user/promotions/route.ts[R24-44]

+    const promotions: SongPromotion[] = (data ?? []).map(row => {
+      const song = row.songs as unknown as {
+        title: string;
+        artist: string;
+        title_ko: string | null;
+        artist_ko: string | null;
+      } | null;
+
+      return {
+        id: row.id,
+        song_id: row.song_id,
+        user_id: row.user_id,
+        content: row.content ?? '',
+        start_date: row.start_date ?? '',
+        end_date: row.end_date ?? '',
+        nickname: '',
+        title: song?.title ?? '',
+        artist: song?.artist ?? '',
+        title_ko: song?.title_ko ?? null,
+        artist_ko: song?.artist_ko ?? null,
+      };
Evidence
SongPromotion 인터페이스는 nickname을 필수로 요구하고, 실제로 PromotionBanner에서 nickname을 화면에 렌더링합니다. 또한 다른
promotions API는 users(nickname) 조인을 통해 nickname을 채우는 선례가 있습니다. 반면 이번 유저 promotions API는 nickname을
''로 고정합니다.

apps/web/src/types/promotion.ts[1-13]
apps/web/src/app/api/user/promotions/route.ts[24-44]
apps/web/src/components/PromotionBanner.tsx[49-57]
apps/web/src/app/api/songs/promotions/route.ts[16-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`GET /api/user/promotions` returns `SongPromotion[]` but hard-codes `nickname: ''`. This diverges from the shared `SongPromotion` contract and from other endpoints that populate `nickname`.

## Issue Context
`SongPromotion.nickname` is required by the type and is rendered in components like `PromotionBanner`.

## Fix Focus Areas
- apps/web/src/app/api/user/promotions/route.ts[14-45]
- apps/web/src/types/promotion.ts[1-13]
- apps/web/src/app/api/songs/promotions/route.ts[16-46]

## Suggested fix (pick one)
1) Populate nickname:
- Add `users(nickname)` to the select.
- Map `nickname` similarly to `/api/songs/promotions`.

2) Split the type:
- Introduce a `UserPromotion` type without `nickname` (or make it optional), and use it consistently in the user promotions API + client query.
- This avoids shipping meaningless placeholder fields.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

PR Description updated to latest commit (fd4efaf)

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

@GulSam00 GulSam00 changed the base branch from feat/223-songCommentAndPromotion to develop May 8, 2026 05:06
Comment on lines +55 to +65
export const useUserPromotionsQuery = (enabled: boolean) => {
return useQuery({
queryKey: ['userPromotions'],
queryFn: async () => {
const response = await getUserPromotions();
if (!response.success) return [];
return response.data ?? [];
},
enabled,
staleTime: 1000 * 60,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Cross-user cache leak 🐞 Bug ⛨ Security

useUserPromotionsQuery가 queryKey를 ['userPromotions']로 고정하고 staleTime을 1분으로 설정해, 같은 클라이언트 세션에서 계정 전환
시 이전 유저의 홍보 이력이 캐시에서 그대로 재사용될 수 있습니다. 이로 인해 다른 유저의 홍보 신청 이력이 일시적으로 노출될 수 있습니다.
Agent Prompt
## Issue description
`useUserPromotionsQuery` uses a static React Query `queryKey` (`['userPromotions']`). Because the cache key is not user-scoped and the query has a non-zero `staleTime`, cached promotions can be served for a different user after an account switch in the same client session.

## Issue Context
The app has access to the authenticated user's `id` (`User.id`). React Query identifies cached results strictly by `queryKey`.

## Fix Focus Areas
- apps/web/src/queries/userQuery.ts[55-80]
- apps/web/src/app/info/promotions/page.tsx[80-87]
- apps/web/src/stores/useAuthStore.ts[14-30]

## Suggested fix
- Change the hook signature to accept `userId` (or read it inside the hook via the auth store), and scope the key: `queryKey: ['userPromotions', userId]`.
- Gate with `enabled: enabled && !!userId`.
- Update invalidation in `useDeleteUserPromotionMutation` to invalidate the scoped key (or use a partial match that includes the prefix).
- In the page, pass the current `user?.id` (not just `isAuthenticated`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +55 to +65
export const useUserPromotionsQuery = (enabled: boolean) => {
return useQuery({
queryKey: ['userPromotions'],
queryFn: async () => {
const response = await getUserPromotions();
if (!response.success) return [];
return response.data ?? [];
},
enabled,
staleTime: 1000 * 60,
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Cross-user cache leak 🐞 Bug ⛨ Security

useUserPromotionsQuery uses a static queryKey (['userPromotions']) with a 60s staleTime, so
a logout→login as a different user within the same SPA session can reuse the previous user’s cached
promotions without refetching, exposing another user’s history. This happens because the
QueryClient persists for the app lifetime and logout does not clear React Query caches.
Agent Prompt
### Issue description
`useUserPromotionsQuery` caches promotions under a static key (`['userPromotions']`). If a user logs out and another logs in without a full reload, React Query can return the previous user’s cached promotions (still “fresh” due to `staleTime`).

### Issue Context
- QueryClient persists for the SPA lifetime.
- Logout only resets Zustand auth state; it does not clear React Query caches.

### Fix Focus Areas
- Use a user-specific queryKey (e.g. `['userPromotions', userId]`) and enable the query only when `userId` exists.
- Update the delete mutation invalidation to target the same user-scoped key.
- Update `/info/promotions` page to pass `userId` (or derive it inside the hook) instead of only `isAuthenticated`.

- apps/web/src/queries/userQuery.ts[55-80]
- apps/web/src/app/info/promotions/page.tsx[80-86]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@GulSam00 GulSam00 merged commit 05cb185 into develop May 8, 2026
1 of 2 checks passed
@GulSam00 GulSam00 deleted the feat/233-promotionHistory branch May 8, 2026 05:55
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.

info 페이지 홍보 신청 이력 확인 및 취소 기능 추가

1 participant