Skip to content

Feat : 곡 댓글 및 홍보(전광판) 기능 추가 (#223)#232

Merged
GulSam00 merged 6 commits into
developfrom
feat/223-songCommentAndPromotion
May 8, 2026
Merged

Feat : 곡 댓글 및 홍보(전광판) 기능 추가 (#223)#232
GulSam00 merged 6 commits into
developfrom
feat/223-songCommentAndPromotion

Conversation

@GulSam00
Copy link
Copy Markdown
Owner

@GulSam00 GulSam00 commented May 8, 2026

User description

📌 PR 제목

[Feat] : 곡 댓글 및 홍보(전광판) 기능 추가

📌 변경 사항

  • GET/POST /api/songs/comments — 곡별 댓글 조회 및 작성 API 추가 (인증 필요)
  • DELETE /api/songs/comments — 본인 댓글 삭제 API 추가 (body 방식)
  • GET/POST /api/songs/promotions — 활성 홍보물 조회 및 홍보 신청 API 추가 (KST 기준)
  • SongCommentSection.tsx — 댓글 목록 + 입력 UI 컴포넌트 (isExpanded 시 표시, optimistic delete)
  • SongPromotionModal.tsx — 달력 range picker로 홍보 기간 선택 + 신청 확인 단계
  • PromotionBanner.tsx — 3초 간격 아래 방향 스크롤 전광판 컴포넌트
  • SearchResultCard.tsx — 홍보 버튼, 댓글 섹션, 홍보 모달 연결
  • layout.tsx — PromotionBanner 전역 배치
  • 곡 홍보 DB 정규화 및 시간 계산 date-fns 적용
  • src/utils/kst.ts — KST 시간대 유틸리티 추가
  • sitemap 갱신

💬 추가 참고 사항


PR Type

Enhancement


Description

  • Implements comprehensive song comment system with CRUD operations (GET/POST/DELETE endpoints, max 100 chars, auth required)

  • Adds song promotion feature with date-based filtering, point system (50P per day), and KST timezone support

  • Creates reusable React Query hooks for comment and promotion operations with optimistic UI updates

  • Builds calendar-based promotion modal component with date range picker and two-step confirmation flow

  • Develops scrolling promotion banner component with 3-second auto-rotation displaying active promotions

  • Integrates comment section and promotion features into search result cards with authentication checks

  • Adds KST timezone utility functions for consistent date handling across the application

  • Includes new UI components (Calendar, SongCommentSection, SongPromotionModal, PromotionBanner)

  • Updates dependencies with react-day-picker and radix-ui for enhanced UI capabilities

  • Improves security by moving OpenAI client initialization into request handler

  • Updates documentation for GitHub issue workflow and component modification guidelines


Diagram Walkthrough

flowchart LR
  A["Search Result Card"] -->|"displays"| B["Song Comment Section"]
  A -->|"opens"| C["Promotion Modal"]
  C -->|"uses"| D["Calendar Component"]
  C -->|"submits"| E["POST /api/songs/promotions"]
  B -->|"uses"| F["POST /api/songs/comments"]
  B -->|"uses"| G["DELETE /api/songs/comments"]
  E -->|"deducts points"| H["User Points"]
  I["Promotion Banner"] -->|"fetches"| J["GET /api/songs/promotions"]
  J -->|"displays"| K["Active Promotions"]
  L["Layout"] -->|"renders"| I
Loading

File Walkthrough

Relevant files
Enhancement
15 files
route.ts
Song comments API endpoints with CRUD operations                 

apps/web/src/app/api/songs/comments/route.ts

  • Implements three API endpoints for song comments: GET to fetch
    comments by songId, POST to create new comments (auth required, max
    100 chars), and DELETE to remove user's own comments
  • Fetches comment data from song_comments table with user nicknames via
    join, ordered by creation date descending
  • Includes validation for required fields, content length limits, and
    authentication checks with appropriate error responses
+120/-0 
route.ts
Song promotions API with date-based filtering and point system

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

  • Implements GET endpoint to retrieve active song promotions filtered by
    KST date range, returning promotion details with song and user
    information
  • Implements POST endpoint to create promotions with validation for
    content length (max 50 chars), date constraints (start date must be
    tomorrow or later), and point deduction (50P per day)
  • Validates user has sufficient points before creating promotion and
    updates user point balance upon successful creation
+151/-0 
songCommentQuery.ts
React Query hooks for song comment operations                       

apps/web/src/queries/songCommentQuery.ts

  • Defines useSongCommentsQuery hook to fetch comments for a specific
    song with conditional enabling
  • Implements usePostSongCommentMutation with success toast and query
    invalidation
  • Implements useDeleteSongCommentMutation with optimistic UI updates
    (removes comment immediately, reverts on error)
+56/-0   
songPromotionQuery.ts
React Query hooks for song promotion operations                   

apps/web/src/queries/songPromotionQuery.ts

  • Defines useSongPromotionsQuery hook to fetch active promotions with
    1-minute stale time
  • Implements usePostSongPromotionMutation that invalidates both
    promotion and user check-in queries on success
+31/-0   
kst.ts
KST timezone utility functions for date handling                 

apps/web/src/utils/kst.ts

  • Adds getTodayKST() function returning today's date in 'YYYY-MM-DD'
    format adjusted to KST timezone
  • Adds getTomorrowKST() function returning tomorrow's date in
    'YYYY-MM-DD' format adjusted to KST timezone
  • Adds getTomorrowKSTDate() function returning tomorrow's date as a
    local midnight Date object for calendar disabled prop usage
+17/-0   
songComment.ts
API client functions for song comment operations                 

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

  • Exports getSongComments() function to fetch comments for a song via
    query parameter
  • Exports postSongComment() function to create a new comment with
    song_id and content
  • Exports deleteSongComment() function to delete a comment by commentId
    using request body
+21/-0   
songPromotion.ts
API client functions for song promotion operations             

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

  • Exports getSongPromotions() function to fetch all active promotions
  • Exports postSongPromotion() function to create promotion with song_id,
    content, start_date, and end_date
+19/-0   
comment.ts
TypeScript interface for song comment data structure         

apps/web/src/types/comment.ts

  • Defines SongComment interface with fields: id, song_id, user_id,
    nickname, content, and created_at
+8/-0     
promotion.ts
TypeScript interface for song promotion data structure     

apps/web/src/types/promotion.ts

  • Defines SongPromotion interface with promotion metadata including id,
    song_id, user_id, nickname, song details (title, artist, title_ko,
    artist_ko), content, and date range (start_date, end_date)
+13/-0   
SongPromotionModal.tsx
Song promotion modal with calendar date picker and confirmation

apps/web/src/components/SongPromotionModal.tsx

  • Implements modal component for song promotion with date range picker
    using react-day-picker Calendar
  • Displays promotion details (song info, content, date range) and
    calculates cost (50P per day) with point validation
  • Includes two-step confirmation flow: date/content selection then
    confirmation with point deduction summary
  • Disables date selection before tomorrow (KST) and prevents submission
    if user lacks sufficient points
+205/-0 
SearchResultCard.tsx
Integrate song promotion and comment features into search results

apps/web/src/app/search/SearchResultCard.tsx

  • Adds Megaphone icon import and SongPromotionModal component import
  • Adds promotion button to action buttons row with authentication check
    via new withAuth helper function
  • Integrates SongCommentSection component below action buttons when card
    is expanded
  • Adds promotion modal dialog with song details passed to
    SongPromotionModal
  • Refactors authentication checks into reusable withAuth helper function
+44/-12 
calendar.tsx
Calendar UI component with range picker support                   

apps/web/src/components/ui/calendar.tsx

  • New calendar component built on react-day-picker with customizable
    styling and range selection support
  • Implements CalendarDayButton sub-component with focus management and
    data attributes for range/selection states
  • Supports month/year dropdowns, week numbers, and RTL layout with
    Tailwind CSS styling
  • Integrates chevron icons from lucide-react for navigation controls
+180/-0 
SongCommentSection.tsx
Song comment section with CRUD operations                               

apps/web/src/components/SongCommentSection.tsx

  • New component for displaying and managing song comments with
    authentication checks
  • Implements comment submission with 100-character limit validation and
    optimistic UI updates
  • Displays comment list with user info, timestamps, and delete
    functionality for own comments
  • Uses useSongCommentsQuery, usePostSongCommentMutation, and
    useDeleteSongCommentMutation hooks
+107/-0 
PromotionBanner.tsx
Promotion banner with auto-rotating carousel                         

apps/web/src/components/PromotionBanner.tsx

  • New scrolling banner component displaying active song promotions with
    3-second rotation interval
  • Shows promotion metadata including song title/artist (with Korean
    fallback), user nickname, and date range
  • Implements smooth vertical scroll animation using framer-motion with
    AnimatePresence
  • Fetches active promotions via useSongPromotionsQuery hook
+66/-0   
layout.tsx
Global promotion banner integration in layout                       

apps/web/src/app/layout.tsx

  • Imports and integrates PromotionBanner component globally in root
    layout
  • Positions banner in fixed bottom-right area with pointer-events
    management for proper interaction
  • Maintains z-index layering and responsive width constraints
+7/-0     
Bug fix
1 files
route.ts
Move OpenAI client initialization into request handler     

apps/web/src/app/api/chat/route.ts

  • Moves OpenAI client initialization from module level into the POST
    handler function
  • Ensures API key is only accessed when the endpoint is actually called,
    improving security and lazy initialization
+4/-4     
Dependencies
2 files
pnpm-lock.yaml
Add UI and date picker dependencies to lock file                 

pnpm-lock.yaml

  • Adds radix-ui package (v1.4.3) with comprehensive UI component
    dependencies
  • Adds react-day-picker package (v9.14.0) for calendar date range
    selection
  • Adds supporting dependencies: @date-fns/tz, date-fns-jalali,
    @tabby_ai/hijri-converter for date handling
+682/-2 
package.json
Add calendar and UI library dependencies                                 

apps/web/package.json

  • Adds radix-ui (^1.4.3) dependency for UI component primitives
  • Adds react-day-picker (^9.14.0) dependency for calendar functionality
+2/-0     
Configuration changes
2 files
settings.json
Configure Claude permissions settings                                       

.claude/settings.json

  • Adds permissions configuration object with defaultMode set to
    bypassPermissions
+4/-0     
sitemap-0.xml
Sitemap timestamp update                                                                 

apps/web/public/sitemap-0.xml

  • Updates sitemap timestamps to reflect latest build date
    (2026-05-04T08:44:32.892Z)
+2/-2     
Documentation
2 files
start.md
GitHub issue creation and tracking workflow documentation

.claude/commands/start.md

  • Adds detailed GitHub issue creation guidelines with structured body
    format (overview + checklist)
  • Documents issue update workflow using gh issue edit command to track
    progress
  • Specifies checkbox format for tracking completed work items
+11/-0   
CLAUDE.md
Clarify shadcn/ui component modification guidelines           

apps/web/CLAUDE.md

  • Updates shadcn/ui component modification policy to allow project-wide
    convention changes after review
  • Clarifies that functional changes are prohibited but style/import
    standardization is permitted
+1/-1     

GulSam00 and others added 6 commits May 4, 2026 17:53
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- songs JOIN으로 곡 메타 조회 (title/artist/title_ko/artist_ko 컬럼 의존 제거)
- start_date <= today <= end_date 활성 기간 필터 추가
- KST 처리 헬퍼를 src/utils/kst.ts로 공통화

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 신청 버튼 클릭 시 곡/내용/기간/차감 포인트를 요약하는 확인 화면으로 전환
- 시간 계산을 date-fns로 정리하고 공통 KST 유틸 적용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- HomePage 의 검색 결과 빈 화면에서만 보이던 배너를 layout 으로 이동, 모든 페이지에서 하단 nav 바로 위 fixed 노출
- 잔여 일수 대신 start_date ~ end_date 형태의 노출 기간 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@GulSam00
Copy link
Copy Markdown
Owner Author

GulSam00 commented May 8, 2026

/describe

@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 2:31am

@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

qodo-code-review Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (14) 📘 Rule violations (4) 📎 Requirement gaps (4)

Context used

Grey Divider


Action required

1. DELETE comments not /[id] 📎 Requirement gap ≡ Correctness ⭐ New
Description
The PR implements comment deletion in /api/songs/comments with a JSON body (commentId) instead
of providing DELETE /api/songs/comments/[id] as required. This fails the specified API contract
for the delete endpoint.
Code

apps/web/src/app/api/songs/comments/route.ts[R83-107]

+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { commentId } = await request.json();
+
+    if (!commentId || typeof commentId !== 'string') {
+      return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
+    }
+
+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ success: true });
+  } catch (error) {
Evidence
PR Compliance ID 3 requires the delete endpoint to be exposed as DELETE /api/songs/comments/[id],
but the added code defines export async function DELETE(...) inside
apps/web/src/app/api/songs/comments/route.ts, which maps to /api/songs/comments (not
/api/songs/comments/[id]).

댓글 API: 조회/작성/삭제 엔드포인트 제공 및 인증/권한 준수
apps/web/src/app/api/songs/comments/route.ts[83-107]

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

## Issue description
Comment deletion is implemented at `/api/songs/comments` using a request body, but compliance requires `DELETE /api/songs/comments/[id]`.

## Issue Context
Next.js route file location determines the URL path. A `route.ts` under `.../comments/` maps to `/api/songs/comments`, while `.../comments/[id]/route.ts` maps to `/api/songs/comments/:id`.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-21]

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


2. ui/calendar.tsx edited directly 📘 Rule violation ⚙ Maintainability ⭐ New
Description
A shadcn/ui-style component was added under src/components/ui/ (calendar.tsx), which violates
the rule forbidding direct edits to vendored shadcn/ui components in that directory. This can
complicate future upstream updates and create maintenance divergence.
Code

apps/web/src/components/ui/calendar.tsx[R1-20]

