diff --git a/apps/web/src/app/api/songs/tosing/merge/route.ts b/apps/web/src/app/api/songs/tosing/merge/route.ts new file mode 100644 index 00000000..b0148045 --- /dev/null +++ b/apps/web/src/app/api/songs/tosing/merge/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; + +import createClient from '@/lib/supabase/server'; +import { ApiResponse } from '@/types/apiRoute'; +import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; + +// 게스트 목록은 localStorage에 무한정 쌓일 수 있어 한 번에 옮길 양을 막아둔다. +const MAX_MERGE_COUNT = 100; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * `/songs/tosing/array`를 쓰지 않는 이유는 이 요청만 중복·유령 곡을 만나기 때문이다. + * 모달에서 담을 때는 `isInToSingList`가 클라이언트에서 걸러주지만, 병합은 이미 담아둔 + * 곡과 겹치고 브라우저가 오래 들고 있던 삭제된 곡 id도 섞인다. 둘 중 하나만 있어도 + * 배치 insert 전체가 깨지고, 그러면 로컬이 비워지지 않아 방문할 때마다 같은 실패를 + * 반복한다. 그래서 넣기 전에 서버에서 거른다. + */ +export async function POST( + request: Request, +): Promise>> { + try { + const supabase = await createClient(); + const userId = await getAuthenticatedUser(supabase); + + const { songIds } = await request.json(); + if (!Array.isArray(songIds) || songIds.length === 0) { + return NextResponse.json({ success: true, data: { merged: 0 } }); + } + + const ids = [...new Set(songIds)].slice(0, MAX_MERGE_COUNT); + + const { data: realSongs, error: songError } = await supabase + .from('songs') + .select('id') + .in('id', ids); + if (songError) throw songError; + + const { data: mine, error: mineError } = await supabase + .from('tosings') + .select('song_id, order_weight') + .eq('user_id', userId); + if (mineError) throw mineError; + + const realIds = new Set((realSongs ?? []).map(row => row.id)); + const mineIds = new Set((mine ?? []).map(row => row.song_id)); + + // 게스트가 잡아둔 순서를 유지한 채, 이미 담긴 곡과 사라진 곡만 걸러낸다 + const targets = ids.filter(id => realIds.has(id) && !mineIds.has(id)); + if (targets.length === 0) { + return NextResponse.json({ success: true, data: { merged: 0 } }); + } + + // 기존 목록 뒤에 붙인다 — 계정에 있던 순서가 밀리지 않게 + const lastWeight = (mine ?? []).reduce((max, row) => Math.max(max, row.order_weight), 0); + + const { error } = await supabase.from('tosings').insert( + targets.map((songId, index) => ({ + user_id: userId, + song_id: songId, + order_weight: lastWeight + index + 1, + })), + ); + if (error) throw error; + + return NextResponse.json({ success: true, data: { merged: targets.length } }); + } catch (error) { + if (error instanceof Error && error.cause === 'auth') { + return NextResponse.json( + { success: false, error: 'User not authenticated' }, + { status: 401 }, + ); + } + + console.error('Error in tosing merge API:', error); + return NextResponse.json( + { success: false, error: 'Failed to merge tosing songs' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index 41165455..463126f0 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -3,6 +3,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import useMergeGuestToSing from '@/hooks/useMergeGuestToSing'; import useAuthStore from '@/stores/useAuthStore'; const ALLOW_PATHS = [ @@ -24,6 +25,8 @@ export default function AuthProvider({ children }: { children: React.ReactNode } const { checkAuth } = useAuthStore(); const [isAuthChecked, setIsAuthChecked] = useState(false); + useMergeGuestToSing(); + useEffect(() => { const isPublicPath = ALLOW_PATHS.includes(pathname); diff --git a/apps/web/src/hooks/useMergeGuestToSing.ts b/apps/web/src/hooks/useMergeGuestToSing.ts new file mode 100644 index 00000000..9798d151 --- /dev/null +++ b/apps/web/src/hooks/useMergeGuestToSing.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; + +import { useMergeGuestToSingMutation } from '@/queries/tosingSongQuery'; +import useAuthStore from '@/stores/useAuthStore'; +import useGuestToSingStore from '@/stores/useGuestToSingStore'; + +/** + * 게스트로 담아둔 부를 곡을 로그인 계정으로 옮긴다. + * + * "로그인 성공" 이벤트를 잡는 대신 "로그인 상태에서는 게스트 목록이 비어 있다"는 + * 불변식을 지킨다. 로그인 방식마다 끝나는 모습이 달라서다 — 이메일/비밀번호는 + * checkAuth()가 상태만 뒤집고 화면이 그대로 이어지는 반면, 카카오와 가입 확인 링크는 + * 서버 리다이렉트라 전체 페이지가 다시 뜬다. 상태 조건 하나면 두 경우가 함께 덮이고, + * 로그인 수단이 늘어도 checkAuth()만 거치면 따라온다. + * + * 성공했을 때만 로컬을 비운다 — 실패하면 다음 방문에서 다시 시도하고, 그 사이에도 + * 사용자의 곡은 localStorage에 그대로 남는다. + */ +export default function useMergeGuestToSing() { + const { isAuthenticated } = useAuthStore(); + const { guestToSingSongs, clearGuestToSingSongs } = useGuestToSingStore(); + const { mutate } = useMergeGuestToSingMutation(); + + // StrictMode의 이중 실행과 리렌더로 인한 중복 요청을 막는다 + const isMergingRef = useRef(false); + + useEffect(() => { + if (!isAuthenticated || guestToSingSongs.length === 0) return; + if (isMergingRef.current) return; + + isMergingRef.current = true; + mutate( + guestToSingSongs.map(item => item.songs.id), + { + onSuccess: response => { + if (!response.success) { + isMergingRef.current = false; + return; + } + + clearGuestToSingSongs(); + + const merged = response.data?.merged ?? 0; + if (merged > 0) { + toast.success('담아둔 곡을 옮겼어요', { + description: `부를 곡 목록에 ${merged}곡을 추가했어요.`, + }); + } + }, + onError: () => { + isMergingRef.current = false; + }, + }, + ); + }, [isAuthenticated, guestToSingSongs, mutate, clearGuestToSingSongs]); +} diff --git a/apps/web/src/lib/api/tosing.ts b/apps/web/src/lib/api/tosing.ts index ed25688a..8cf304dd 100644 --- a/apps/web/src/lib/api/tosing.ts +++ b/apps/web/src/lib/api/tosing.ts @@ -30,6 +30,14 @@ export async function postToSingSongArray(body: { songIds: string[] }) { return response.data; } +export async function postToSingSongMerge(body: { songIds: string[] }) { + const response = await instance.post>( + '/songs/tosing/merge', + body, + ); + return response.data; +} + export async function deleteToSingSong(body: { songId: string }) { const response = await instance.delete>('/songs/tosing', { data: body }); return response.data; diff --git a/apps/web/src/queries/tosingSongQuery.ts b/apps/web/src/queries/tosingSongQuery.ts index dd6f0baa..df8b9d23 100644 --- a/apps/web/src/queries/tosingSongQuery.ts +++ b/apps/web/src/queries/tosingSongQuery.ts @@ -5,6 +5,7 @@ import { getToSingSong, patchToSingSong, postToSingSongArray, + postToSingSongMerge, } from '@/lib/api/tosing'; import { ToSingSong } from '@/types/song'; @@ -46,6 +47,24 @@ export function usePostToSingSongMutation() { }); } +// 게스트로 담아둔 곡을 로그인 계정으로 병합 +// 실패해도 로컬을 비우지 않아야 재시도가 가능하므로, 성공 판정은 호출부에서 한다. +export function useMergeGuestToSingMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (songIds: string[]) => postToSingSongMerge({ songIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['toSingSong'] }); + queryClient.invalidateQueries({ queryKey: ['searchSong'] }); + }, + onError: error => { + // 사용자가 시킨 동작이 아니라 배경에서 도는 병합이라 alert로 막지 않는다 + console.error('게스트 부를 곡 병합 실패:', error); + }, + }); +} + // 부를 노래 삭제 export function useDeleteToSingSongMutation() { const queryClient = useQueryClient();