Feat : 곡 댓글 및 홍보(전광판) 기능 추가 (#223)#232
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <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>
|
/describe |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
/review |
|
/improve |
Code Review by Qodo
Context used✅ Tickets:
🎫 곡 댓글 및 홍보(전광판) 기능 추가 1. DELETE comments not /[id]
|
Code Review by Qodo
Context used✅ Tickets:
🎫 곡 댓글 및 홍보(전광판) 기능 추가 1. Comments DELETE not /[id]
|
Review Summary by QodoAdd song comments and promotion (billboard) features with date-based scheduling and point system
WalkthroughsDescription• 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 Diagramflowchart 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
File Changes1. apps/web/src/app/api/songs/comments/route.ts
|
Code Review by Qodo
Context used✅ Tickets:
🎫 곡 댓글 및 홍보(전광판) 기능 추가 1. No song_comments migration added
|
|
PR Description updated to latest commit (bee96d6)
ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan |
| 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 }); |
There was a problem hiding this comment.
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
| 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 }); | ||
|
|
There was a problem hiding this comment.
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
| 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 }); | ||
| } |
There was a problem hiding this comment.
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
| '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({ |
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
| 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; | ||
|
|
There was a problem hiding this comment.
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
| 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 }); | ||
| } | ||
|
|
There was a problem hiding this comment.
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
| '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 |
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
| 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; | ||
|
|
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
| 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) { |
There was a problem hiding this comment.
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
| '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']; |
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
| 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; |
There was a problem hiding this comment.
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
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 전역 배치date-fns적용src/utils/kst.ts— KST 시간대 유틸리티 추가💬 추가 참고 사항
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-pickerandradix-uifor enhanced UI capabilitiesImproves security by moving OpenAI client initialization into request handler
Updates documentation for GitHub issue workflow and component modification guidelines
Diagram Walkthrough
File Walkthrough
15 files
route.ts
Song comments API endpoints with CRUD operationsapps/web/src/app/api/songs/comments/route.ts
GETto fetchcomments by
songId,POSTto create new comments (auth required, max100 chars), and
DELETEto remove user's own commentssong_commentstable with user nicknames viajoin, ordered by creation date descending
authentication checks with appropriate error responses
route.ts
Song promotions API with date-based filtering and point systemapps/web/src/app/api/songs/promotions/route.ts
GETendpoint to retrieve active song promotions filtered byKST date range, returning promotion details with song and user
information
POSTendpoint to create promotions with validation forcontent length (max 50 chars), date constraints (start date must be
tomorrow or later), and point deduction (50P per day)
updates user point balance upon successful creation
songCommentQuery.ts
React Query hooks for song comment operationsapps/web/src/queries/songCommentQuery.ts
useSongCommentsQueryhook to fetch comments for a specificsong with conditional enabling
usePostSongCommentMutationwith success toast and queryinvalidation
useDeleteSongCommentMutationwith optimistic UI updates(removes comment immediately, reverts on error)
songPromotionQuery.ts
React Query hooks for song promotion operationsapps/web/src/queries/songPromotionQuery.ts
useSongPromotionsQueryhook to fetch active promotions with1-minute stale time
usePostSongPromotionMutationthat invalidates bothpromotion and user check-in queries on success
kst.ts
KST timezone utility functions for date handlingapps/web/src/utils/kst.ts
getTodayKST()function returning today's date in 'YYYY-MM-DD'format adjusted to KST timezone
getTomorrowKST()function returning tomorrow's date in'YYYY-MM-DD' format adjusted to KST timezone
getTomorrowKSTDate()function returning tomorrow's date as alocal midnight Date object for calendar disabled prop usage
songComment.ts
API client functions for song comment operationsapps/web/src/lib/api/songComment.ts
getSongComments()function to fetch comments for a song viaquery parameter
postSongComment()function to create a new comment withsong_idandcontentdeleteSongComment()function to delete a comment bycommentIdusing request body
songPromotion.ts
API client functions for song promotion operationsapps/web/src/lib/api/songPromotion.ts
getSongPromotions()function to fetch all active promotionspostSongPromotion()function to create promotion withsong_id,content,start_date, andend_datecomment.ts
TypeScript interface for song comment data structureapps/web/src/types/comment.ts
SongCommentinterface with fields:id,song_id,user_id,nickname,content, andcreated_atpromotion.ts
TypeScript interface for song promotion data structureapps/web/src/types/promotion.ts
SongPromotioninterface with promotion metadata includingid,song_id,user_id,nickname, song details (title,artist,title_ko,artist_ko),content, and date range (start_date,end_date)SongPromotionModal.tsx
Song promotion modal with calendar date picker and confirmationapps/web/src/components/SongPromotionModal.tsx
using
react-day-pickerCalendarcalculates cost (50P per day) with point validation
confirmation with point deduction summary
if user lacks sufficient points
SearchResultCard.tsx
Integrate song promotion and comment features into search resultsapps/web/src/app/search/SearchResultCard.tsx
Megaphoneicon import andSongPromotionModalcomponent importvia new
withAuthhelper functionSongCommentSectioncomponent below action buttons when cardis expanded
SongPromotionModalwithAuthhelper functioncalendar.tsx
Calendar UI component with range picker supportapps/web/src/components/ui/calendar.tsx
react-day-pickerwith customizablestyling and range selection support
CalendarDayButtonsub-component with focus management anddata attributes for range/selection states
Tailwind CSS styling
lucide-reactfor navigation controlsSongCommentSection.tsx
Song comment section with CRUD operationsapps/web/src/components/SongCommentSection.tsx
authentication checks
optimistic UI updates
functionality for own comments
useSongCommentsQuery,usePostSongCommentMutation, anduseDeleteSongCommentMutationhooksPromotionBanner.tsx
Promotion banner with auto-rotating carouselapps/web/src/components/PromotionBanner.tsx
3-second rotation interval
fallback), user nickname, and date range
framer-motionwithAnimatePresenceuseSongPromotionsQueryhooklayout.tsx
Global promotion banner integration in layoutapps/web/src/app/layout.tsx
PromotionBannercomponent globally in rootlayout
management for proper interaction
1 files
route.ts
Move OpenAI client initialization into request handlerapps/web/src/app/api/chat/route.ts
POSThandler function
improving security and lazy initialization
2 files
pnpm-lock.yaml
Add UI and date picker dependencies to lock filepnpm-lock.yaml
radix-uipackage (v1.4.3) with comprehensive UI componentdependencies
react-day-pickerpackage (v9.14.0) for calendar date rangeselection
@date-fns/tz,date-fns-jalali,@tabby_ai/hijri-converterfor date handlingpackage.json
Add calendar and UI library dependenciesapps/web/package.json
radix-ui(^1.4.3) dependency for UI component primitivesreact-day-picker(^9.14.0) dependency for calendar functionality2 files
settings.json
Configure Claude permissions settings.claude/settings.json
permissionsconfiguration object withdefaultModeset tobypassPermissionssitemap-0.xml
Sitemap timestamp updateapps/web/public/sitemap-0.xml
(2026-05-04T08:44:32.892Z)
2 files
start.md
GitHub issue creation and tracking workflow documentation.claude/commands/start.md
format (overview + checklist)
gh issue editcommand to trackprogress
CLAUDE.md
Clarify shadcn/ui component modification guidelinesapps/web/CLAUDE.md
convention changes after review
standardization is permitted