+'use client';
+
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
+
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+
+function Calendar({
+  className,
+  classNames,
+  showOutsideDays = true,
+  captionLayout = 'label',
+  buttonVariant = 'ghost',
+  formatters,
+  components,
+  ...props
+}: React.ComponentProps<typeof DayPicker> & {
+  buttonVariant?: React.ComponentProps<typeof Button>['variant'];
Evidence
PR Compliance ID 11 forbids direct modifications under src/components/ui/. The PR introduces
apps/web/src/components/ui/calendar.tsx, which is a direct change within that protected directory.

CLAUDE.md
apps/web/src/components/ui/calendar.tsx[1-20]

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

## Issue description
`apps/web/src/components/ui/calendar.tsx` was added under the shadcn/ui vendored directory, which is disallowed by compliance.

## Issue Context
The project policy requires keeping `src/components/ui/` untouched to avoid breaking shadcn/ui upgrade paths.

## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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


3. Banner index out-of-range 🐞 Bug ≡ Correctness ⭐ New
Description
PromotionBanner가 promotions 목록이 줄어든 뒤에도 기존 currentIndex로 promotions[currentIndex]를 접근해 current가
undefined가 될 수 있으며, 이후 current.title 접근에서 런타임 크래시가 발생합니다. 이 컴포넌트는 layout에 전역으로 렌더되어 페이지 전체가 깨질 수
있습니다.
Code

apps/web/src/components/PromotionBanner.tsx[R21-27]

+  if (promotions.length === 0) return null;
+
+  const current = promotions[currentIndex];
+  const displayTitle =
+    current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
+  const displayArtist =
+    current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
Evidence
PromotionBanner는 빈 배열만 가드하고(currentIndex 범위는 가드하지 않음), React Query invalidate로 promotions 리스트가 비동기로
교체/축소될 수 있습니다. 전역 layout에 항상 포함되어 있어 크래시 영향 범위가 큽니다.

apps/web/src/components/PromotionBanner.tsx[13-27]
apps/web/src/queries/songPromotionQuery.ts[18-26]
apps/web/src/app/layout.tsx[104-117]

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

### Issue description
`PromotionBanner`가 `promotions[currentIndex]`를 그대로 사용하여, promotions 배열 길이가 줄면 `current`가 `undefined`가 되어 렌더링 중 크래시가 발생합니다.

### Issue Context
React Query invalidate/refetch로 `promotions`는 런타임에 자주 교체될 수 있고, 이 배너는 `layout.tsx`에 전역으로 배치되어 있습니다.

### Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-28]
- apps/web/src/queries/songPromotionQuery.ts[18-26]

### Suggested fix
- `promotions`가 바뀔 때마다 `currentIndex`를 안전하게 보정하세요.
 - 예: `useEffect(() => setCurrentIndex(i => Math.min(i, promotions.length - 1)), [promotions.length])`
 - 또는 렌더에서 `const safeIndex = currentIndex % promotions.length`를 사용하고, `current = promotions[safeIndex]`로 접근
- `current`가 falsy일 때 early return 처리도 추가(방어적으로)

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


View more (11)
4. Non-atomic point deduction 🐞 Bug ☼ Reliability ⭐ New
Description
POST /api/songs/promotions가 홍보 insert 후 포인트 update를 별도 쿼리로 실행해, 포인트 update 실패 시 ‘홍보만 등록’되는 불일치가
발생합니다. 또한 point를 먼저 읽어(userData.point) 계산한 뒤 update하여 동시 요청에서 포인트가 중복 차감/무상 홍보가 발생할 수 있습니다.
Code

apps/web/src/app/api/songs/promotions/route.ts[R102-135]

+    const { data: userData, error: userError } = await supabase
+      .from('users')
+      .select('point')
+      .eq('id', userId)
+      .single();
+
+    if (userError || !userData) throw userError ?? new Error('User not found');
+
+    if (userData.point < cost) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: `포인트가 부족합니다. 필요: ${cost}P, 보유: ${userData.point}P`,
+        },
+        { status: 400 },
+      );
+    }
+
+    const { error: insertError } = await supabase.from('song_promotions').insert({
+      song_id,
+      user_id: userId,
+      content,
+      start_date,
+      end_date,
+    });
+
+    if (insertError) throw insertError;
+
+    const { error: pointError } = await supabase
+      .from('users')
+      .update({ point: userData.point - cost })
+      .eq('id', userId);
+
+    if (pointError) throw pointError;
Evidence
현재 구현은 (1) 유저 포인트 조회, (2) promotion insert, (3) users update가 완전히 분리되어 있고, 실패 시 보상/롤백 로직이 없습니다. 또한
update가 userData.point - cost처럼 과거 스냅샷 기반이라 동시 요청에 취약합니다.

apps/web/src/app/api/songs/promotions/route.ts[102-136]

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

### Issue description
홍보 등록(`song_promotions` insert)과 포인트 차감(`users` update)이 트랜잭션 없이 분리되어 있어, 부분 성공/동시성 경합 시 데이터 불일치(무상 홍보, 포인트 오차)가 발생합니다.

### Issue Context
현재는 `userData.point`를 조회한 뒤 insert하고, 마지막에 `userData.point - cost`로 update합니다. 중간에 실패하거나 동시 요청이 들어오면 정합성이 깨집니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[59-137]

### Suggested fix
- DB 레벨에서 원자적으로 처리하도록 Postgres 함수(RPC)를 만들고, API에서는 `supabase.rpc(...)` 1회로 처리하세요.
 - 함수 내부에서: (a) 사용자 포인트 확인(충분하지 않으면 예외), (b) 포인트 차감, (c) promotion insert를 **단일 트랜잭션**으로 실행
- 최소한의 임시 대응(권장 X): 포인트 update 실패 시 insert된 promotion을 즉시 delete하는 보상 로직을 추가하고, update는 조건부로(예: `point >= cost` 조건 포함) 수행해 경합을 줄이세요.

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


5. Comments DELETE not /[id] 📎 Requirement gap ≡ Correctness
Description
The PR implements comment deletion as DELETE /api/songs/comments with a JSON body (commentId)
instead of the required DELETE /api/songs/comments/[id]. This does not meet the specified API
contract and can break clients/tests expecting the [id] route and ownership enforcement at that
path.
Code

apps/web/src/app/api/songs/comments/route.ts[R83-93]

+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { commentId } = await request.json();
+
+    if (!commentId || typeof commentId !== 'string') {
+      return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
+    }
+
Evidence
PR Compliance ID 3 requires deletion to be implemented at DELETE /api/songs/comments/[id]. The
added API route file is .../comments/route.ts and its DELETE handler reads commentId from the
request body, indicating the endpoint is DELETE /api/songs/comments rather than /comments/[id].

Comment APIs enforce authentication and ownership rules
apps/web/src/app/api/songs/comments/route.ts[83-93]

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

## Issue description
Comment deletion is implemented as `DELETE /api/songs/comments` with a request body, but compliance requires `DELETE /api/songs/comments/[id]` and ownership enforcement at that route.
## Issue Context
The checklist (PR Compliance ID 3) explicitly defines the delete endpoint as `DELETE /api/songs/comments/[id]`.
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-20]

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


6. Added shadcn ui/calendar.tsx 📘 Rule violation ⚙ Maintainability
Description
This PR adds a new component file under apps/web/src/components/ui/ (calendar.tsx), which is
disallowed by the shadcn/ui vendoring rule. Keeping changes out of src/components/ui/ is required
to preserve updatability and avoid drift from upstream components.
Code

apps/web/src/components/ui/calendar.tsx[R1-25]

+'use client';
+
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
+
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+
+function Calendar({
+  className,
+  classNames,
+  showOutsideDays = true,
+  captionLayout = 'label',
+  buttonVariant = 'ghost',
+  formatters,
+  components,
+  ...props
+}: React.ComponentProps<typeof DayPicker> & {
+  buttonVariant?: React.ComponentProps<typeof Button>['variant'];
+}) {
+  const defaultClassNames = getDefaultClassNames();
+
+  return (
+    <DayPicker
Evidence
PR Compliance ID 7 forbids making code changes directly within src/components/ui/. The PR
introduces a new file apps/web/src/components/ui/calendar.tsx, which is a direct change under that
directory.

CLAUDE.md
apps/web/src/components/ui/calendar.tsx[1-25]

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

## Issue description
A new shadcn-style component file was added under `apps/web/src/components/ui/`, which violates the rule prohibiting direct changes in that directory.
## Issue Context
The calendar is imported by `SongPromotionModal`, but compliance requires keeping `src/components/ui/` untouched.
## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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


7. Promotion spend not atomic 🐞 Bug ≡ Correctness
Description
POST /api/songs/promotions가 홍보 insert와 유저 포인트 update를 별도 쿼리로 수행해, 중간 실패 시 홍보만 생성되고 포인트가 차감되지 않을 수
있으며 동시 요청에서 포인트를 초과 사용(overspend)할 수 있습니다.
Code

apps/web/src/app/api/songs/promotions/route.ts[R102-135]

+    const { data: userData, error: userError } = await supabase
+      .from('users')
+      .select('point')
+      .eq('id', userId)
+      .single();
+
+    if (userError || !userData) throw userError ?? new Error('User not found');
+
+    if (userData.point < cost) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: `포인트가 부족합니다. 필요: ${cost}P, 보유: ${userData.point}P`,
+        },
+        { status: 400 },
+      );
+    }
+
+    const { error: insertError } = await supabase.from('song_promotions').insert({
+      song_id,
+      user_id: userId,
+      content,
+      start_date,
+      end_date,
+    });
+
+    if (insertError) throw insertError;
+
+    const { error: pointError } = await supabase
+      .from('users')
+      .update({ point: userData.point - cost })
+      .eq('id', userId);
+
+    if (pointError) throw pointError;
Evidence
현재 구현은 (1) users.point를 조회한 뒤 (2) song_promotions에 insert 하고 (3) users.point를 update 합니다. 트랜잭션/조건부
업데이트가 없어서 insert 성공 후 update 실패 또는 경쟁 조건에서 정합성이 깨질 수 있습니다.

apps/web/src/app/api/songs/promotions/route.ts[102-135]

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

## Issue description
`POST /api/songs/promotions`에서 홍보 생성과 포인트 차감이 분리되어 있어, 부분 실패 또는 동시성 상황에서 포인트 정합성이 깨질 수 있습니다.
### Issue Context
현재 흐름: user point 조회 → promotions insert → users update 입니다. 이 방식은 중간 실패 시 무료 홍보가 생기거나, 동시 요청이 point 체크를 통과해 overspend가 발생할 수 있습니다.
### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[99-137]
### Suggested fix approach
- DB 레벨에서 원자적으로 처리되도록 RPC(Stored Procedure)로 `포인트 충분 여부 확인 + 포인트 차감 + promotion insert`를 한 트랜잭션으로 묶어 호출하세요.
- 대안(부분적): `users`를 `update ... where point >= cost` 형태로 조건부 차감하고(영향 row count 확인), 그 다음 insert를 하되 **두 작업은 여전히 트랜잭션으로 묶는 것**이 안전합니다.

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


8. Promotion date not validated 🐞 Bug ☼ Reliability
Description
POST /api/songs/promotions가 start_date/end_date 형식을 검증하지 않고 new Date(...)로 파싱해 days/cost가 NaN이 될 수
있으며, 그 결과 포인트 비교/차감 로직이 비정상 동작(500 또는 잘못된 업데이트)할 수 있습니다.
Code

apps/web/src/app/api/songs/promotions/route.ts[R64-101]

