Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/web/src/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ interface Navigation {

const navigation: Navigation[] = [
{ name: '최신 곡', href: '/recent', key: 'RECENT' },

{ name: '부를 곡', href: '/tosing', key: 'TOSING' },
{ name: '검색', href: '/', key: 'SEARCH' },

{ name: '인기곡', href: '/popular', key: 'POPULAR' },
{ name: '지도', href: '/map', key: 'MAP' },
{ name: '정보', href: '/info', key: 'INFO' },
];

Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/app/api/karaoke/favorites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Supabase table required:
// CREATE TABLE karaoke_favorites (
// id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
// user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
// place_id text NOT NULL,
// place_name text NOT NULL,
// address text NOT NULL,
// lat float8 NOT NULL,
// lng float8 NOT NULL,
// created_at timestamptz DEFAULT now(),
// UNIQUE(user_id, place_id)
// );

import { NextResponse } from 'next/server';

import createClient from '@/lib/supabase/server';
import { ApiResponse } from '@/types/apiRoute';
import { KaraokeFavorite } from '@/types/karaoke';
import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser';

export async function GET(): Promise<NextResponse<ApiResponse<KaraokeFavorite[]>>> {
try {
const supabase = await createClient();
const userId = await getAuthenticatedUser(supabase);

const { data, error } = await supabase
.from('karaoke_favorites')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });

if (error) throw error;

return NextResponse.json({ success: true, data: data ?? [] });
} catch (error) {
if (error instanceof Error && error.cause === 'auth') {
return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
}
return NextResponse.json({ success: false, error: 'Failed to get favorites' }, { status: 500 });
}
}

export async function POST(request: Request): Promise<NextResponse<ApiResponse<void>>> {
try {
const supabase = await createClient();
const userId = await getAuthenticatedUser(supabase);

const { placeId, placeName, address, lat, lng } = await request.json();

const { error } = await supabase.from('karaoke_favorites').insert({
user_id: userId,
place_id: placeId,
place_name: placeName,
address,
lat,
lng,
});

if (error) throw error;

return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof Error && error.cause === 'auth') {
return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
}
return NextResponse.json({ success: false, error: 'Failed to add favorite' }, { status: 500 });
}
}

export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> {
try {
const supabase = await createClient();
const userId = await getAuthenticatedUser(supabase);

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

const { error } = await supabase
.from('karaoke_favorites')
.delete()
.match({ user_id: userId, place_id: placeId });

if (error) throw error;

return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof Error && error.cause === 'auth') {
return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 });
}
return NextResponse.json({ success: false, error: 'Failed to delete favorite' }, { status: 500 });
}
}
12 changes: 12 additions & 0 deletions apps/web/src/app/map/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client';

import KakaoMap from '@/components/KakaoMap';

export default function MapPage() {
return (
<div className="flex h-full flex-col">
<h1 className="mb-3 text-lg font-bold">노래방 찾기</h1>
<KakaoMap />
</div>
);
}
1 change: 1 addition & 0 deletions apps/web/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ALLOW_PATHS = [
'/recent',
'/tosing',
'/update-password',
'/map',
];

