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
81 changes: 81 additions & 0 deletions apps/web/src/app/api/songs/tosing/merge/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse<ApiResponse<{ merged: number }>>> {
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<string>(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 },
);
}
}
3 changes: 3 additions & 0 deletions apps/web/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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);

Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/hooks/useMergeGuestToSing.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
8 changes: 8 additions & 0 deletions apps/web/src/lib/api/tosing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse<{ merged: number }>>(
'/songs/tosing/merge',
body,
);
return response.data;
}

export async function deleteToSingSong(body: { songId: string }) {
const response = await instance.delete<ApiResponse<void>>('/songs/tosing', { data: body });
return response.data;
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/queries/tosingSongQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getToSingSong,
patchToSingSong,
postToSingSongArray,
postToSingSongMerge,
} from '@/lib/api/tosing';
import { ToSingSong } from '@/types/song';

Expand Down Expand Up @@ -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();
Expand Down