+    const { song_id, content, start_date, end_date } = await request.json();
+
+    if (!song_id || !content || !start_date || !end_date) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: 'song_id, content, start_date, end_date are required',
+        },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 50) {
+      return NextResponse.json(
+        { success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const tomorrowKST = getTomorrowKST();
+
+    if (start_date < tomorrowKST) {
+      return NextResponse.json(
+        { success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    if (end_date < start_date) {
+      return NextResponse.json(
+        { success: false, error: '종료일은 시작일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
+    const cost = days * 50;
+
Evidence
요청 body에서 start_date/end_date를 단순 존재 여부만 체크한 뒤, 문자열 비교와 new Date(end_date), `new
Date(start_date)로 계산합니다. 유효하지 않은 문자열이면 Invalid Date가 되어 differenceInCalendarDays(...)`가 NaN이 되고
cost도 NaN이 됩니다.

apps/web/src/app/api/songs/promotions/route.ts[64-101]
apps/web/src/app/api/songs/promotions/route.ts[130-133]

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

## Issue description
`start_date`/`end_date`가 유효한 `YYYY-MM-DD`인지 검증하지 않아, 잘못된 입력이 들어오면 days/cost가 NaN이 되고 이후 포인트 차감 로직이 깨질 수 있습니다.
### Issue Context
프론트엔드가 정상 값을 보내더라도 API는 외부로 노출되어 있으므로 입력 검증이 필요합니다.
### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-101]
### Suggested fix approach
- `start_date`/`end_date`에 대해 `^\d{4}-\d{2}-\d{2}$` 정규식 체크 후, 파싱 결과가 유효한지(isValid) 확인하세요.
- `days`가 finite number이고 `days >= 1`인지 확인하고, 아니면 400으로 거절하세요.
- 검증 통과 후에만 cost/포인트 차감 로직을 진행하세요.

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


9. Banner index can crash 🐞 Bug ☼ Reliability
Description
PromotionBanner에서 promotions 배열이 줄어들었을 때 currentIndex를 보정하지 않아 promotions[currentIndex]가 undefined가
될 수 있고, 이후 current.title_ko 접근에서 런타임 에러로 크래시할 수 있습니다.
Code

apps/web/src/components/PromotionBanner.tsx[R13-27]

+  useEffect(() => {
+    if (promotions.length <= 1) return;
+    const timer = setInterval(() => {
+      setCurrentIndex(prev => (prev + 1) % promotions.length);
+    }, 3000);
+    return () => clearInterval(timer);
+  }, [promotions.length]);
+
+  if (promotions.length === 0) return null;
+
+  const current = promotions[currentIndex];
+  const displayTitle =
+    current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
+  const displayArtist =
+    current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
Evidence
렌더에서 const current = promotions[currentIndex];로 즉시 인덱싱하지만, currentIndex는 promotions.length 감소 시
자동으로 0..len-1 범위로 클램프되지 않습니다. interval 갱신은 길이 변경 시점 이후에만 일어나므로, 그 사이 렌더에서 out-of-range가 발생할 수 있습니다.

apps/web/src/components/PromotionBanner.tsx[10-27]

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

## Issue description
`PromotionBanner`가 `currentIndex`를 유지한 채 promotions가 줄어들면 out-of-range 인덱싱으로 크래시할 수 있습니다.
### Issue Context
프로모션 목록은 refetch/만료로 언제든지 줄어들 수 있습니다.
### Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-27]
### Suggested fix approach
- promotions가 바뀔 때(또는 length가 바뀔 때) `setCurrentIndex(i => Math.min(i, promotions.length - 1))` 혹은 `if (currentIndex >= promotions.length) setCurrentIndex(0)`로 보정하세요.
- 안전하게 `const current = promotions[currentIndex] ?? promotions[0];` 같은 폴백도 추가하세요.

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


10. No song_comments migration added 📎 Requirement gap ☼ Reliability
Description
New comment APIs depend on the song_comments table, but this PR does not add any schema/migration
code proving the required columns/constraints/index exist. This risks runtime failures or policy
drift from the required Supabase schema spec.
Code

apps/web/src/app/api/songs/comments/route.ts[R19-23]

+    const { data, error } = await supabase
+      .from('song_comments')
+      .select('id, song_id, user_id, content, created_at, users(nickname)')
+      .eq('song_id', songId)
+      .order('created_at', { ascending: false });
Evidence
PR Compliance ID 1 requires a song_comments table with specific constraints and an index, but the
PR only adds code that queries/inserts/deletes from song_comments without adding any
table-creation/migration artifacts in-repo to satisfy the required schema.

Supabase song_comments table is created per spec
apps/web/src/app/api/songs/comments/route.ts[19-23]

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 PR introduces comment APIs that use `song_comments`, but there is no in-repo Supabase migration/schema change ensuring the table exists with the required columns, FK/constraints, and `song_id` index.
## Issue Context
Compliance requires `song_comments` to match the defined schema (UUID PK default `gen_random_uuid()`, FK to `auth.users(id)` with cascade, `char_length(content) <= 100` check, `created_at default now()`, and an index on `song_id`).
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[19-67]
- supabase/migrations/20260508_create_song_comments.sql[1-200]

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


11. No song_promotions migration added 📎 Requirement gap ☼ Reliability
Description
New promotion APIs depend on the song_promotions table, but this PR does not add any
schema/migration code proving the required table structure and end_date index exist. This can
break promotion reads/writes or violate the defined DB design.
Code

apps/web/src/app/api/songs/promotions/route.ts[R15-23]

+    const { data, error } = await supabase
+      .from('song_promotions')
+      .select(
+        'id, song_id, user_id, content, start_date, end_date, users(nickname), songs(title, artist, title_ko, artist_ko)',
+      )
+      .lte('start_date', todayKST)
+      .gte('end_date', todayKST)
+      .order('end_date', { ascending: false });
+
Evidence
PR Compliance ID 2 requires public.song_promotions to exist with specific FK + end_date index,
but the PR only adds API code that queries/inserts into song_promotions without adding any in-repo
schema/migration to establish the required table definition.

Supabase song_promotions table exists and matches the defined schema
apps/web/src/app/api/songs/promotions/route.ts[15-23]

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 PR adds promotion APIs that use `song_promotions`, but no migration/schema change is included to guarantee the table matches the required schema (including FK to `songs(id)` with `on delete cascade`) and the `end_date` index.
## Issue Context
Compliance requires `song_promotions` to have the defined columns and an index on `end_date`.
## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[15-26]
- apps/web/src/app/api/songs/promotions/route.ts[120-136]
- supabase/migrations/20260508_create_song_promotions.sql[1-250]

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


12. Added ui/calendar.tsx 📘 Rule violation ⚙ Maintainability
Description
This PR adds a new file under apps/web/src/components/ui/, which is disallowed for shadcn/ui
vendor components. Direct edits/additions in this directory reduce upgradability and violate the
no-modification rule.
Code

apps/web/src/components/ui/calendar.tsx[R1-10]

+'use client';
+
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
+
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+
+function Calendar({
Evidence
PR Compliance ID 7 forbids any modifications/additions under src/components/ui/. The PR introduces
apps/web/src/components/ui/calendar.tsx, which is within that restricted directory.

CLAUDE.md
apps/web/src/components/ui/calendar.tsx[1-10]

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

## Issue description
A new component was added under `apps/web/src/components/ui/`, which is a restricted shadcn/ui directory per compliance.
## Issue Context
To keep shadcn/ui upgradable, project-specific components/wrappers should live outside `src/components/ui/` (e.g., `src/components/`), or be implemented as separate wrappers that import from shadcn/ui without modifying that directory.
## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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


13. Promotion point update non-atomic 🐞 Bug ☼ Reliability
Description
POST /api/songs/promotions inserts a promotion and then deducts points in a separate DB update, so a
failure after insert (or concurrent requests) can create promotions without the intended point
deduction and/or allow overspending. This causes persistent data inconsistency because there is no
transaction/rollback.
Code

apps/web/src/app/api/songs/promotions/route.ts[R120-135]

+    const { error: insertError } = await supabase.from('song_promotions').insert({
+      song_id,
+      user_id: userId,
+      content,
+      start_date,
+      end_date,
+    });
+
+    if (insertError) throw insertError;
+
+    const { error: pointError } = await supabase
+      .from('users')
+      .update({ point: userData.point - cost })
+      .eq('id', userId);
+
+    if (pointError) throw pointError;
Evidence
The handler performs an insert into song_promotions and only afterwards updates users.point; any
error during the second step results in a thrown error after the promotion row already exists.
Additionally, reading the point balance and later writing a derived balance without locking enables
race conditions under concurrent requests.

apps/web/src/app/api/songs/promotions/route.ts[102-136]

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

## Issue description
Promotion creation and point deduction are performed as two separate operations, which can leave the system in an inconsistent state.
## Issue Context
This endpoint is part of the point economy; it must be correct under failures and concurrency.
## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[102-136]
## Suggested fix
- Move the logic into a single atomic DB operation, e.g.:
- a Postgres function (Supabase RPC) that:
 - `SELECT ... FOR UPDATE` the user's point row
 - verifies sufficient points
 - inserts into `song_promotions`
 - updates `users.point = users.point - cost`
 - returns success/failure
- If RPC is not possible immediately, at minimum:
- perform the point deduction first with a conditional update (only if point >= cost) and check affected row count,
- and only then insert the promotion (still not fully atomic, but reduces free-promotion cases).

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


14. Promotion dates not validated 🐞 Bug ≡ Correctness
Description
POST /api/songs/promotions uses start_date/end_date directly from request.json for string
comparisons and date math; malformed values can produce NaN for days/cost and make the
userData.point < cost guard ineffective. This lets invalid requests proceed to promotion
insert/point update with an undefined cost basis.
Code

apps/web/src/app/api/songs/promotions/route.ts[R64-101]

+    const { song_id, content, start_date, end_date } = await request.json();
+
+    if (!song_id || !content || !start_date || !end_date) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: 'song_id, content, start_date, end_date are required',
+        },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 50) {
+      return NextResponse.json(
+        { success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const tomorrowKST = getTomorrowKST();
+
+    if (start_date < tomorrowKST) {
+      return NextResponse.json(
+        { success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    if (end_date < start_date) {
+      return NextResponse.json(
+        { success: false, error: '종료일은 시작일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
+    const cost = days * 50;
+
Evidence
The handler does not type/format-check start_date and end_date before new Date(...) and
differenceInCalendarDays(...). If the dates are invalid, days becomes NaN and `userData.point <
cost` will never be true (because comparisons with NaN are false), so insufficient-point validation
is skipped.

apps/web/src/app/api/songs/promotions/route.ts[64-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
`start_date`/`end_date` are not validated, allowing invalid dates to bypass the point check due to NaN cost calculations.
## Issue Context
This endpoint expects `YYYY-MM-DD` strings.
## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-118]
## Suggested fix
- Validate input types:
- `typeof start_date === 'string' && typeof end_date === 'string'`
- Validate format strictly (e.g. `/^\d{4}-\d{2}-\d{2}$/`).
- Parse using a strict parser (or at least validate `Number.isFinite(date.getTime())`).
- After computing `days`, assert `Number.isFinite(days) && days > 0` before computing cost.
- If invalid, return 400 with a clear error.

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



Remediation recommended

15. KST vs local date mismatch 🐞 Bug ≡ Correctness ⭐ New
Description
SongPromotionModal이 선택한 날짜를 format(date, 'yyyy-MM-dd')로 로컬 타임존 기준 문자열로 만들고, Calendar disable도
getTomorrowKSTDate()가 반환한 로컬 자정 Date에 의존합니다. 서버는 KST 기준 문자열(getTomorrowKST)로 검증하므로 타임존이 KST가 아닌
환경에서 날짜가 하루씩 어긋나거나(선택은 되는데 서버에서 거절) 잘못된 기간으로 저장될 수 있습니다.
Code

apps/web/src/components/SongPromotionModal.tsx[R41-73]

+  const point = user?.point ?? 0;
+  const tomorrowKST = getTomorrowKSTDate();
+
+  const days = range?.from
+    ? differenceInCalendarDays(range.to ?? range.from, range.from) + 1
+    : 0;
+
+  const cost = days * 50;
+  const canAfford = point >= cost;
+  const canSubmit = days > 0 && content.trim().length > 0 && canAfford;
+
+  const displayTitle = title_ko && title_ko !== title ? title_ko : title;
+  const displayArtist = artist_ko && artist_ko !== artist ? artist_ko : artist;
+
+  const handleProceed = () => {
+    if (!canSubmit || !range?.from) return;
+    setIsConfirming(true);
+  };
+
+  const handleConfirm = () => {
+    if (!canSubmit || !range?.from) return;
+    const start_date = format(range.from, 'yyyy-MM-dd');
+    const end_date = format(range.to ?? range.from, 'yyyy-MM-dd');
+    postPromotion(
+      {
+        song_id: songId,
+        content: content.trim(),
+        start_date,
+        end_date,
+      },
+      { onSuccess: handleClose },
+    );
+  };
Evidence
클라이언트는 format(range.from, 'yyyy-MM-dd')로 로컬 기준 날짜 문자열을 전송하고, 서버는 getTomorrowKST()(UTC+9 기반 문자열)와
문자열 비교로 시작일을 검증합니다. 또한 getTomorrowKSTDate()는 KST가 아닌 ‘로컬 자정 Date’를 만들어 Calendar disabled 기준으로 쓰고
있어 클라이언트/서버 기준이 쉽게 분리됩니다.

apps/web/src/components/SongPromotionModal.tsx[41-73]
apps/web/src/components/SongPromotionModal.tsx[165-174]
apps/web/src/utils/kst.ts[13-17]
apps/web/src/app/api/songs/promotions/route.ts[83-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
홍보 기간이 ‘KST 기준’이어야 하는데, 클라이언트는 날짜를 로컬 타임존 기준으로 문자열화/disable 처리하고 서버는 KST 문자열로 검증하여, 타임존이 다른 환경에서 오프바이원/거절 오류가 발생할 수 있습니다.

### Issue Context
- 클라이언트 전송: `format(range.from, 'yyyy-MM-dd')` (로컬 기준)
- 서버 검증: `start_date < getTomorrowKST()` (KST 기준 문자열)
- Calendar disable: `disabled={{ before: getTomorrowKSTDate() }}` (로컬 자정 Date)

### Fix Focus Areas
- apps/web/src/components/SongPromotionModal.tsx[41-73]
- apps/web/src/components/SongPromotionModal.tsx[165-174]
- apps/web/src/utils/kst.ts[1-17]

### Suggested fix
- DateRange/Calendar 내부 표현은 Date로 두더라도, **서버로 보내는 start/end_date는 KST 기준 문자열**로 변환해서 보내세요.
 - 예: `toKstDateString(date: Date): string` 유틸을 만들고 `Intl.DateTimeFormat`(timeZone: 'Asia/Seoul') 등으로 `YYYY-MM-DD` 생성
- Calendar의 `disabled`는 객체(before: Date) 대신 함수 형태로 KST 기준 문자열 비교를 하여 로컬 타임존 영향 제거:
 - `disabled={(d) => toKstDateString(d) < getTomorrowKST()}`
- `getTomorrowKSTDate()`는 오해 소지가 있으므로(로컬 Date 반환), 위 방식으로 대체하거나 이름/주석을 ‘로컬 기준’으로 명확히 하세요.

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


16. Blank comments accepted 🐞 Bug ≡ Correctness ⭐ New
Description
POST /api/songs/comments가 content를 trim하지 않고 !content/length만 검사하여 공백만 있는 문자열(예: '   ')이 통과하고 DB에
저장됩니다. 클라이언트는 trim하지만 API는 직접 호출 시 쉽게 우회됩니다.
Code

apps/web/src/app/api/songs/comments/route.ts[R48-67]

+    const { song_id, content } = await request.json();
+
+    if (!song_id || !content) {
+      return NextResponse.json(
+        { success: false, error: 'song_id and content are required' },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 100) {
+      return NextResponse.json(
+        { success: false, error: '댓글은 100자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const { error } = await supabase
+      .from('song_comments')
+      .insert({ song_id, user_id: userId, content });
+
Evidence
서버는 content를 그대로 insert하며 공백-only를 차단하는 검증이 없습니다. 반면 UI는 content.trim() 기반으로 제출하여(클라이언트 의존) 서버가
단독으로는 무결성을 보장하지 못합니다.

apps/web/src/app/api/songs/comments/route.ts[43-67]
apps/web/src/components/SongCommentSection.tsx[30-42]

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

### Issue description
댓글 생성 API가 공백-only 댓글을 허용합니다. 서버에서 `content`를 trim하지 않아 `"   "` 같은 입력이 저장될 수 있습니다.

### Issue Context
클라이언트는 trim을 적용하지만(우회 가능), API는 직접 호출될 수 있으므로 서버가 최종 검증을 해야 합니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[43-70]

### Suggested fix
- `content`를 문자열로 강제하고 trim한 값을 검증/저장하세요.
 - 예: `const trimmed = String(content ?? '').trim(); if (!trimmed) return 400; if (trimmed.length > 100) return 400;` 
 - insert 시에도 `content: trimmed` 사용

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


17. Calendar uses local midnight 🐞 Bug ≡ Correctness
Description
getTomorrowKSTDate()가 로컬 타임존 자정 Date를 만들어 캘린더 비활성화 기준이 KST가 아닌 로컬 기준이 되고, 또한 선택된 날짜를 로컬 기준으로
'yyyy-MM-dd' 포맷해 서버의 KST 기준 검증과 날짜가 어긋날 수 있습니다.
Code

apps/web/src/utils/kst.ts[R14-17]

+export function getTomorrowKSTDate(): Date {
+  const [y, m, d] = getTomorrowKST().split('-').map(Number);
+  return new Date(y, m - 1, d);
+}
Evidence
getTomorrowKSTDate()new Date(y, m-1, d)로 로컬 자정 Date를 생성합니다. SongPromotionModal은 이 값을
disabled={{ before: tomorrowKST }}로 사용하고, 서버로 보낼 start_date/end_date도 로컬 Date를 `format(...,
'yyyy-MM-dd')`로 직렬화하므로, KST 기준 날짜와 불일치가 발생할 수 있습니다.

apps/web/src/utils/kst.ts[13-17]
apps/web/src/components/SongPromotionModal.tsx[60-70]
apps/web/src/components/SongPromotionModal.tsx[168-173]
apps/web/src/app/api/songs/promotions/route.ts[83-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
클라이언트에서 날짜 선택/직렬화가 로컬 타임존 기준으로 동작해, 서버의 KST 기준 검증과 날짜가 어긋날 수 있습니다.
### Issue Context
서버는 `getTomorrowKST()`(KST 기준)로 `start_date`를 검증합니다. 클라이언트가 로컬 기준으로 날짜 문자열을 만들면 KST 기준 '내일'과 달라질 수 있습니다.
### Fix Focus Areas
- apps/web/src/utils/kst.ts[13-17]
- apps/web/src/components/SongPromotionModal.tsx[60-70]
- apps/web/src/components/SongPromotionModal.tsx[168-173]
### Suggested fix approach
- 선택된 Date를 서버로 보낼 때 KST 기준 문자열로 변환하는 유틸을 추가하세요(예: `formatKSTDate(date): addHours(date, 9).toISOString().slice(0,10)` 같은 방식으로 epoch 기반 KST day를 계산).
- Calendar disable도 단순 `before: Date` 대신, `disabled={(date) => formatKSTDate(date) < getTomorrowKST()}`처럼 KST 기준으로 비교하도록 변경하세요(react-day-picker는 disabled에 함수도 허용).
- 이렇게 하면 사용자의 로컬 타임존과 무관하게 KST 기준으로 선택 제한/전송이 일치합니다.

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


View more (2)
18. Root CLAUDE.md not updated 📘 Rule violation ⚙ Maintainability
Description
This PR changes .claude/commands/ and .claude/settings.json, but does not update the repository
guidance in the root CLAUDE.md accordingly. This violates the requirement to keep CLAUDE.md in
sync with workflow/rules changes.
Code

.claude/commands/start.md[R25-40]

+   이슈 본문은 아래 구조로 작성한다:
+   - 작업 개요 설명 (1~3줄)
+   - `## 작업 체크리스트` 섹션: 구현할 항목을 GitHub Flavored Markdown checkbox(`- [ ]`)로 나열
+   - 완료된 항목은 `- [x]`로 표시 (이슈 생성 시점엔 모두 `- [ ]`)
+

gh issue create --title "<이슈 제목>" --body "<이슈 본문>"

생성된 이슈 번호를 추출한다.
+   작업이 진행되면서 항목이 완료될 때마다 아래 명령으로 이슈 본문을 갱신한다:
+
+   ```
+   gh issue edit <번호> --body "<갱신된 본문>"
+   ```
Evidence
PR Compliance ID 8 requires updating CLAUDE.md when .claude/commands/ changes; the PR updates
/start workflow instructions in .claude/commands/start.md and also changes
.claude/settings.json, but no corresponding update is made to the root CLAUDE.md workflow/rules
documentation.

CLAUDE.md
.claude/commands/start.md[25-40]
.claude/settings.json[1-5]
CLAUDE.md[108-117]

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

## Issue description
Repository workflow/rules files under `.claude/` were changed, but the root `CLAUDE.md` was not updated to reflect the new workflow guidance.
## Issue Context
`CLAUDE.md` explicitly states it must be updated when `.claude/commands/` changes.
## Fix Focus Areas
- .claude/commands/start.md[23-40]
- .claude/settings.json[1-5]
- CLAUDE.md[61-117]

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


19. Whitespace comments accepted 🐞 Bug ≡ Correctness
Description
POST /api/songs/comments does not trim content or reject whitespace-only strings, so requests like
content="   " pass validation and insert effectively empty comments. This makes server-side
validation inconsistent with the client, and the API is not robust against non-UI callers.
Code

apps/web/src/app/api/songs/comments/route.ts[R48-67]

+    const { song_id, content } = await request.json();
+
+    if (!song_id || !content) {
+      return NextResponse.json(
+        { success: false, error: 'song_id and content are required' },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 100) {
+      return NextResponse.json(
+        { success: false, error: '댓글은 100자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const { error } = await supabase
+      .from('song_comments')
+      .insert({ song_id, user_id: userId, content });
+
Evidence
The server only checks !content and content.length, which does not catch whitespace-only
strings; it inserts the raw content. The client trims before posting, showing the API currently
relies on the UI for correctness.

apps/web/src/app/api/songs/comments/route.ts[48-67]
apps/web/src/components/SongCommentSection.tsx[30-42]

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

## Issue description
Server allows whitespace-only comments because it does not trim or validate non-empty content.
## Issue Context
Client currently trims, but API should be self-contained.
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[48-67]
## Suggested fix
- Convert to:
- `const trimmed = typeof content === 'string' ? content.trim() : ''`
- validate `trimmed.length > 0`
- validate `trimmed.length <= 100`
- insert `trimmed`

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



Advisory comments

20. Noisy server console.log 🐞 Bug ⚙ Maintainability ⭐ New
Description
DELETE /api/songs/comments가 삭제 결과를 console.log로 남겨 서버 로그를 불필요하게 오염시킵니다. 운영 환경에서 로그 노이즈를 늘리고 문제 분석 시
신호 대 잡음비를 떨어뜨립니다.
Code

apps/web/src/app/api/songs/comments/route.ts[R94-103]

+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
Evidence
삭제 직후 매 요청마다 console.log('data', data, 'count', count)를 출력하도록 되어 있습니다.

apps/web/src/app/api/songs/comments/route.ts[94-104]

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

### Issue description
댓글 삭제 API에 디버그용 `console.log`가 남아 있어 서버 로그를 오염시킵니다.

### Issue Context
운영 환경에서 불필요한 로그는 비용/가독성에 악영향을 줍니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[94-104]

### Suggested fix
- `console.log('data', data, 'count', count);` 제거
- 필요 시 환경 기반(debug only) 로깅으로 변경

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


21. Debug log in DELETE 🐞 Bug ⚙ Maintainability
Description
DELETE /api/songs/comments에서 삭제 결과를 console.log로 출력하고 있어 프로덕션 로그 노이즈를 만들고 불필요한 내부 상태 노출 위험을 키웁니다.
Code

apps/web/src/app/api/songs/comments/route.ts[R94-103]

+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
Evidence
삭제 API 내부에서 console.log('data', data, 'count', count);를 남겨둔 상태입니다. 운영 환경에서는 불필요한 로그로 디버깅을 방해할 수
있습니다.

apps/web/src/app/api/songs/comments/route.ts[94-103]

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

## Issue description
댓글 삭제 API에 디버그 로그가 남아 있습니다.
### Issue Context
서버 라우트의 console.log는 운영 로그에 그대로 남아 노이즈가 됩니다.
### Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[101-101]
### Suggested fix approach
- 해당 `console.log(...)` 라인을 제거하거나, 필요하다면 `NODE_ENV !== 'production'`에서만 출력되도록 가드하세요.

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


22. Leftover debug console.log 🐞 Bug ⚙ Maintainability
Description
DELETE /api/songs/comments contains a console.log of deletion results, creating noisy logs in
production and making it harder to spot real errors. This should be removed or replaced with
structured logging at an appropriate level.
Code

apps/web/src/app/api/songs/comments/route.ts[R94-104]

+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
+    }
Evidence
The delete handler logs every deletion attempt regardless of outcome, which is not part of the
endpoint’s behavior and will clutter server logs.

apps/web/src/app/api/songs/comments/route.ts[94-104]

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

## Issue description
Unconditional `console.log` remains in the comments delete API.
## Issue Context
This runs on the server for every delete request.
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[94-104]
## Suggested fix
- Remove the `console.log`.
- If visibility is needed, replace with a structured log only on unexpected conditions (e.g., errors), without logging sensitive payloads.

ⓘ 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

qodo-code-review Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (10) 📘 Rule violations (3) 📎 Requirement gaps (4)

Context used

Grey Divider


Action required

1. Comments DELETE not /[id] 📎 Requirement gap ≡ Correctness ⭐ New
Description
The PR implements comment deletion as DELETE /api/songs/comments with a JSON body (commentId)
instead of the required DELETE /api/songs/comments/[id]. This does not meet the specified API
contract and can break clients/tests expecting the [id] route and ownership enforcement at that
path.
Code

apps/web/src/app/api/songs/comments/route.ts[R83-93]

+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { commentId } = await request.json();
+
+    if (!commentId || typeof commentId !== 'string') {
+      return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
+    }
+
Evidence
PR Compliance ID 3 requires deletion to be implemented at DELETE /api/songs/comments/[id]. The
added API route file is .../comments/route.ts and its DELETE handler reads commentId from the
request body, indicating the endpoint is DELETE /api/songs/comments rather than /comments/[id].

Comment APIs enforce authentication and ownership rules
apps/web/src/app/api/songs/comments/route.ts[83-93]

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

## Issue description
Comment deletion is implemented as `DELETE /api/songs/comments` with a request body, but compliance requires `DELETE /api/songs/comments/[id]` and ownership enforcement at that route.

## Issue Context
The checklist (PR Compliance ID 3) explicitly defines the delete endpoint as `DELETE /api/songs/comments/[id]`.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-20]

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


2. Added shadcn ui/calendar.tsx 📘 Rule violation ⚙ Maintainability ⭐ New
Description
This PR adds a new component file under apps/web/src/components/ui/ (calendar.tsx), which is
disallowed by the shadcn/ui vendoring rule. Keeping changes out of src/components/ui/ is required
to preserve updatability and avoid drift from upstream components.
Code

apps/web/src/components/ui/calendar.tsx[R1-25]

+'use client';
+
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
+
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+
+function Calendar({
+  className,
+  classNames,
+  showOutsideDays = true,
+  captionLayout = 'label',
+  buttonVariant = 'ghost',
+  formatters,
+  components,
+  ...props
+}: React.ComponentProps<typeof DayPicker> & {
+  buttonVariant?: React.ComponentProps<typeof Button>['variant'];
+}) {
+  const defaultClassNames = getDefaultClassNames();
+
+  return (
+    <DayPicker
Evidence
PR Compliance ID 7 forbids making code changes directly within src/components/ui/. The PR
introduces a new file apps/web/src/components/ui/calendar.tsx, which is a direct change under that
directory.

CLAUDE.md
apps/web/src/components/ui/calendar.tsx[1-25]

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

## Issue description
A new shadcn-style component file was added under `apps/web/src/components/ui/`, which violates the rule prohibiting direct changes in that directory.

## Issue Context
The calendar is imported by `SongPromotionModal`, but compliance requires keeping `src/components/ui/` untouched.

## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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


3. Promotion spend not atomic 🐞 Bug ≡ Correctness ⭐ New
Description
POST /api/songs/promotions가 홍보 insert와 유저 포인트 update를 별도 쿼리로 수행해, 중간 실패 시 홍보만 생성되고 포인트가 차감되지 않을 수
있으며 동시 요청에서 포인트를 초과 사용(overspend)할 수 있습니다.
Code

apps/web/src/app/api/songs/promotions/route.ts[R102-135]

+    const { data: userData, error: userError } = await supabase
+      .from('users')
+      .select('point')
+      .eq('id', userId)
+      .single();
+
+    if (userError || !userData) throw userError ?? new Error('User not found');
+
+    if (userData.point < cost) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: `포인트가 부족합니다. 필요: ${cost}P, 보유: ${userData.point}P`,
+        },
+        { status: 400 },
+      );
+    }
+
+    const { error: insertError } = await supabase.from('song_promotions').insert({
+      song_id,
+      user_id: userId,
+      content,
+      start_date,
+      end_date,
+    });
+
+    if (insertError) throw insertError;
+
+    const { error: pointError } = await supabase
+      .from('users')
+      .update({ point: userData.point - cost })
+      .eq('id', userId);
+
+    if (pointError) throw pointError;
Evidence
현재 구현은 (1) users.point를 조회한 뒤 (2) song_promotions에 insert 하고 (3) users.point를 update 합니다. 트랜잭션/조건부
업데이트가 없어서 insert 성공 후 update 실패 또는 경쟁 조건에서 정합성이 깨질 수 있습니다.

apps/web/src/app/api/songs/promotions/route.ts[102-135]

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

### Issue description
`POST /api/songs/promotions`에서 홍보 생성과 포인트 차감이 분리되어 있어, 부분 실패 또는 동시성 상황에서 포인트 정합성이 깨질 수 있습니다.

### Issue Context
현재 흐름: user point 조회 → promotions insert → users update 입니다. 이 방식은 중간 실패 시 무료 홍보가 생기거나, 동시 요청이 point 체크를 통과해 overspend가 발생할 수 있습니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[99-137]

### Suggested fix approach
- DB 레벨에서 원자적으로 처리되도록 RPC(Stored Procedure)로 `포인트 충분 여부 확인 + 포인트 차감 + promotion insert`를 한 트랜잭션으로 묶어 호출하세요.
- 대안(부분적): `users`를 `update ... where point >= cost` 형태로 조건부 차감하고(영향 row count 확인), 그 다음 insert를 하되 **두 작업은 여전히 트랜잭션으로 묶는 것**이 안전합니다.

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


View more (9)
4. Promotion date not validated 🐞 Bug ☼ Reliability ⭐ New
Description
POST /api/songs/promotions가 start_date/end_date 형식을 검증하지 않고 new Date(...)로 파싱해 days/cost가 NaN이 될 수
있으며, 그 결과 포인트 비교/차감 로직이 비정상 동작(500 또는 잘못된 업데이트)할 수 있습니다.
Code

apps/web/src/app/api/songs/promotions/route.ts[R64-101]

+    const { song_id, content, start_date, end_date } = await request.json();
+
+    if (!song_id || !content || !start_date || !end_date) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: 'song_id, content, start_date, end_date are required',
+        },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 50) {
+      return NextResponse.json(
+        { success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const tomorrowKST = getTomorrowKST();
+
+    if (start_date < tomorrowKST) {
+      return NextResponse.json(
+        { success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    if (end_date < start_date) {
+      return NextResponse.json(
+        { success: false, error: '종료일은 시작일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
+    const cost = days * 50;
+
Evidence
요청 body에서 start_date/end_date를 단순 존재 여부만 체크한 뒤, 문자열 비교와 new Date(end_date), `new
Date(start_date)로 계산합니다. 유효하지 않은 문자열이면 Invalid Date가 되어 differenceInCalendarDays(...)`가 NaN이 되고
cost도 NaN이 됩니다.

apps/web/src/app/api/songs/promotions/route.ts[64-101]
apps/web/src/app/api/songs/promotions/route.ts[130-133]

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

### Issue description
`start_date`/`end_date`가 유효한 `YYYY-MM-DD`인지 검증하지 않아, 잘못된 입력이 들어오면 days/cost가 NaN이 되고 이후 포인트 차감 로직이 깨질 수 있습니다.

### Issue Context
프론트엔드가 정상 값을 보내더라도 API는 외부로 노출되어 있으므로 입력 검증이 필요합니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-101]

### Suggested fix approach
- `start_date`/`end_date`에 대해 `^\d{4}-\d{2}-\d{2}$` 정규식 체크 후, 파싱 결과가 유효한지(isValid) 확인하세요.
- `days`가 finite number이고 `days >= 1`인지 확인하고, 아니면 400으로 거절하세요.
- 검증 통과 후에만 cost/포인트 차감 로직을 진행하세요.

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


5. Banner index can crash 🐞 Bug ☼ Reliability ⭐ New
Description
PromotionBanner에서 promotions 배열이 줄어들었을 때 currentIndex를 보정하지 않아 promotions[currentIndex]가 undefined가
될 수 있고, 이후 current.title_ko 접근에서 런타임 에러로 크래시할 수 있습니다.
Code

apps/web/src/components/PromotionBanner.tsx[R13-27]

+  useEffect(() => {
+    if (promotions.length <= 1) return;
+    const timer = setInterval(() => {
+      setCurrentIndex(prev => (prev + 1) % promotions.length);
+    }, 3000);
+    return () => clearInterval(timer);
+  }, [promotions.length]);
+
+  if (promotions.length === 0) return null;
+
+  const current = promotions[currentIndex];
+  const displayTitle =
+    current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
+  const displayArtist =
+    current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
Evidence
렌더에서 const current = promotions[currentIndex];로 즉시 인덱싱하지만, currentIndex는 promotions.length 감소 시
자동으로 0..len-1 범위로 클램프되지 않습니다. interval 갱신은 길이 변경 시점 이후에만 일어나므로, 그 사이 렌더에서 out-of-range가 발생할 수 있습니다.

apps/web/src/components/PromotionBanner.tsx[10-27]

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

### Issue description
`PromotionBanner`가 `currentIndex`를 유지한 채 promotions가 줄어들면 out-of-range 인덱싱으로 크래시할 수 있습니다.

### Issue Context
프로모션 목록은 refetch/만료로 언제든지 줄어들 수 있습니다.

### Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-27]

### Suggested fix approach
- promotions가 바뀔 때(또는 length가 바뀔 때) `setCurrentIndex(i => Math.min(i, promotions.length - 1))` 혹은 `if (currentIndex >= promotions.length) setCurrentIndex(0)`로 보정하세요.
- 안전하게 `const current = promotions[currentIndex] ?? promotions[0];` 같은 폴백도 추가하세요.

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


6. No song_comments migration added 📎 Requirement gap ☼ Reliability
Description
New comment APIs depend on the song_comments table, but this PR does not add any schema/migration
code proving the required columns/constraints/index exist. This risks runtime failures or policy
drift from the required Supabase schema spec.
Code

apps/web/src/app/api/songs/comments/route.ts[R19-23]

+    const { data, error } = await supabase
+      .from('song_comments')
+      .select('id, song_id, user_id, content, created_at, users(nickname)')
+      .eq('song_id', songId)
+      .order('created_at', { ascending: false });
Evidence
PR Compliance ID 1 requires a song_comments table with specific constraints and an index, but the
PR only adds code that queries/inserts/deletes from song_comments without adding any
table-creation/migration artifacts in-repo to satisfy the required schema.

Supabase song_comments table is created per spec
apps/web/src/app/api/songs/comments/route.ts[19-23]

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 PR introduces comment APIs that use `song_comments`, but there is no in-repo Supabase migration/schema change ensuring the table exists with the required columns, FK/constraints, and `song_id` index.
## Issue Context
Compliance requires `song_comments` to match the defined schema (UUID PK default `gen_random_uuid()`, FK to `auth.users(id)` with cascade, `char_length(content) <= 100` check, `created_at default now()`, and an index on `song_id`).
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[19-67]
- supabase/migrations/20260508_create_song_comments.sql[1-200]

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


7. No song_promotions migration added 📎 Requirement gap ☼ Reliability
Description
New promotion APIs depend on the song_promotions table, but this PR does not add any
schema/migration code proving the required table structure and end_date index exist. This can
break promotion reads/writes or violate the defined DB design.
Code

apps/web/src/app/api/songs/promotions/route.ts[R15-23]

+    const { data, error } = await supabase
+      .from('song_promotions')
+      .select(
+        'id, song_id, user_id, content, start_date, end_date, users(nickname), songs(title, artist, title_ko, artist_ko)',
+      )
+      .lte('start_date', todayKST)
+      .gte('end_date', todayKST)
+      .order('end_date', { ascending: false });
+
Evidence
PR Compliance ID 2 requires public.song_promotions to exist with specific FK + end_date index,
but the PR only adds API code that queries/inserts into song_promotions without adding any in-repo
schema/migration to establish the required table definition.

Supabase song_promotions table exists and matches the defined schema
apps/web/src/app/api/songs/promotions/route.ts[15-23]

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 PR adds promotion APIs that use `song_promotions`, but no migration/schema change is included to guarantee the table matches the required schema (including FK to `songs(id)` with `on delete cascade`) and the `end_date` index.
## Issue Context
Compliance requires `song_promotions` to have the defined columns and an index on `end_date`.
## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[15-26]
- apps/web/src/app/api/songs/promotions/route.ts[120-136]
- supabase/migrations/20260508_create_song_promotions.sql[1-250]

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


8. DELETE comments not /[id] 📎 Requirement gap ≡ Correctness
Description
The PR implements comment deletion on /api/songs/comments using a JSON body commentId, not on
/api/songs/comments/[id] as required. This fails the specified API contract in the compliance
checklist.
Code

apps/web/src/app/api/songs/comments/route.ts[R83-92]

+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { commentId } = await request.json();
+
+    if (!commentId || typeof commentId !== 'string') {
+      return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
+    }
Evidence
PR Compliance ID 3 explicitly requires DELETE /api/songs/comments/[id], but the added route
handler defines DELETE in apps/web/src/app/api/songs/comments/route.ts, meaning the endpoint is
/api/songs/comments and takes commentId from the request body.

Comment APIs enforce authentication and ownership rules
apps/web/src/app/api/songs/comments/route.ts[83-92]
apps/web/src/lib/api/songComment.ts[18-20]

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 comment API is implemented at `/api/songs/comments` with a body `commentId`, but compliance requires `DELETE /api/songs/comments/[id]`.
## Issue Context
This is an API contract requirement in the compliance checklist; client code should call the `[id]` route.
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-20]
- apps/web/src/app/api/songs/comments/[id]/route.ts[1-200]

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


9. Added ui/calendar.tsx 📘 Rule violation ⚙ Maintainability
Description
This PR adds a new file under apps/web/src/components/ui/, which is disallowed for shadcn/ui
vendor components. Direct edits/additions in this directory reduce upgradability and violate the
no-modification rule.
Code

apps/web/src/components/ui/calendar.tsx[R1-10]

+'use client';
+
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
+
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+
+function Calendar({
Evidence
PR Compliance ID 7 forbids any modifications/additions under src/components/ui/. The PR introduces
apps/web/src/components/ui/calendar.tsx, which is within that restricted directory.

CLAUDE.md
apps/web/src/components/ui/calendar.tsx[1-10]

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

## Issue description
A new component was added under `apps/web/src/components/ui/`, which is a restricted shadcn/ui directory per compliance.
## Issue Context
To keep shadcn/ui upgradable, project-specific components/wrappers should live outside `src/components/ui/` (e.g., `src/components/`), or be implemented as separate wrappers that import from shadcn/ui without modifying that directory.
## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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


10. Banner index out-of-range 🐞 Bug ≡ Correctness
Description
PromotionBanner accesses promotions[currentIndex] without clamping when the promotions list shrinks,
so current can become undefined and the component will throw while rendering
current.title_ko/current.artist_ko. This can happen after a refetch because the promotions query is
invalidated on promotion creation.
Code

apps/web/src/components/PromotionBanner.tsx[R21-27]

+  if (promotions.length === 0) return null;
+
+  const current = promotions[currentIndex];
+  const displayTitle =
+    current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
+  const displayArtist =
+    current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
Evidence
PromotionBanner calculates current from promotions[currentIndex] and immediately dereferences it,
but currentIndex is only advanced modulo the *current* promotions.length and is never reset when
length decreases; a shrink from N>1 to 1 while currentIndex>0 will make promotions[currentIndex]
undefined. The promotions list can change at runtime because the promotion mutation invalidates the
query, triggering a refetch with a different-length array.

apps/web/src/components/PromotionBanner.tsx[13-27]
apps/web/src/queries/songPromotionQuery.ts[18-25]

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

## Issue description
`PromotionBanner` can crash when the promotions array shrinks because `currentIndex` is not clamped to the new array length.
## Issue Context
The query is invalidated after promotion creation, so the fetched list can change length.
## Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-27]
## Suggested fix
- Add an effect that runs when `promotions.length` changes:
- If `promotions.length === 0`, set `currentIndex` to 0.
- If `currentIndex >= promotions.length`, set `currentIndex` to 0 (or `promotions.length - 1`).
- Alternatively, compute `const safeIndex = promotions.length ? currentIndex % promotions.length : 0` before indexing.

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


11. Promotion point update non-atomic 🐞 Bug ☼ Reliability
Description
POST /api/songs/promotions inserts a promotion and then deducts points in a separate DB update, so a
failure after insert (or concurrent requests) can create promotions without the intended point
deduction and/or allow overspending. This causes persistent data inconsistency because there is no
transaction/rollback.
Code

apps/web/src/app/api/songs/promotions/route.ts[R120-135]

+    const { error: insertError } = await supabase.from('song_promotions').insert({
+      song_id,
+      user_id: userId,
+      content,
+      start_date,
+      end_date,
+    });
+
+    if (insertError) throw insertError;
+
+    const { error: pointError } = await supabase
+      .from('users')
+      .update({ point: userData.point - cost })
+      .eq('id', userId);
+
+    if (pointError) throw pointError;
Evidence
The handler performs an insert into song_promotions and only afterwards updates users.point; any
error during the second step results in a thrown error after the promotion row already exists.
Additionally, reading the point balance and later writing a derived balance without locking enables
race conditions under concurrent requests.

apps/web/src/app/api/songs/promotions/route.ts[102-136]

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

## Issue description
Promotion creation and point deduction are performed as two separate operations, which can leave the system in an inconsistent state.
## Issue Context
This endpoint is part of the point economy; it must be correct under failures and concurrency.
## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[102-136]
## Suggested fix
- Move the logic into a single atomic DB operation, e.g.:
- a Postgres function (Supabase RPC) that:
  - `SELECT ... FOR UPDATE` the user's point row
  - verifies sufficient points
  - inserts into `song_promotions`
  - updates `users.point = users.point - cost`
  - returns success/failure
- If RPC is not possible immediately, at minimum:
- perform the point deduction first with a conditional update (only if point >= cost) and check affected row count,
- and only then insert the promotion (still not fully atomic, but reduces free-promotion cases).

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


12. Promotion dates not validated 🐞 Bug ≡ Correctness
Description
POST /api/songs/promotions uses start_date/end_date directly from request.json for string
comparisons and date math; malformed values can produce NaN for days/cost and make the
userData.point < cost guard ineffective. This lets invalid requests proceed to promotion
insert/point update with an undefined cost basis.
Code

apps/web/src/app/api/songs/promotions/route.ts[R64-101]

+    const { song_id, content, start_date, end_date } = await request.json();
+
+    if (!song_id || !content || !start_date || !end_date) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: 'song_id, content, start_date, end_date are required',
+        },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 50) {
+      return NextResponse.json(
+        { success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const tomorrowKST = getTomorrowKST();
+
+    if (start_date < tomorrowKST) {
+      return NextResponse.json(
+        { success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    if (end_date < start_date) {
+      return NextResponse.json(
+        { success: false, error: '종료일은 시작일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
+    const cost = days * 50;
+
Evidence
The handler does not type/format-check start_date and end_date before new Date(...) and
differenceInCalendarDays(...). If the dates are invalid, days becomes NaN and `userData.point <
cost` will never be true (because comparisons with NaN are false), so insufficient-point validation
is skipped.

apps/web/src/app/api/songs/promotions/route.ts[64-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
`start_date`/`end_date` are not validated, allowing invalid dates to bypass the point check due to NaN cost calculations.
## Issue Context
This endpoint expects `YYYY-MM-DD` strings.
## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-118]
## Suggested fix
- Validate input types:
- `typeof start_date === 'string' && typeof end_date === 'string'`
- Validate format strictly (e.g. `/^\d{4}-\d{2}-\d{2}$/`).
- Parse using a strict parser (or at least validate `Number.isFinite(date.getTime())`).
- After computing `days`, assert `Number.isFinite(days) && days > 0` before computing cost.
- If invalid, return 400 with a clear error.

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



Remediation recommended

13. Calendar uses local midnight 🐞 Bug ≡ Correctness ⭐ New
Description
getTomorrowKSTDate()가 로컬 타임존 자정 Date를 만들어 캘린더 비활성화 기준이 KST가 아닌 로컬 기준이 되고, 또한 선택된 날짜를 로컬 기준으로
'yyyy-MM-dd' 포맷해 서버의 KST 기준 검증과 날짜가 어긋날 수 있습니다.
Code

apps/web/src/utils/kst.ts[R14-17]

+export function getTomorrowKSTDate(): Date {
+  const [y, m, d] = getTomorrowKST().split('-').map(Number);
+  return new Date(y, m - 1, d);
+}
Evidence
getTomorrowKSTDate()new Date(y, m-1, d)로 로컬 자정 Date를 생성합니다. SongPromotionModal은 이 값을
disabled={{ before: tomorrowKST }}로 사용하고, 서버로 보낼 start_date/end_date도 로컬 Date를 `format(...,
'yyyy-MM-dd')`로 직렬화하므로, KST 기준 날짜와 불일치가 발생할 수 있습니다.

apps/web/src/utils/kst.ts[13-17]
apps/web/src/components/SongPromotionModal.tsx[60-70]
apps/web/src/components/SongPromotionModal.tsx[168-173]
apps/web/src/app/api/songs/promotions/route.ts[83-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
클라이언트에서 날짜 선택/직렬화가 로컬 타임존 기준으로 동작해, 서버의 KST 기준 검증과 날짜가 어긋날 수 있습니다.

### Issue Context
서버는 `getTomorrowKST()`(KST 기준)로 `start_date`를 검증합니다. 클라이언트가 로컬 기준으로 날짜 문자열을 만들면 KST 기준 '내일'과 달라질 수 있습니다.

### Fix Focus Areas
- apps/web/src/utils/kst.ts[13-17]
- apps/web/src/components/SongPromotionModal.tsx[60-70]
- apps/web/src/components/SongPromotionModal.tsx[168-173]

### Suggested fix approach
- 선택된 Date를 서버로 보낼 때 KST 기준 문자열로 변환하는 유틸을 추가하세요(예: `formatKSTDate(date): addHours(date, 9).toISOString().slice(0,10)` 같은 방식으로 epoch 기반 KST day를 계산).
- Calendar disable도 단순 `before: Date` 대신, `disabled={(date) => formatKSTDate(date) < getTomorrowKST()}`처럼 KST 기준으로 비교하도록 변경하세요(react-day-picker는 disabled에 함수도 허용).
- 이렇게 하면 사용자의 로컬 타임존과 무관하게 KST 기준으로 선택 제한/전송이 일치합니다.

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


14. Root CLAUDE.md not updated 📘 Rule violation ⚙ Maintainability
Description
This PR changes .claude/commands/ and .claude/settings.json, but does not update the repository
guidance in the root CLAUDE.md accordingly. This violates the requirement to keep CLAUDE.md in
sync with workflow/rules changes.
Code

.claude/commands/start.md[R25-40]

+   이슈 본문은 아래 구조로 작성한다:
+   - 작업 개요 설명 (1~3줄)
+   - `## 작업 체크리스트` 섹션: 구현할 항목을 GitHub Flavored Markdown checkbox(`- [ ]`)로 나열
+   - 완료된 항목은 `- [x]`로 표시 (이슈 생성 시점엔 모두 `- [ ]`)
+

gh issue create --title "<이슈 제목>" --body "<이슈 본문>"


생성된 이슈 번호를 추출한다.

+   작업이 진행되면서 항목이 완료될 때마다 아래 명령으로 이슈 본문을 갱신한다:
+
+   ```
+   gh issue edit <번호> --body "<갱신된 본문>"
+   ```
Evidence
PR Compliance ID 8 requires updating CLAUDE.md when .claude/commands/ changes; the PR updates
/start workflow instructions in .claude/commands/start.md and also changes
.claude/settings.json, but no corresponding update is made to the root CLAUDE.md workflow/rules
documentation.

CLAUDE.md
.claude/commands/start.md[25-40]
.claude/settings.json[1-5]
CLAUDE.md[108-117]

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

## Issue description
Repository workflow/rules files under `.claude/` were changed, but the root `CLAUDE.md` was not updated to reflect the new workflow guidance.
## Issue Context
`CLAUDE.md` explicitly states it must be updated when `.claude/commands/` changes.
## Fix Focus Areas
- .claude/commands/start.md[23-40]
- .claude/settings.json[1-5]
- CLAUDE.md[61-117]

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


15. Whitespace comments accepted 🐞 Bug ≡ Correctness
Description
POST /api/songs/comments does not trim content or reject whitespace-only strings, so requests like
content="   " pass validation and insert effectively empty comments. This makes server-side
validation inconsistent with the client, and the API is not robust against non-UI callers.
Code

apps/web/src/app/api/songs/comments/route.ts[R48-67]

+    const { song_id, content } = await request.json();
+
+    if (!song_id || !content) {
+      return NextResponse.json(
+        { success: false, error: 'song_id and content are required' },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 100) {
+      return NextResponse.json(
+        { success: false, error: '댓글은 100자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const { error } = await supabase
+      .from('song_comments')
+      .insert({ song_id, user_id: userId, content });
+
Evidence
The server only checks !content and content.length, which does not catch whitespace-only
strings; it inserts the raw content. The client trims before posting, showing the API currently
relies on the UI for correctness.

apps/web/src/app/api/songs/comments/route.ts[48-67]
apps/web/src/components/SongCommentSection.tsx[30-42]

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

## Issue description
Server allows whitespace-only comments because it does not trim or validate non-empty content.
## Issue Context
Client currently trims, but API should be self-contained.
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[48-67]
## Suggested fix
- Convert to:
- `const trimmed = typeof content === 'string' ? content.trim() : ''`
- validate `trimmed.length > 0`
- validate `trimmed.length <= 100`
- insert `trimmed`

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



Advisory comments

16. Debug log in DELETE 🐞 Bug ⚙ Maintainability ⭐ New
Description
DELETE /api/songs/comments에서 삭제 결과를 console.log로 출력하고 있어 프로덕션 로그 노이즈를 만들고 불필요한 내부 상태 노출 위험을 키웁니다.
Code

apps/web/src/app/api/songs/comments/route.ts[R94-103]

+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
Evidence
삭제 API 내부에서 console.log('data', data, 'count', count);를 남겨둔 상태입니다. 운영 환경에서는 불필요한 로그로 디버깅을 방해할 수
있습니다.

apps/web/src/app/api/songs/comments/route.ts[94-103]

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

### Issue description
댓글 삭제 API에 디버그 로그가 남아 있습니다.

### Issue Context
서버 라우트의 console.log는 운영 로그에 그대로 남아 노이즈가 됩니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[101-101]

### Suggested fix approach
- 해당 `console.log(...)` 라인을 제거하거나, 필요하다면 `NODE_ENV !== 'production'`에서만 출력되도록 가드하세요.

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


17. Leftover debug console.log 🐞 Bug ⚙ Maintainability
Description
DELETE /api/songs/comments contains a console.log of deletion results, creating noisy logs in
production and making it harder to spot real errors. This should be removed or replaced with
structured logging at an appropriate level.
Code

apps/web/src/app/api/songs/comments/route.ts[R94-104]

+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
+    }
Evidence
The delete handler logs every deletion attempt regardless of outcome, which is not part of the
endpoint’s behavior and will clutter server logs.

apps/web/src/app/api/songs/comments/route.ts[94-104]

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

## Issue description
Unconditional `console.log` remains in the comments delete API.
## Issue Context
This runs on the server for every delete request.
## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[94-104]
## Suggested fix
- Remove the `console.log`.
- If visibility is needed, replace with a structured log only on unexpected conditions (e.g., errors), without logging sensitive payloads.

ⓘ 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 song comments and promotion (billboard) features with date-based scheduling and point system

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Implements comprehensive song comments system with CRUD API endpoints, React Query hooks, and UI
  components for authenticated users (max 100 chars per comment)
• Adds song promotion feature with date-range selection, point-based system (50P per day), and KST
  timezone handling for scheduling promotions
• Creates PromotionBanner component that displays active promotions in a scrolling carousel
  (3-second intervals) with smooth animations
• Integrates promotion and comment features into SearchResultCard with modal dialogs and
  expandable sections
• Adds calendar UI component built on react-day-picker for date range selection in promotion modal
• Implements KST timezone utility functions (getTodayKST(), getTomorrowKST(),
  getTomorrowKSTDate()) for consistent date calculations independent of server timezone
• Includes optimistic delete for comments with cache rollback on error
• Fixes OpenAI client initialization in chat API by moving it into request handler for improved
  security
• Updates dependencies with radix-ui, react-day-picker, and date handling libraries
• Enhances documentation for GitHub issue tracking workflow and shadcn/ui component modification
  guidelines
Diagram
flowchart LR
  A["Song Comments API<br/>GET/POST/DELETE"] --> B["React Query Hooks<br/>useSongCommentsQuery"]
  B --> C["SongCommentSection<br/>Component"]
  C --> D["SearchResultCard"]
  
  E["Song Promotions API<br/>GET/POST"] --> F["React Query Hooks<br/>useSongPromotionsQuery"]
  F --> G["PromotionBanner<br/>Scrolling Display"]
  F --> H["SongPromotionModal<br/>Date Picker"]
  H --> D
  
  I["KST Utils<br/>Timezone Handling"] --> E
  I --> H
  
  J["Calendar Component<br/>Date Range Picker"] --> H
  
  D --> K["Global Layout<br/>with Banner"]
  G --> K
Loading

Grey Divider

File Changes

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

Song comments API endpoints with CRUD operations

• Implements three API endpoints for song comments: GET to fetch comments by songId, POST to
 create new comments (auth required, max 100 chars), and DELETE to remove user's own comments
• Fetches comments with user nicknames from Supabase, ordered by creation date descending
• Includes validation for required fields, content length limits, and authentication checks with
 appropriate error responses

apps/web/src/app/api/songs/comments/route.ts


2. apps/web/src/app/api/songs/promotions/route.ts ✨ Enhancement +151/-0

Song promotion API with date-based filtering and point system

• Implements GET endpoint to retrieve active song promotions within KST date range with song and
 user details
• Implements POST endpoint to create promotions with validation for content length (50 chars max),
 date constraints (start date must be tomorrow or later), and point deduction (50P per day)
• Validates user has sufficient points before creating promotion and updates user point balance on
 success

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


3. apps/web/src/lib/api/songComment.ts ✨ Enhancement +21/-0

API client functions for song comments

• Provides three API client functions: getSongComments to fetch comments by song ID,
 postSongComment to create comments, and deleteSongComment to remove comments
• Wraps HTTP calls to /songs/comments endpoint with proper request/response typing

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


View more (19)
4. apps/web/src/lib/api/songPromotion.ts ✨ Enhancement +19/-0

API client functions for song promotions

• Provides two API client functions: getSongPromotions to fetch active promotions and
 postSongPromotion to create new promotions
• Wraps HTTP calls to /songs/promotions endpoint with proper request/response typing

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


5. apps/web/src/queries/songCommentQuery.ts ✨ Enhancement +56/-0

React Query hooks for song comment operations

• Implements three React Query hooks: useSongCommentsQuery to fetch comments,
 usePostSongCommentMutation to create comments with cache invalidation, and
 useDeleteSongCommentMutation with optimistic delete
• Optimistic delete updates cache immediately before server confirmation, with rollback on error
• Includes toast notifications for success and error states

apps/web/src/queries/songCommentQuery.ts


6. apps/web/src/queries/songPromotionQuery.ts ✨ Enhancement +31/-0

React Query hooks for song promotion operations

• Implements two React Query hooks: useSongPromotionsQuery to fetch active promotions with
 1-minute stale time, and usePostSongPromotionMutation to create promotions
• Invalidates both promotion and user check-in queries on successful promotion creation
• Includes toast notifications for success and error states

apps/web/src/queries/songPromotionQuery.ts


7. apps/web/src/utils/kst.ts ✨ Enhancement +17/-0

KST timezone utility functions for date calculations

• Provides three utility functions for KST timezone handling: getTodayKST() returns today's date
 as 'YYYY-MM-DD' string, getTomorrowKST() returns tomorrow's date string, and
 getTomorrowKSTDate() returns tomorrow as a Date object for calendar disabled prop
• All functions add 9 hours to UTC to calculate KST time independent of server timezone

apps/web/src/utils/kst.ts


8. apps/web/src/types/comment.ts ✨ Enhancement +8/-0

TypeScript interface for song comments

• Defines SongComment interface with fields: id, song_id, user_id, nickname, content,
 and created_at

apps/web/src/types/comment.ts


9. apps/web/src/types/promotion.ts ✨ Enhancement +13/-0

TypeScript interface for song promotions

• Defines SongPromotion interface with fields for promotion metadata (id, song_id, user_id,
 nickname), song details (title, artist, title_ko, artist_ko), and promotion info
 (content, start_date, end_date)

apps/web/src/types/promotion.ts


10. apps/web/src/components/SongPromotionModal.tsx ✨ Enhancement +205/-0

Song promotion modal with date picker and confirmation

• Modal component for creating song promotions with date range picker using react-day-picker
 Calendar
• Two-step flow: date/content selection then confirmation screen showing cost calculation (50P per
 day) and point validation
• Displays song info, promotion content textarea (max 50 chars), date range selection, and point
 deduction summary

apps/web/src/components/SongPromotionModal.tsx


11. apps/web/src/app/search/SearchResultCard.tsx ✨ Enhancement +44/-12

Search result card with promotion and comment features

• Adds Megaphone icon button for song promotion with auth check
• Integrates SongCommentSection component into expanded card view
• Adds SongPromotionModal dialog for promotion creation
• Refactors auth checks into reusable withAuth helper function
• Updates star icon styling to use fill-current class

apps/web/src/app/search/SearchResultCard.tsx


12. apps/web/src/app/api/chat/route.ts 🐞 Bug fix +4/-4

OpenAI client initialization moved to request handler

• Moves OpenAI client initialization from module level into the POST handler function
• Ensures API key is accessed only when the endpoint is called, improving security and lazy
 initialization

apps/web/src/app/api/chat/route.ts


13. pnpm-lock.yaml Dependencies +682/-2

Dependencies for UI components and date picking

• Adds radix-ui package (v1.4.3) as a dependency with all its component subpackages
• Adds react-day-picker package (v9.14.0) for calendar date range selection
• Adds supporting dependencies: @date-fns/tz, date-fns-jalali, and @tabby_ai/hijri-converter
 for date handling

pnpm-lock.yaml


14. .claude/settings.json ⚙️ Configuration changes +4/-0

Claude settings configuration update

• Adds permissions configuration with defaultMode set to bypassPermissions

.claude/settings.json


15. apps/web/src/components/ui/calendar.tsx ✨ Enhancement +180/-0

Calendar UI component for date range selection

• New calendar component built on react-day-picker with customizable styling and date range
 selection support
• Implements Calendar and CalendarDayButton components with Tailwind CSS classes for various
 date states (selected, range, disabled, etc.)
• Supports month/year dropdowns, navigation controls, and week number display with RTL support
• Integrates with project's design system using buttonVariants and cn utility for className
 merging

apps/web/src/components/ui/calendar.tsx


16. apps/web/src/components/SongCommentSection.tsx ✨ Enhancement +107/-0

Song comment display and management component

• New component for displaying and managing song comments with authentication checks
• Implements comment submission with 100-character limit validation and optimistic UI updates
• Displays comment list with user nickname, content, creation date, and delete button (only for
 comment author)
• Integrates with useSongCommentsQuery, usePostSongCommentMutation, and
 useDeleteSongCommentMutation hooks

apps/web/src/components/SongCommentSection.tsx


17. apps/web/src/components/PromotionBanner.tsx ✨ Enhancement +66/-0

Scrolling promotion banner for active song promotions

• New promotional banner component that cycles through active song promotions every 3 seconds
• Displays promotion details including song title/artist (with Korean fallback), user nickname, and
 promotion period
• Uses Framer Motion for smooth vertical scroll animations between promotions
• Fetches active promotions via useSongPromotionsQuery hook and handles empty state

apps/web/src/components/PromotionBanner.tsx


18. apps/web/public/sitemap-0.xml ⚙️ Configuration changes +2/-2

Sitemap timestamp update

• Updates sitemap timestamps from 2026-05-02T15:28:30.385Z to 2026-05-04T08:44:32.892Z
• Reflects regenerated sitemap with current build timestamp

apps/web/public/sitemap-0.xml


19. apps/web/package.json Dependencies +2/-0

Add calendar and UI library dependencies

• Adds radix-ui (v1.4.3) dependency for UI component primitives
• Adds react-day-picker (v9.14.0) dependency for calendar functionality

apps/web/package.json


20. apps/web/src/app/layout.tsx ✨ Enhancement +7/-0

Global promotion banner layout integration

• Imports and globally places PromotionBanner component in fixed bottom-right position
• Wraps banner in fixed positioning container with pointer-events management for proper interaction
 handling
• Banner positioned at z-40 with max-width constraint and responsive padding

apps/web/src/app/layout.tsx


21. .claude/commands/start.md 📝 Documentation +11/-0

GitHub issue creation and tracking workflow documentation

• Adds detailed GitHub Issue creation guidelines with structured format (overview, checklist
 sections)
• Documents issue body update workflow using gh issue edit command to track progress
• Specifies checkbox format (- [ ] and - [x]) for tracking completed work items

.claude/commands/start.md


22. apps/web/CLAUDE.md 📝 Documentation +1/-1

Clarify shadcn/ui component modification guidelines

• Updates shadcn/ui components documentation to clarify modification policy
• Allows project-wide convention updates (import paths, project conventions) after confirmation,
 while prohibiting functional changes

apps/web/CLAUDE.md


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 8, 2026

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (2) 📎 Requirement gaps (3)

Context used

Grey Divider


Action required

1. No song_comments migration added 📎 Requirement gap ☼ Reliability
Description
New comment APIs depend on the song_comments table, but this PR does not add any schema/migration
code proving the required columns/constraints/index exist. This risks runtime failures or policy
drift from the required Supabase schema spec.
Code

apps/web/src/app/api/songs/comments/route.ts[R19-23]

+    const { data, error } = await supabase
+      .from('song_comments')
+      .select('id, song_id, user_id, content, created_at, users(nickname)')
+      .eq('song_id', songId)
+      .order('created_at', { ascending: false });
Evidence
PR Compliance ID 1 requires a song_comments table with specific constraints and an index, but the
PR only adds code that queries/inserts/deletes from song_comments without adding any
table-creation/migration artifacts in-repo to satisfy the required schema.

Supabase song_comments table is created per spec
apps/web/src/app/api/songs/comments/route.ts[19-23]

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 PR introduces comment APIs that use `song_comments`, but there is no in-repo Supabase migration/schema change ensuring the table exists with the required columns, FK/constraints, and `song_id` index.

## Issue Context
Compliance requires `song_comments` to match the defined schema (UUID PK default `gen_random_uuid()`, FK to `auth.users(id)` with cascade, `char_length(content) <= 100` check, `created_at default now()`, and an index on `song_id`).

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[19-67]
- supabase/migrations/20260508_create_song_comments.sql[1-200]

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


2. No song_promotions migration added 📎 Requirement gap ☼ Reliability
Description
New promotion APIs depend on the song_promotions table, but this PR does not add any
schema/migration code proving the required table structure and end_date index exist. This can
break promotion reads/writes or violate the defined DB design.
Code

apps/web/src/app/api/songs/promotions/route.ts[R15-23]

+    const { data, error } = await supabase
+      .from('song_promotions')
+      .select(
+        'id, song_id, user_id, content, start_date, end_date, users(nickname), songs(title, artist, title_ko, artist_ko)',
+      )
+      .lte('start_date', todayKST)
+      .gte('end_date', todayKST)
+      .order('end_date', { ascending: false });
+
Evidence
PR Compliance ID 2 requires public.song_promotions to exist with specific FK + end_date index,
but the PR only adds API code that queries/inserts into song_promotions without adding any in-repo
schema/migration to establish the required table definition.

Supabase song_promotions table exists and matches the defined schema
apps/web/src/app/api/songs/promotions/route.ts[15-23]

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 PR adds promotion APIs that use `song_promotions`, but no migration/schema change is included to guarantee the table matches the required schema (including FK to `songs(id)` with `on delete cascade`) and the `end_date` index.

## Issue Context
Compliance requires `song_promotions` to have the defined columns and an index on `end_date`.

## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[15-26]
- apps/web/src/app/api/songs/promotions/route.ts[120-136]
- supabase/migrations/20260508_create_song_promotions.sql[1-250]

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


3. DELETE comments not /[id] 📎 Requirement gap ≡ Correctness
Description
The PR implements comment deletion on /api/songs/comments using a JSON body commentId, not on
/api/songs/comments/[id] as required. This fails the specified API contract in the compliance
checklist.
Code

apps/web/src/app/api/songs/comments/route.ts[R83-92]

+export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
+  try {
+    const supabase = await createClient();
+    const userId = await getAuthenticatedUser(supabase);
+
+    const { commentId } = await request.json();
+
+    if (!commentId || typeof commentId !== 'string') {
+      return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
+    }
Evidence
PR Compliance ID 3 explicitly requires DELETE /api/songs/comments/[id], but the added route
handler defines DELETE in apps/web/src/app/api/songs/comments/route.ts, meaning the endpoint is
/api/songs/comments and takes commentId from the request body.

Comment APIs enforce authentication and ownership rules
apps/web/src/app/api/songs/comments/route.ts[83-92]
apps/web/src/lib/api/songComment.ts[18-20]

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 comment API is implemented at `/api/songs/comments` with a body `commentId`, but compliance requires `DELETE /api/songs/comments/[id]`.

## Issue Context
This is an API contract requirement in the compliance checklist; client code should call the `[id]` route.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-20]
- apps/web/src/app/api/songs/comments/[id]/route.ts[1-200]

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


View more (4)
4. Added ui/calendar.tsx 📘 Rule violation ⚙ Maintainability
Description
This PR adds a new file under apps/web/src/components/ui/, which is disallowed for shadcn/ui
vendor components. Direct edits/additions in this directory reduce upgradability and violate the
no-modification rule.
Code

apps/web/src/components/ui/calendar.tsx[R1-10]

+'use client';
+
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
+import * as React from 'react';
+import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
+
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/utils/cn';
+
+function Calendar({
Evidence
PR Compliance ID 7 forbids any modifications/additions under src/components/ui/. The PR introduces
apps/web/src/components/ui/calendar.tsx, which is within that restricted directory.

CLAUDE.md
apps/web/src/components/ui/calendar.tsx[1-10]

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

## Issue description
A new component was added under `apps/web/src/components/ui/`, which is a restricted shadcn/ui directory per compliance.

## Issue Context
To keep shadcn/ui upgradable, project-specific components/wrappers should live outside `src/components/ui/` (e.g., `src/components/`), or be implemented as separate wrappers that import from shadcn/ui without modifying that directory.

## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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


5. Banner index out-of-range 🐞 Bug ≡ Correctness
Description
PromotionBanner accesses promotions[currentIndex] without clamping when the promotions list shrinks,
so current can become undefined and the component will throw while rendering
current.title_ko/current.artist_ko. This can happen after a refetch because the promotions query is
invalidated on promotion creation.
Code

apps/web/src/components/PromotionBanner.tsx[R21-27]

+  if (promotions.length === 0) return null;
+
+  const current = promotions[currentIndex];
+  const displayTitle =
+    current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
+  const displayArtist =
+    current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
Evidence
PromotionBanner calculates current from promotions[currentIndex] and immediately dereferences it,
but currentIndex is only advanced modulo the *current* promotions.length and is never reset when
length decreases; a shrink from N>1 to 1 while currentIndex>0 will make promotions[currentIndex]
undefined. The promotions list can change at runtime because the promotion mutation invalidates the
query, triggering a refetch with a different-length array.

apps/web/src/components/PromotionBanner.tsx[13-27]
apps/web/src/queries/songPromotionQuery.ts[18-25]

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

## Issue description
`PromotionBanner` can crash when the promotions array shrinks because `currentIndex` is not clamped to the new array length.

## Issue Context
The query is invalidated after promotion creation, so the fetched list can change length.

## Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-27]

## Suggested fix
- Add an effect that runs when `promotions.length` changes:
 - If `promotions.length === 0`, set `currentIndex` to 0.
 - If `currentIndex >= promotions.length`, set `currentIndex` to 0 (or `promotions.length - 1`).
- Alternatively, compute `const safeIndex = promotions.length ? currentIndex % promotions.length : 0` before indexing.

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


6. Promotion point update non-atomic 🐞 Bug ☼ Reliability
Description
POST /api/songs/promotions inserts a promotion and then deducts points in a separate DB update, so a
failure after insert (or concurrent requests) can create promotions without the intended point
deduction and/or allow overspending. This causes persistent data inconsistency because there is no
transaction/rollback.
Code

apps/web/src/app/api/songs/promotions/route.ts[R120-135]

+    const { error: insertError } = await supabase.from('song_promotions').insert({
+      song_id,
+      user_id: userId,
+      content,
+      start_date,
+      end_date,
+    });
+
+    if (insertError) throw insertError;
+
+    const { error: pointError } = await supabase
+      .from('users')
+      .update({ point: userData.point - cost })
+      .eq('id', userId);
+
+    if (pointError) throw pointError;
Evidence
The handler performs an insert into song_promotions and only afterwards updates users.point; any
error during the second step results in a thrown error after the promotion row already exists.
Additionally, reading the point balance and later writing a derived balance without locking enables
race conditions under concurrent requests.

apps/web/src/app/api/songs/promotions/route.ts[102-136]

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

## Issue description
Promotion creation and point deduction are performed as two separate operations, which can leave the system in an inconsistent state.

## Issue Context
This endpoint is part of the point economy; it must be correct under failures and concurrency.

## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[102-136]

## Suggested fix
- Move the logic into a single atomic DB operation, e.g.:
 - a Postgres function (Supabase RPC) that:
   - `SELECT ... FOR UPDATE` the user's point row
   - verifies sufficient points
   - inserts into `song_promotions`
   - updates `users.point = users.point - cost`
   - returns success/failure
- If RPC is not possible immediately, at minimum:
 - perform the point deduction first with a conditional update (only if point >= cost) and check affected row count,
 - and only then insert the promotion (still not fully atomic, but reduces free-promotion cases).

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


7. Promotion dates not validated 🐞 Bug ≡ Correctness
Description
POST /api/songs/promotions uses start_date/end_date directly from request.json for string
comparisons and date math; malformed values can produce NaN for days/cost and make the
userData.point < cost guard ineffective. This lets invalid requests proceed to promotion
insert/point update with an undefined cost basis.
Code

apps/web/src/app/api/songs/promotions/route.ts[R64-101]

+    const { song_id, content, start_date, end_date } = await request.json();
+
+    if (!song_id || !content || !start_date || !end_date) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: 'song_id, content, start_date, end_date are required',
+        },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 50) {
+      return NextResponse.json(
+        { success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const tomorrowKST = getTomorrowKST();
+
+    if (start_date < tomorrowKST) {
+      return NextResponse.json(
+        { success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    if (end_date < start_date) {
+      return NextResponse.json(
+        { success: false, error: '종료일은 시작일 이후여야 합니다.' },
+        { status: 400 },
+      );
+    }
+
+    const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
+    const cost = days * 50;
+
Evidence
The handler does not type/format-check start_date and end_date before new Date(...) and
differenceInCalendarDays(...). If the dates are invalid, days becomes NaN and `userData.point <
cost` will never be true (because comparisons with NaN are false), so insufficient-point validation
is skipped.

apps/web/src/app/api/songs/promotions/route.ts[64-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
`start_date`/`end_date` are not validated, allowing invalid dates to bypass the point check due to NaN cost calculations.

## Issue Context
This endpoint expects `YYYY-MM-DD` strings.

## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-118]

## Suggested fix
- Validate input types:
 - `typeof start_date === 'string' && typeof end_date === 'string'`
- Validate format strictly (e.g. `/^\d{4}-\d{2}-\d{2}$/`).
- Parse using a strict parser (or at least validate `Number.isFinite(date.getTime())`).
- After computing `days`, assert `Number.isFinite(days) && days > 0` before computing cost.
- If invalid, return 400 with a clear error.

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



Remediation recommended

8. Root CLAUDE.md not updated 📘 Rule violation ⚙ Maintainability
Description
This PR changes .claude/commands/ and .claude/settings.json, but does not update the repository
guidance in the root CLAUDE.md accordingly. This violates the requirement to keep CLAUDE.md in
sync with workflow/rules changes.
Code

.claude/commands/start.md[R25-40]

+   이슈 본문은 아래 구조로 작성한다:
+   - 작업 개요 설명 (1~3줄)
+   - `## 작업 체크리스트` 섹션: 구현할 항목을 GitHub Flavored Markdown checkbox(`- [ ]`)로 나열
+   - 완료된 항목은 `- [x]`로 표시 (이슈 생성 시점엔 모두 `- [ ]`)
+

gh issue create --title "<이슈 제목>" --body "<이슈 본문>"


생성된 이슈 번호를 추출한다.

+   작업이 진행되면서 항목이 완료될 때마다 아래 명령으로 이슈 본문을 갱신한다:
+
+   ```
+   gh issue edit <번호> --body "<갱신된 본문>"
+   ```
Evidence
PR Compliance ID 8 requires updating CLAUDE.md when .claude/commands/ changes; the PR updates
/start workflow instructions in .claude/commands/start.md and also changes
.claude/settings.json, but no corresponding update is made to the root CLAUDE.md workflow/rules
documentation.

CLAUDE.md
.claude/commands/start.md[25-40]
.claude/settings.json[1-5]
CLAUDE.md[108-117]

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

## Issue description
Repository workflow/rules files under `.claude/` were changed, but the root `CLAUDE.md` was not updated to reflect the new workflow guidance.

## Issue Context
`CLAUDE.md` explicitly states it must be updated when `.claude/commands/` changes.

## Fix Focus Areas
- .claude/commands/start.md[23-40]
- .claude/settings.json[1-5]
- CLAUDE.md[61-117]

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


9. Whitespace comments accepted 🐞 Bug ≡ Correctness
Description
POST /api/songs/comments does not trim content or reject whitespace-only strings, so requests like
content="   " pass validation and insert effectively empty comments. This makes server-side
validation inconsistent with the client, and the API is not robust against non-UI callers.
Code

apps/web/src/app/api/songs/comments/route.ts[R48-67]

+    const { song_id, content } = await request.json();
+
+    if (!song_id || !content) {
+      return NextResponse.json(
+        { success: false, error: 'song_id and content are required' },
+        { status: 400 },
+      );
+    }
+
+    if (content.length > 100) {
+      return NextResponse.json(
+        { success: false, error: '댓글은 100자 이내로 작성해주세요.' },
+        { status: 400 },
+      );
+    }
+
+    const { error } = await supabase
+      .from('song_comments')
+      .insert({ song_id, user_id: userId, content });
+
Evidence
The server only checks !content and content.length, which does not catch whitespace-only
strings; it inserts the raw content. The client trims before posting, showing the API currently
relies on the UI for correctness.

apps/web/src/app/api/songs/comments/route.ts[48-67]
apps/web/src/components/SongCommentSection.tsx[30-42]

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

## Issue description
Server allows whitespace-only comments because it does not trim or validate non-empty content.

## Issue Context
Client currently trims, but API should be self-contained.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[48-67]

## Suggested fix
- Convert to:
 - `const trimmed = typeof content === 'string' ? content.trim() : ''`
 - validate `trimmed.length > 0`
 - validate `trimmed.length <= 100`
 - insert `trimmed`

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



Advisory comments

10. Leftover debug console.log 🐞 Bug ⚙ Maintainability
Description
DELETE /api/songs/comments contains a console.log of deletion results, creating noisy logs in
production and making it harder to spot real errors. This should be removed or replaced with
structured logging at an appropriate level.
Code

apps/web/src/app/api/songs/comments/route.ts[R94-104]

+    const { data, error, count } = await supabase
+      .from('song_comments')
+      .delete({ count: 'exact' })
+      .match({ id: commentId, user_id: userId });
+
+    if (error) throw error;
+
+    console.log('data', data, 'count', count);
+    if (count === 0) {
+      return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
+    }
Evidence
The delete handler logs every deletion attempt regardless of outcome, which is not part of the
endpoint’s behavior and will clutter server logs.

apps/web/src/app/api/songs/comments/route.ts[94-104]

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

## Issue description
Unconditional `console.log` remains in the comments delete API.

## Issue Context
This runs on the server for every delete request.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[94-104]

## Suggested fix
- Remove the `console.log`.
- If visibility is needed, replace with a structured log only on unexpected conditions (e.g., errors), without logging sensitive payloads.

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


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

PR Description updated to latest commit (bee96d6)

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

Comment on lines +19 to +23
const { data, error } = await supabase
.from('song_comments')
.select('id, song_id, user_id, content, created_at, users(nickname)')
.eq('song_id', songId)
.order('created_at', { ascending: 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.

Action required

1. No song_comments migration added 📎 Requirement gap ☼ Reliability

New comment APIs depend on the song_comments table, but this PR does not add any schema/migration
code proving the required columns/constraints/index exist. This risks runtime failures or policy
drift from the required Supabase schema spec.
Agent Prompt
## Issue description
The PR introduces comment APIs that use `song_comments`, but there is no in-repo Supabase migration/schema change ensuring the table exists with the required columns, FK/constraints, and `song_id` index.

## Issue Context
Compliance requires `song_comments` to match the defined schema (UUID PK default `gen_random_uuid()`, FK to `auth.users(id)` with cascade, `char_length(content) <= 100` check, `created_at default now()`, and an index on `song_id`).

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[19-67]
- supabase/migrations/20260508_create_song_comments.sql[1-200]

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

Comment on lines +15 to +23
const { data, error } = await supabase
.from('song_promotions')
.select(
'id, song_id, user_id, content, start_date, end_date, users(nickname), songs(title, artist, title_ko, artist_ko)',
)
.lte('start_date', todayKST)
.gte('end_date', todayKST)
.order('end_date', { ascending: 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.

Action required

2. No song_promotions migration added 📎 Requirement gap ☼ Reliability

New promotion APIs depend on the song_promotions table, but this PR does not add any
schema/migration code proving the required table structure and end_date index exist. This can
break promotion reads/writes or violate the defined DB design.
Agent Prompt
## Issue description
The PR adds promotion APIs that use `song_promotions`, but no migration/schema change is included to guarantee the table matches the required schema (including FK to `songs(id)` with `on delete cascade`) and the `end_date` index.

## Issue Context
Compliance requires `song_promotions` to have the defined columns and an index on `end_date`.

## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[15-26]
- apps/web/src/app/api/songs/promotions/route.ts[120-136]
- supabase/migrations/20260508_create_song_promotions.sql[1-250]

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

Comment on lines +83 to +92
export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
try {
const supabase = await createClient();
const userId = await getAuthenticatedUser(supabase);

const { commentId } = await request.json();

if (!commentId || typeof commentId !== 'string') {
return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
}
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

3. Delete comments not /[id] 📎 Requirement gap ≡ Correctness

The PR implements comment deletion on /api/songs/comments using a JSON body commentId, not on
/api/songs/comments/[id] as required. This fails the specified API contract in the compliance
checklist.
Agent Prompt
## Issue description
The DELETE comment API is implemented at `/api/songs/comments` with a body `commentId`, but compliance requires `DELETE /api/songs/comments/[id]`.

## Issue Context
This is an API contract requirement in the compliance checklist; client code should call the `[id]` route.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-20]
- apps/web/src/app/api/songs/comments/[id]/route.ts[1-200]

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

Comment on lines +1 to +10
'use client';

import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import * as React from 'react';
import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';

import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';

function Calendar({
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

4. Added ui/calendar.tsx 📘 Rule violation ⚙ Maintainability

This PR adds a new file under apps/web/src/components/ui/, which is disallowed for shadcn/ui
vendor components. Direct edits/additions in this directory reduce upgradability and violate the
no-modification rule.
Agent Prompt
## Issue description
A new component was added under `apps/web/src/components/ui/`, which is a restricted shadcn/ui directory per compliance.

## Issue Context
To keep shadcn/ui upgradable, project-specific components/wrappers should live outside `src/components/ui/` (e.g., `src/components/`), or be implemented as separate wrappers that import from shadcn/ui without modifying that directory.

## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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

Comment on lines +21 to +27
if (promotions.length === 0) return null;

const current = promotions[currentIndex];
const displayTitle =
current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
const displayArtist =
current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
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

5. Banner index out-of-range 🐞 Bug ≡ Correctness

PromotionBanner accesses promotions[currentIndex] without clamping when the promotions list shrinks,
so current can become undefined and the component will throw while rendering
current.title_ko/current.artist_ko. This can happen after a refetch because the promotions query is
invalidated on promotion creation.
Agent Prompt
## Issue description
`PromotionBanner` can crash when the promotions array shrinks because `currentIndex` is not clamped to the new array length.

## Issue Context
The query is invalidated after promotion creation, so the fetched list can change length.

## Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-27]

## Suggested fix
- Add an effect that runs when `promotions.length` changes:
  - If `promotions.length === 0`, set `currentIndex` to 0.
  - If `currentIndex >= promotions.length`, set `currentIndex` to 0 (or `promotions.length - 1`).
- Alternatively, compute `const safeIndex = promotions.length ? currentIndex % promotions.length : 0` before indexing.

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

Comment on lines +120 to +135
const { error: insertError } = await supabase.from('song_promotions').insert({
song_id,
user_id: userId,
content,
start_date,
end_date,
});

if (insertError) throw insertError;

const { error: pointError } = await supabase
.from('users')
.update({ point: userData.point - cost })
.eq('id', userId);

if (pointError) throw pointError;
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

6. Promotion point update non-atomic 🐞 Bug ☼ Reliability

POST /api/songs/promotions inserts a promotion and then deducts points in a separate DB update, so a
failure after insert (or concurrent requests) can create promotions without the intended point
deduction and/or allow overspending. This causes persistent data inconsistency because there is no
transaction/rollback.
Agent Prompt
## Issue description
Promotion creation and point deduction are performed as two separate operations, which can leave the system in an inconsistent state.

## Issue Context
This endpoint is part of the point economy; it must be correct under failures and concurrency.

## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[102-136]

## Suggested fix
- Move the logic into a single atomic DB operation, e.g.:
  - a Postgres function (Supabase RPC) that:
    - `SELECT ... FOR UPDATE` the user's point row
    - verifies sufficient points
    - inserts into `song_promotions`
    - updates `users.point = users.point - cost`
    - returns success/failure
- If RPC is not possible immediately, at minimum:
  - perform the point deduction first with a conditional update (only if point >= cost) and check affected row count,
  - and only then insert the promotion (still not fully atomic, but reduces free-promotion cases).

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

Comment on lines +64 to +101
const { song_id, content, start_date, end_date } = await request.json();

if (!song_id || !content || !start_date || !end_date) {
return NextResponse.json(
{
success: false,
error: 'song_id, content, start_date, end_date are required',
},
{ status: 400 },
);
}

if (content.length > 50) {
return NextResponse.json(
{ success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
{ status: 400 },
);
}

const tomorrowKST = getTomorrowKST();

if (start_date < tomorrowKST) {
return NextResponse.json(
{ success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
{ status: 400 },
);
}

if (end_date < start_date) {
return NextResponse.json(
{ success: false, error: '종료일은 시작일 이후여야 합니다.' },
{ status: 400 },
);
}

const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
const cost = days * 50;

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

7. Promotion dates not validated 🐞 Bug ≡ Correctness

POST /api/songs/promotions uses start_date/end_date directly from request.json for string
comparisons and date math; malformed values can produce NaN for days/cost and make the
userData.point < cost guard ineffective. This lets invalid requests proceed to promotion
insert/point update with an undefined cost basis.
Agent Prompt
## Issue description
`start_date`/`end_date` are not validated, allowing invalid dates to bypass the point check due to NaN cost calculations.

## Issue Context
This endpoint expects `YYYY-MM-DD` strings.

## Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-118]

## Suggested fix
- Validate input types:
  - `typeof start_date === 'string' && typeof end_date === 'string'`
- Validate format strictly (e.g. `/^\d{4}-\d{2}-\d{2}$/`).
- Parse using a strict parser (or at least validate `Number.isFinite(date.getTime())`).
- After computing `days`, assert `Number.isFinite(days) && days > 0` before computing cost.
- If invalid, return 400 with a clear error.

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

Comment on lines +83 to +93
export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
try {
const supabase = await createClient();
const userId = await getAuthenticatedUser(supabase);

const { commentId } = await request.json();

if (!commentId || typeof commentId !== 'string') {
return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
}

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. Comments delete not /[id] 📎 Requirement gap ≡ Correctness

The PR implements comment deletion as DELETE /api/songs/comments with a JSON body (commentId)
instead of the required DELETE /api/songs/comments/[id]. This does not meet the specified API
contract and can break clients/tests expecting the [id] route and ownership enforcement at that
path.
Agent Prompt
## Issue description
Comment deletion is implemented as `DELETE /api/songs/comments` with a request body, but compliance requires `DELETE /api/songs/comments/[id]` and ownership enforcement at that route.

## Issue Context
The checklist (PR Compliance ID 3) explicitly defines the delete endpoint as `DELETE /api/songs/comments/[id]`.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-20]

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

Comment on lines +1 to +25
'use client';

import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import * as React from 'react';
import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';

import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';

function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();

return (
<DayPicker
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

2. Added shadcn ui/calendar.tsx 📘 Rule violation ⚙ Maintainability

This PR adds a new component file under apps/web/src/components/ui/ (calendar.tsx), which is
disallowed by the shadcn/ui vendoring rule. Keeping changes out of src/components/ui/ is required
to preserve updatability and avoid drift from upstream components.
Agent Prompt
## Issue description
A new shadcn-style component file was added under `apps/web/src/components/ui/`, which violates the rule prohibiting direct changes in that directory.

## Issue Context
The calendar is imported by `SongPromotionModal`, but compliance requires keeping `src/components/ui/` untouched.

## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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

Comment on lines +102 to +135
const { data: userData, error: userError } = await supabase
.from('users')
.select('point')
.eq('id', userId)
.single();

if (userError || !userData) throw userError ?? new Error('User not found');

if (userData.point < cost) {
return NextResponse.json(
{
success: false,
error: `포인트가 부족합니다. 필요: ${cost}P, 보유: ${userData.point}P`,
},
{ status: 400 },
);
}

const { error: insertError } = await supabase.from('song_promotions').insert({
song_id,
user_id: userId,
content,
start_date,
end_date,
});

if (insertError) throw insertError;

const { error: pointError } = await supabase
.from('users')
.update({ point: userData.point - cost })
.eq('id', userId);

if (pointError) throw pointError;
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

3. Promotion spend not atomic 🐞 Bug ≡ Correctness

POST /api/songs/promotions가 홍보 insert와 유저 포인트 update를 별도 쿼리로 수행해, 중간 실패 시 홍보만 생성되고 포인트가 차감되지 않을 수
있으며 동시 요청에서 포인트를 초과 사용(overspend)할 수 있습니다.
Agent Prompt
### Issue description
`POST /api/songs/promotions`에서 홍보 생성과 포인트 차감이 분리되어 있어, 부분 실패 또는 동시성 상황에서 포인트 정합성이 깨질 수 있습니다.

### Issue Context
현재 흐름: user point 조회 → promotions insert → users update 입니다. 이 방식은 중간 실패 시 무료 홍보가 생기거나, 동시 요청이 point 체크를 통과해 overspend가 발생할 수 있습니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[99-137]

### Suggested fix approach
- DB 레벨에서 원자적으로 처리되도록 RPC(Stored Procedure)로 `포인트 충분 여부 확인 + 포인트 차감 + promotion insert`를 한 트랜잭션으로 묶어 호출하세요.
- 대안(부분적): `users`를 `update ... where point >= cost` 형태로 조건부 차감하고(영향 row count 확인), 그 다음 insert를 하되 **두 작업은 여전히 트랜잭션으로 묶는 것**이 안전합니다.

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

Comment on lines +64 to +101
const { song_id, content, start_date, end_date } = await request.json();

if (!song_id || !content || !start_date || !end_date) {
return NextResponse.json(
{
success: false,
error: 'song_id, content, start_date, end_date are required',
},
{ status: 400 },
);
}

if (content.length > 50) {
return NextResponse.json(
{ success: false, error: '홍보 내용은 50자 이내로 작성해주세요.' },
{ status: 400 },
);
}

const tomorrowKST = getTomorrowKST();

if (start_date < tomorrowKST) {
return NextResponse.json(
{ success: false, error: '홍보 시작일은 내일 이후여야 합니다.' },
{ status: 400 },
);
}

if (end_date < start_date) {
return NextResponse.json(
{ success: false, error: '종료일은 시작일 이후여야 합니다.' },
{ status: 400 },
);
}

const days = differenceInCalendarDays(new Date(end_date), new Date(start_date)) + 1;
const cost = days * 50;

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

4. Promotion date not validated 🐞 Bug ☼ Reliability

POST /api/songs/promotions가 start_date/end_date 형식을 검증하지 않고 new Date(...)로 파싱해 days/cost가 NaN이 될 수
있으며, 그 결과 포인트 비교/차감 로직이 비정상 동작(500 또는 잘못된 업데이트)할 수 있습니다.
Agent Prompt
### Issue description
`start_date`/`end_date`가 유효한 `YYYY-MM-DD`인지 검증하지 않아, 잘못된 입력이 들어오면 days/cost가 NaN이 되고 이후 포인트 차감 로직이 깨질 수 있습니다.

### Issue Context
프론트엔드가 정상 값을 보내더라도 API는 외부로 노출되어 있으므로 입력 검증이 필요합니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[64-101]

### Suggested fix approach
- `start_date`/`end_date`에 대해 `^\d{4}-\d{2}-\d{2}$` 정규식 체크 후, 파싱 결과가 유효한지(isValid) 확인하세요.
- `days`가 finite number이고 `days >= 1`인지 확인하고, 아니면 400으로 거절하세요.
- 검증 통과 후에만 cost/포인트 차감 로직을 진행하세요.

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

Comment on lines +13 to +27
useEffect(() => {
if (promotions.length <= 1) return;
const timer = setInterval(() => {
setCurrentIndex(prev => (prev + 1) % promotions.length);
}, 3000);
return () => clearInterval(timer);
}, [promotions.length]);

if (promotions.length === 0) return null;

const current = promotions[currentIndex];
const displayTitle =
current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
const displayArtist =
current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
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

5. Banner index can crash 🐞 Bug ☼ Reliability

PromotionBanner에서 promotions 배열이 줄어들었을 때 currentIndex를 보정하지 않아 promotions[currentIndex]가 undefined가
될 수 있고, 이후 current.title_ko 접근에서 런타임 에러로 크래시할 수 있습니다.
Agent Prompt
### Issue description
`PromotionBanner`가 `currentIndex`를 유지한 채 promotions가 줄어들면 out-of-range 인덱싱으로 크래시할 수 있습니다.

### Issue Context
프로모션 목록은 refetch/만료로 언제든지 줄어들 수 있습니다.

### Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-27]

### Suggested fix approach
- promotions가 바뀔 때(또는 length가 바뀔 때) `setCurrentIndex(i => Math.min(i, promotions.length - 1))` 혹은 `if (currentIndex >= promotions.length) setCurrentIndex(0)`로 보정하세요.
- 안전하게 `const current = promotions[currentIndex] ?? promotions[0];` 같은 폴백도 추가하세요.

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

Comment on lines +83 to +107
export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
try {
const supabase = await createClient();
const userId = await getAuthenticatedUser(supabase);

const { commentId } = await request.json();

if (!commentId || typeof commentId !== 'string') {
return NextResponse.json({ success: false, error: 'Missing commentId' }, { status: 400 });
}

const { data, error, count } = await supabase
.from('song_comments')
.delete({ count: 'exact' })
.match({ id: commentId, user_id: userId });

if (error) throw error;

console.log('data', data, 'count', count);
if (count === 0) {
return NextResponse.json({ success: false, error: 'Comment not found' }, { status: 404 });
}

return NextResponse.json({ success: true });
} catch (error) {
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. Delete comments not /[id] 📎 Requirement gap ≡ Correctness

The PR implements comment deletion in /api/songs/comments with a JSON body (commentId) instead
of providing DELETE /api/songs/comments/[id] as required. This fails the specified API contract
for the delete endpoint.
Agent Prompt
## Issue description
Comment deletion is implemented at `/api/songs/comments` using a request body, but compliance requires `DELETE /api/songs/comments/[id]`.

## Issue Context
Next.js route file location determines the URL path. A `route.ts` under `.../comments/` maps to `/api/songs/comments`, while `.../comments/[id]/route.ts` maps to `/api/songs/comments/:id`.

## Fix Focus Areas
- apps/web/src/app/api/songs/comments/route.ts[83-120]
- apps/web/src/lib/api/songComment.ts[18-21]

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

Comment on lines +1 to +20
'use client';

import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import * as React from 'react';
import { type DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';

import { Button, buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';

function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
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

2. ui/calendar.tsx edited directly 📘 Rule violation ⚙ Maintainability

A shadcn/ui-style component was added under src/components/ui/ (calendar.tsx), which violates
the rule forbidding direct edits to vendored shadcn/ui components in that directory. This can
complicate future upstream updates and create maintenance divergence.
Agent Prompt
## Issue description
`apps/web/src/components/ui/calendar.tsx` was added under the shadcn/ui vendored directory, which is disallowed by compliance.

## Issue Context
The project policy requires keeping `src/components/ui/` untouched to avoid breaking shadcn/ui upgrade paths.

## Fix Focus Areas
- apps/web/src/components/ui/calendar.tsx[1-180]
- apps/web/src/components/SongPromotionModal.tsx[12-16]

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

Comment on lines +21 to +27
if (promotions.length === 0) return null;

const current = promotions[currentIndex];
const displayTitle =
current.title_ko && current.title_ko !== current.title ? current.title_ko : current.title;
const displayArtist =
current.artist_ko && current.artist_ko !== current.artist ? current.artist_ko : current.artist;
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

3. Banner index out-of-range 🐞 Bug ≡ Correctness

PromotionBanner가 promotions 목록이 줄어든 뒤에도 기존 currentIndex로 promotions[currentIndex]를 접근해 current가
undefined가 될 수 있으며, 이후 current.title 접근에서 런타임 크래시가 발생합니다. 이 컴포넌트는 layout에 전역으로 렌더되어 페이지 전체가 깨질 수
있습니다.
Agent Prompt
### Issue description
`PromotionBanner`가 `promotions[currentIndex]`를 그대로 사용하여, promotions 배열 길이가 줄면 `current`가 `undefined`가 되어 렌더링 중 크래시가 발생합니다.

### Issue Context
React Query invalidate/refetch로 `promotions`는 런타임에 자주 교체될 수 있고, 이 배너는 `layout.tsx`에 전역으로 배치되어 있습니다.

### Fix Focus Areas
- apps/web/src/components/PromotionBanner.tsx[13-28]
- apps/web/src/queries/songPromotionQuery.ts[18-26]

### Suggested fix
- `promotions`가 바뀔 때마다 `currentIndex`를 안전하게 보정하세요.
  - 예: `useEffect(() => setCurrentIndex(i => Math.min(i, promotions.length - 1)), [promotions.length])`
  - 또는 렌더에서 `const safeIndex = currentIndex % promotions.length`를 사용하고, `current = promotions[safeIndex]`로 접근
- `current`가 falsy일 때 early return 처리도 추가(방어적으로)

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

Comment on lines +102 to +135
const { data: userData, error: userError } = await supabase
.from('users')
.select('point')
.eq('id', userId)
.single();

if (userError || !userData) throw userError ?? new Error('User not found');

if (userData.point < cost) {
return NextResponse.json(
{
success: false,
error: `포인트가 부족합니다. 필요: ${cost}P, 보유: ${userData.point}P`,
},
{ status: 400 },
);
}

const { error: insertError } = await supabase.from('song_promotions').insert({
song_id,
user_id: userId,
content,
start_date,
end_date,
});

if (insertError) throw insertError;

const { error: pointError } = await supabase
.from('users')
.update({ point: userData.point - cost })
.eq('id', userId);

if (pointError) throw pointError;
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

4. Non-atomic point deduction 🐞 Bug ☼ Reliability

POST /api/songs/promotions가 홍보 insert 후 포인트 update를 별도 쿼리로 실행해, 포인트 update 실패 시 ‘홍보만 등록’되는 불일치가
발생합니다. 또한 point를 먼저 읽어(userData.point) 계산한 뒤 update하여 동시 요청에서 포인트가 중복 차감/무상 홍보가 발생할 수 있습니다.
Agent Prompt
### Issue description
홍보 등록(`song_promotions` insert)과 포인트 차감(`users` update)이 트랜잭션 없이 분리되어 있어, 부분 성공/동시성 경합 시 데이터 불일치(무상 홍보, 포인트 오차)가 발생합니다.

### Issue Context
현재는 `userData.point`를 조회한 뒤 insert하고, 마지막에 `userData.point - cost`로 update합니다. 중간에 실패하거나 동시 요청이 들어오면 정합성이 깨집니다.

### Fix Focus Areas
- apps/web/src/app/api/songs/promotions/route.ts[59-137]

### Suggested fix
- DB 레벨에서 원자적으로 처리하도록 Postgres 함수(RPC)를 만들고, API에서는 `supabase.rpc(...)` 1회로 처리하세요.
  - 함수 내부에서: (a) 사용자 포인트 확인(충분하지 않으면 예외), (b) 포인트 차감, (c) promotion insert를 **단일 트랜잭션**으로 실행
- 최소한의 임시 대응(권장 X): 포인트 update 실패 시 insert된 promotion을 즉시 delete하는 보상 로직을 추가하고, update는 조건부로(예: `point >= cost` 조건 포함) 수행해 경합을 줄이세요.

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

@GulSam00 GulSam00 merged commit f0c31e4 into develop May 8, 2026
2 checks passed
@GulSam00 GulSam00 deleted the feat/223-songCommentAndPromotion branch May 8, 2026 09:50
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