export default function AuthProvider({ children }: { children: React.ReactNode }) {
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/components/KakaoMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import Script from 'next/script';
import { toast } from 'sonner';

import { Button } from '@/components/ui/button';
import { useKakaoMap } from '@/hooks/useKakaoMap';
import {
useAddKaraokeFavoriteMutation,
useDeleteKaraokeFavoriteMutation,
useKaraokeFavoritesQuery,
} from '@/queries/karaokeQuery';
import useAuthStore from '@/stores/useAuthStore';

export default function KakaoMap() {
const { mapRef, selectedPlace, setIsScriptLoaded } = useKakaoMap();

const { isAuthenticated } = useAuthStore();
const { data: favorites = [] } = useKaraokeFavoritesQuery(isAuthenticated);
const addFavorite = useAddKaraokeFavoriteMutation();
const deleteFavorite = useDeleteKaraokeFavoriteMutation();

const favoriteIds = new Set(favorites.map(f => f.place_id));

const handleFavoriteToggle = () => {
if (!isAuthenticated) {
toast.error('로그인이 필요합니다.');
return;
}
if (!selectedPlace) return;

const isFav = favoriteIds.has(selectedPlace.id);
if (isFav) {
deleteFavorite.mutate(selectedPlace.id, {
onSuccess: () => toast.success(`${selectedPlace.place_name} 즐겨찾기 삭제`),
});
} else {
addFavorite.mutate(
{
placeId: selectedPlace.id,
placeName: selectedPlace.place_name,
address: selectedPlace.road_address_name || selectedPlace.address_name,
lat: Number(selectedPlace.y),
lng: Number(selectedPlace.x),
},
{ onSuccess: () => toast.success(`${selectedPlace.place_name} 즐겨찾기 추가`) },
);
}
};

return (
<>
<Script
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`}
onLoad={() => setIsScriptLoaded(true)}
/>
Comment on lines +53 to +56
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Claude.md missing map docs 📘 Rule violation ⚙ Maintainability

The PR introduces a new public route (/map) and a required env var (NEXT_PUBLIC_KAKAO_MAP_KEY)
but does not update apps/web/CLAUDE.md to reflect these changes. This creates onboarding/setup
drift and can cause local runs to fail or behave unexpectedly.
Agent Prompt
## Issue description
`apps/web/CLAUDE.md` is outdated relative to the PR changes (new public route and new required env var).

## Issue Context
- `/map` was added to public allow paths.
- `NEXT_PUBLIC_KAKAO_MAP_KEY` is now required for Kakao map loading.

## Fix Focus Areas
- apps/web/CLAUDE.md[59-62]
- apps/web/CLAUDE.md[89-98]
- apps/web/src/auth.tsx[13-16]
- apps/web/src/components/KakaoMap.tsx[53-56]

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


<div className="flex h-full flex-col gap-3">
<div ref={mapRef} className="w-full flex-1 rounded-lg" style={{ minHeight: '60vh' }} />

{selectedPlace && (
<div className="bg-card rounded-lg border p-4">
<p className="font-semibold">{selectedPlace.place_name}</p>
<p className="text-muted-foreground mt-1 text-sm">
{selectedPlace.road_address_name || selectedPlace.address_name}
</p>
{selectedPlace.phone && (
<p className="text-muted-foreground text-sm">{selectedPlace.phone}</p>
)}
<Button
className="mt-3 w-full"
variant={favoriteIds.has(selectedPlace.id) ? 'destructive' : 'default'}
onClick={handleFavoriteToggle}
disabled={addFavorite.isPending || deleteFavorite.isPending}
>
{favoriteIds.has(selectedPlace.id) ? '즐겨찾기 삭제' : '즐겨찾기 추가'}
</Button>
</div>
)}

{favorites.length > 0 && (
<div className="bg-card rounded-lg border p-4">
<p className="mb-2 font-semibold">즐겨찾기 ({favorites.length})</p>
<ul className="flex flex-col gap-2">
{favorites.map(fav => (
<li key={fav.place_id} className="flex items-center justify-between text-sm">
<span>{fav.place_name}</span>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground h-auto px-2 py-1 text-xs"
onClick={() =>
deleteFavorite.mutate(fav.place_id, {
onSuccess: () => toast.success(`${fav.place_name} 삭제`),
})
}
>
삭제
</Button>
</li>
))}
</ul>
</div>
)}
Comment on lines +81 to +104
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. Karaoke favorites page missing 📎 Requirement gap ≡ Correctness

There is no dedicated page/route that displays the user's saved karaoke favorites; favorites are
only shown inline within the map component. This does not meet the requirement for a standalone
favorites list page.
Agent Prompt
## Issue description
A dedicated karaoke favorites list page/route is missing; favorites are only shown within the map UI.

## Issue Context
Compliance requires a standalone page that fetches and displays favorites stored in Supabase (via the internal `/api/karaoke/favorites` route).

## Fix Focus Areas
- apps/web/src/app/map/page.tsx[1-12]
- apps/web/src/components/KakaoMap.tsx[81-104]

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

</div>
</>
);
}
87 changes: 87 additions & 0 deletions apps/web/src/hooks/useKakaoMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client';

/* eslint-disable @typescript-eslint/no-explicit-any */

import { useCallback, useEffect, useRef, useState } from 'react';

import { KakaoPlace } from '@/types/karaoke';

declare global {
interface Window {
kakao: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
}

export function useKakaoMap() {
const mapRef = useRef<HTMLDivElement>(null);
const [selectedPlace, setSelectedPlace] = useState<KakaoPlace | null>(null);
const [isScriptLoaded, setIsScriptLoaded] = useState(false);

const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => {
const ps = new window.kakao.maps.services.Places();
const center = new window.kakao.maps.LatLng(lat, lng);
const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });

ps.keywordSearch(
'노래방',
(data: KakaoPlace[], status: string) => {
if (status !== window.kakao.maps.services.Status.OK) return;

data.forEach(place => {
const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x));
const marker = new window.kakao.maps.Marker({ map, position });

window.kakao.maps.event.addListener(marker, 'click', () => {
infowindow.close();
setSelectedPlace(place);
infowindow.setContent(
`<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`,
);
infowindow.open(map, marker);
});
Comment on lines +34 to +41
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. Infowindow xss risk 🐞 Bug ⛨ Security

useKakaoMap()이 Kakao InfoWindow content를 HTML 문자열로 만들면서 외부 데이터인 place.place_name을 그대로 보간해 DOM에
주입합니다. 악의적인 place_name 값이 들어오면 InfoWindow 영역에서 스크립트/HTML 실행(XSS)로 이어질 수 있습니다.
Agent Prompt
### Issue description
`infowindow.setContent()`에 외부 데이터(`place.place_name`)를 HTML 문자열로 직접 삽입해 XSS가 가능해집니다.

### Issue Context
Kakao Maps InfoWindow는 문자열 content를 HTML로 렌더링합니다. 따라서 place_name을 HTML-escape 하거나, 문자열 대신 DOM 노드를 만들어 `textContent`로 넣어야 합니다.

### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[34-41]

### Suggested approach
- `const div = document.createElement('div'); div.style...; div.textContent = place.place_name; infowindow.setContent(div);` 처럼 DOM node 기반으로 설정
- 또는 최소한 HTML escape 유틸을 만들어 `${escapeHtml(place.place_name)}` 적용

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

Comment on lines +34 to +41
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. Kakao infowindow xss 🐞 Bug ⛨ Security

useKakaoMap()에서 외부(Kakao Places API)로부터 온 place_name을 HTML 문자열에 그대로 삽입해 InfoWindow.setContent()에 넘겨
DOM 기반 XSS가 가능합니다. 악성 place_name이 포함되면 스크립트 실행/세션 탈취 등으로 이어질 수 있습니다.
Agent Prompt
### Issue description
`InfoWindow.setContent()`에 전달하는 문자열에 `place.place_name`을 그대로 삽입하고 있어, 외부 데이터 기반 XSS가 가능합니다.

### Issue Context
Kakao Places API 응답은 신뢰할 수 없는 입력으로 취급해야 합니다. `setContent()`에는 HTML 문자열 대신 DOM 노드를 사용하거나, 반드시 HTML escaping/sanitization을 적용해야 합니다.

### Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[25-41]

### Suggested fix
- 가능하면 DOM 엘리먼트를 만들어 `textContent`로 값을 세팅한 뒤 `setContent(element)`로 전달하세요.
  - 예: `const el = document.createElement('div'); el.style...; el.textContent = place.place_name; infowindow.setContent(el);`
- HTML 문자열을 유지해야 한다면 최소한 `place_name`을 HTML escape 처리(예: `& < > " '` 변환)하거나, 프로젝트 정책에 맞는 sanitizer(예: DOMPurify)를 적용하세요.

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

});
},
{
location: center,
radius: 1000,
sort: window.kakao.maps.services.SortBy.DISTANCE,
},
);
Comment on lines +20 to +49
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. Kakao places called in browser 📘 Rule violation ⛨ Security

Client code directly loads the Kakao Maps SDK and calls Places().keywordSearch() in the browser,
which is an external API interaction outside /api/*. This violates the BFF requirement and
bypasses centralized auth/validation and request controls.
Agent Prompt
## Issue description
Browser/client code is directly calling an external API (Kakao Places via JS SDK) instead of routing through internal `/api/*` endpoints (BFF pattern).

## Issue Context
`useKakaoMap` uses `new window.kakao.maps.services.Places()` and `keywordSearch()`, and `KakaoMap` loads Kakao's SDK script in the client.

## Fix Focus Areas
- apps/web/src/hooks/useKakaoMap.ts[20-49]
- apps/web/src/components/KakaoMap.tsx[53-56]
- apps/web/src/app/api/[...]/route.ts[1-200]

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

Comment on lines +20 to +49
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 place search input 📎 Requirement gap ≡ Correctness

The new /map UI only performs a fixed keyword search ('노래방') around the current/fallback
location and does not provide a user-driven place search input/results UI. This fails the
requirement for a location search page that includes place search functionality.
Agent Prompt
## Issue description
`/map` currently has no user-driven place search UI (input/results); it only runs a fixed keyword search (`'노래방'`).

## Issue Context
Compliance requires a location search page with map display + place search UI/logic (search input and results display).

## Fix Focus Areas
- apps/web/src/components/KakaoMap.tsx[51-60]
- apps/web/src/hooks/useKakaoMap.ts[20-49]

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

}, []);

const initMap = useCallback(() => {
if (!mapRef.current || !window.kakao) return;

window.kakao.maps.load(() => {
const options = {
center: new window.kakao.maps.LatLng(37.5665, 126.978),
level: 4,
};
const map = new window.kakao.maps.Map(mapRef.current, options);

if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
map.setCenter(new window.kakao.maps.LatLng(lat, lng));
searchNearbyKaraoke(map, lat, lng);
},
() => {
searchNearbyKaraoke(map, 37.5665, 126.978);
},
);
} else {
searchNearbyKaraoke(map, 37.5665, 126.978);
}
});
}, [searchNearbyKaraoke]);

useEffect(() => {
if (isScriptLoaded) {
initMap();
}
}, [isScriptLoaded, initMap]);

return { mapRef, selectedPlace, isScriptLoaded, setIsScriptLoaded };
}
25 changes: 25 additions & 0 deletions apps/web/src/lib/api/karaokeMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiResponse } from '@/types/apiRoute';
import { KaraokeFavorite } from '@/types/karaoke';

import { instance } from './client';

export async function getKaraokeFavorites() {
const response = await instance.get<ApiResponse<KaraokeFavorite[]>>('/karaoke/favorites');
return response.data;
}

export async function postKaraokeFavorite(body: {
placeId: string;
placeName: string;
address: string;
lat: number;
lng: number;
}) {
const response = await instance.post<ApiResponse<void>>('/karaoke/favorites', body);
return response.data;
}

export async function deleteKaraokeFavorite(body: { placeId: string }) {
const response = await instance.delete<ApiResponse<void>>('/karaoke/favorites', { data: body });
return response.data;
}
47 changes: 47 additions & 0 deletions apps/web/src/queries/karaokeQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

import {
deleteKaraokeFavorite,
getKaraokeFavorites,
postKaraokeFavorite,
} from '@/lib/api/karaokeMap';

export function useKaraokeFavoritesQuery(isAuthenticated: boolean) {
return useQuery({
queryKey: ['karaokeFavorites'],
queryFn: async () => {
const response = await getKaraokeFavorites();
if (!response.success) return [];
return response.data ?? [];
},
enabled: isAuthenticated,
});
}

export function useAddKaraokeFavoriteMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (body: {
placeId: string;
placeName: string;
address: string;
lat: number;
lng: number;
}) => postKaraokeFavorite(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['karaokeFavorites'] });
},
});
}

export function useDeleteKaraokeFavoriteMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (placeId: string) => deleteKaraokeFavorite({ placeId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['karaokeFavorites'] });
},
});
}
Loading